Enhance scratchbox storage metadata management
CI / Go Tests (push) Successful in 14s
CI / Build (push) Successful in 8s
Format / gofmt (push) Failing after 4s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 14s
Release Artifacts / Build and release Docker image (push) Successful in 24s

- Updated `README.md` to clarify the distinction between public scratch IDs and internal blob IDs.
- Modified `filesystem.go` to introduce a new `BlobID` field in the `Metadata` struct, ensuring the separation of public and internal identifiers.
- Implemented functions to generate and reserve unique public IDs for scratch entries, improving metadata integrity.
- Adjusted tests in `filesystem_test.go` to validate the new ID structure and ensure public IDs do not match blob hashes.
This commit is contained in:
2026-06-01 05:50:37 -05:00
parent 4579443d09
commit a3162a3e5f
3 changed files with 62 additions and 19 deletions
+1 -1
View File
@@ -294,4 +294,4 @@ Scratchbox storage is designed for a single process instance per `storage.data_d
- Do not run multiple scratchbox processes against the same data directory.
- Metadata/index writes are process-local and are not coordinated with cross-process locking.
- Scratch payload files and metadata index are encrypted at rest.
- Scratch IDs are derived from the SHA-256 of the stored blob (encrypted+gzip payload on disk).
- Public scratch IDs are short random aliases; stored blobs still use SHA-256-derived internal blob IDs on disk.
+55 -15
View File
@@ -21,9 +21,12 @@ import (
)
const (
indexFilename = "metadata"
filesDirName = "scratches"
encryptedChunkSize = 64 * 1024
indexFilename = "metadata"
filesDirName = "scratches"
encryptedChunkSize = 64 * 1024
publicIDLength = 12
publicIDAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
publicIDMaxAttempts = 8
)
var ErrNotFound = errors.New("scratch not found")
@@ -33,6 +36,7 @@ var encryptedIndexMagic = [4]byte{'S', 'M', 'D', '1'}
type Metadata struct {
ID string `json:"id"`
BlobID string `json:"blob_id,omitempty"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
@@ -146,8 +150,8 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
}
id := hex.EncodeToString(blobHash.Sum(nil))
finalPath := filepath.Join(s.filesDir, id)
blobID := hex.EncodeToString(blobHash.Sum(nil))
finalPath := filepath.Join(s.filesDir, blobID)
if _, statErr := os.Stat(finalPath); statErr == nil {
_ = os.Remove(tempFile.Name())
return Metadata{}, errors.New("scratch id collision for content hash")
@@ -161,8 +165,17 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
}
now := time.Now().UTC()
s.mu.Lock()
publicID, err := reservePublicID(s.metadata)
if err != nil {
s.mu.Unlock()
_ = os.Remove(finalPath)
return Metadata{}, err
}
meta := Metadata{
ID: id,
ID: publicID,
BlobID: blobID,
FilePath: finalPath,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
@@ -170,16 +183,9 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
OriginalName: normalizeOriginalName(originalName),
Size: size,
}
s.mu.Lock()
if _, exists := s.metadata[id]; exists {
s.mu.Unlock()
_ = os.Remove(finalPath)
return Metadata{}, errors.New("scratch id collision in metadata index")
}
s.metadata[id] = meta
s.metadata[publicID] = meta
if err := s.persistLocked(); err != nil {
delete(s.metadata, id)
delete(s.metadata, publicID)
s.mu.Unlock()
_ = os.Remove(finalPath)
return Metadata{}, err
@@ -314,6 +320,15 @@ func (s *FilesystemStore) loadIndex() error {
if doc.Scratches == nil {
doc.Scratches = map[string]Metadata{}
}
for key, meta := range doc.Scratches {
if strings.TrimSpace(meta.ID) == "" {
meta.ID = key
}
if strings.TrimSpace(meta.BlobID) == "" {
meta.BlobID = filepath.Base(meta.FilePath)
}
doc.Scratches[key] = meta
}
s.metadata = doc.Scratches
if ttlRaw := strings.TrimSpace(doc.DefaultTTL); ttlRaw != "" {
ttl, parseErr := time.ParseDuration(ttlRaw)
@@ -582,6 +597,31 @@ func equalBytes(a, b []byte) bool {
return true
}
func reservePublicID(existing map[string]Metadata) (string, error) {
for i := 0; i < publicIDMaxAttempts; i++ {
id, err := generatePublicID()
if err != nil {
return "", err
}
if _, exists := existing[id]; !exists {
return id, nil
}
}
return "", errors.New("unable to reserve unique public id")
}
func generatePublicID() (string, error) {
buf := make([]byte, publicIDLength)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate public id bytes: %w", err)
}
alphabetLen := byte(len(publicIDAlphabet))
for i := range buf {
buf[i] = publicIDAlphabet[int(buf[i]%alphabetLen)]
}
return string(buf), nil
}
func encryptIndexPayload(plaintext []byte, aead cipher.AEAD) ([]byte, error) {
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
+6 -3
View File
@@ -123,9 +123,12 @@ func TestCreateEncryptedStoreDoesNotExposeGzipHeaderOnDisk(t *testing.T) {
t.Fatalf("ReadFile(encrypted) error = %v", err)
}
sum := sha256.Sum256(filePayload)
wantID := hex.EncodeToString(sum[:])
if meta.ID != wantID {
t.Fatalf("meta.ID = %q, want SHA-256 of encrypted blob %q", meta.ID, wantID)
wantBlobID := hex.EncodeToString(sum[:])
if meta.BlobID != wantBlobID {
t.Fatalf("meta.BlobID = %q, want SHA-256 of encrypted blob %q", meta.BlobID, wantBlobID)
}
if meta.ID == wantBlobID {
t.Fatalf("meta.ID should be a short public id, got blob hash id %q", meta.ID)
}
}