diff --git a/README.md b/README.md index c0a9489..ae9dfa4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 1f53bb9..4c74cdd 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -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 { diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index 2ad6ffc..c152feb 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -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) } }