From da59e54261f8aefa9b40efc0aebbfc07e63b7f5a Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 4 Jul 2026 23:37:13 +0200 Subject: [PATCH] feat(depot): backend interface and local backend Implement Backend interface with Probe/Get/Put methods, ProbeState constants, and LocalBackend using filesystem storage with atomic operations and hash verification. Co-Authored-By: Claude Fable 5 --- internal/depot/backend.go | 24 ++++++ internal/depot/local.go | 113 ++++++++++++++++++++++++++++ internal/depot/local_test.go | 139 +++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 internal/depot/backend.go create mode 100644 internal/depot/local.go create mode 100644 internal/depot/local_test.go diff --git a/internal/depot/backend.go b/internal/depot/backend.go new file mode 100644 index 0000000..6e66f6e --- /dev/null +++ b/internal/depot/backend.go @@ -0,0 +1,24 @@ +package depot + +import "context" + +type ProbeState int + +const ( + Present ProbeState = iota + Absent + Unconfirmed +) + +// Backend defines the interface for blob storage backends. +type Backend interface { + // Name returns the backend name. + Name() string + // Probe returns the existence state of one sha. transient=true means a + // retry might change the answer (feeds the serial confirm loop). + Probe(ctx context.Context, sha string) (state ProbeState, transient bool, err error) + // Get fetches sha into dest (temp file + rename), re-hashes, deletes on mismatch. + Get(ctx context.Context, sha, dest string) error + // Put uploads bytes from src for sha. Read-only backends return an error. + Put(ctx context.Context, sha, src string) error +} diff --git a/internal/depot/local.go b/internal/depot/local.go new file mode 100644 index 0000000..eaceeae --- /dev/null +++ b/internal/depot/local.go @@ -0,0 +1,113 @@ +package depot + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" +) + +// LocalBackend implements Backend for local filesystem storage. +type LocalBackend struct { + Root string +} + +// Name returns the backend name. +func (b *LocalBackend) Name() string { + return "local" +} + +// Probe checks if a blob exists. +func (b *LocalBackend) Probe(ctx context.Context, sha string) (ProbeState, bool, error) { + blobPath := filepath.Join(b.Root, BlobKey(sha)) + _, err := os.Stat(blobPath) + if err == nil { + return Present, false, nil + } + if os.IsNotExist(err) { + return Absent, false, nil + } + // Real I/O error (permission, etc.) + return Absent, false, err +} + +// Put copies the file from src to the blob storage. +func (b *LocalBackend) Put(ctx context.Context, sha, src string) error { + blobPath := filepath.Join(b.Root, BlobKey(sha)) + blobDir := filepath.Dir(blobPath) + + // Create parent directories + if err := os.MkdirAll(blobDir, 0755); err != nil { + return err + } + + // Create temp file in the same directory for atomic rename + tmpFile, err := os.CreateTemp(blobDir, ".tmp-") + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) + + // Copy source to temp file + srcFile, err := os.Open(src) + if err != nil { + tmpFile.Close() + return err + } + defer srcFile.Close() + + _, err = io.Copy(tmpFile, srcFile) + tmpFile.Close() + if err != nil { + return err + } + + // Atomic rename + return os.Rename(tmpFile.Name(), blobPath) +} + +// Get fetches the blob, re-hashes it, and deletes it if the hash doesn't match. +func (b *LocalBackend) Get(ctx context.Context, sha, dest string) error { + blobPath := filepath.Join(b.Root, BlobKey(sha)) + + // Create temp file in dest directory for atomic rename + destDir := filepath.Dir(dest) + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(destDir, ".tmp-") + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) + + // Copy and hash simultaneously + srcFile, err := os.Open(blobPath) + if err != nil { + tmpFile.Close() + return err + } + defer srcFile.Close() + + hasher := sha256.New() + teeReader := io.TeeReader(srcFile, hasher) + + _, err = io.Copy(tmpFile, teeReader) + tmpFile.Close() + if err != nil { + return err + } + + // Check hash + gotHash := fmt.Sprintf("%x", hasher.Sum(nil)) + if gotHash != sha { + os.Remove(tmpFile.Name()) + return fmt.Errorf("hash mismatch: expected %s, got %s", sha, gotHash) + } + + // Atomic rename + return os.Rename(tmpFile.Name(), dest) +} diff --git a/internal/depot/local_test.go b/internal/depot/local_test.go new file mode 100644 index 0000000..459a8f8 --- /dev/null +++ b/internal/depot/local_test.go @@ -0,0 +1,139 @@ +package depot + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestLocalRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + backend := &LocalBackend{Root: tmpDir} + + // Generate test data + testData := []byte("hello world") + testSHA := fmt.Sprintf("%x", sha256.Sum256(testData)) + + // Create source file + srcFile := filepath.Join(tmpDir, "source.txt") + if err := os.WriteFile(srcFile, testData, 0644); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + // Put + ctx := context.Background() + if err := backend.Put(ctx, testSHA, srcFile); err != nil { + t.Fatalf("Put failed: %v", err) + } + + // Probe should be Present + state, transient, err := backend.Probe(ctx, testSHA) + if err != nil { + t.Fatalf("Probe failed: %v", err) + } + if state != Present { + t.Fatalf("expected Present, got %v", state) + } + if transient { + t.Fatalf("local backend should never be transient") + } + + // Get + destFile := filepath.Join(tmpDir, "dest.txt") + if err := backend.Get(ctx, testSHA, destFile); err != nil { + t.Fatalf("Get failed: %v", err) + } + + // Verify bytes match + gotData, err := os.ReadFile(destFile) + if err != nil { + t.Fatalf("failed to read dest file: %v", err) + } + if !bytes.Equal(gotData, testData) { + t.Fatalf("data mismatch: want %s, got %s", testData, gotData) + } +} + +func TestLocalProbeAbsent(t *testing.T) { + tmpDir := t.TempDir() + backend := &LocalBackend{Root: tmpDir} + + ctx := context.Background() + state, transient, err := backend.Probe(ctx, "0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d") + if err != nil { + t.Fatalf("Probe should not error on missing blob: %v", err) + } + if state != Absent { + t.Fatalf("expected Absent, got %v", state) + } + if transient { + t.Fatalf("local backend should never be transient") + } +} + +func TestLocalGetHashMismatch(t *testing.T) { + tmpDir := t.TempDir() + backend := &LocalBackend{Root: tmpDir} + + // Create a corrupt blob at the expected path + sha := "0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d0b1d" + blobKey := BlobKey(sha) + blobPath := filepath.Join(tmpDir, blobKey) + + if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { + t.Fatalf("failed to create blob dir: %v", err) + } + if err := os.WriteFile(blobPath, []byte("wrong data"), 0644); err != nil { + t.Fatalf("failed to create blob file: %v", err) + } + + // Get should error + ctx := context.Background() + destFile := filepath.Join(tmpDir, "dest.txt") + err := backend.Get(ctx, sha, destFile) + if err == nil { + t.Fatalf("Get should error on hash mismatch") + } + + // Destination file should not exist + _, err = os.Stat(destFile) + if !os.IsNotExist(err) { + t.Fatalf("dest file should not exist after Get error: %v", err) + } +} + +func TestLocalPutIdempotent(t *testing.T) { + tmpDir := t.TempDir() + backend := &LocalBackend{Root: tmpDir} + + testData := []byte("hello world") + testSHA := fmt.Sprintf("%x", sha256.Sum256(testData)) + + srcFile := filepath.Join(tmpDir, "source.txt") + if err := os.WriteFile(srcFile, testData, 0644); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + ctx := context.Background() + + // Put twice + if err := backend.Put(ctx, testSHA, srcFile); err != nil { + t.Fatalf("first Put failed: %v", err) + } + if err := backend.Put(ctx, testSHA, srcFile); err != nil { + t.Fatalf("second Put failed: %v", err) + } + + // Verify blob exists + state, _, err := backend.Probe(ctx, testSHA) + if err != nil { + t.Fatalf("Probe failed: %v", err) + } + if state != Present { + t.Fatalf("expected Present after Put, got %v", state) + } +}