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 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 23:37:13 +02:00
co-authored by Claude Fable 5
parent f1ff144740
commit da59e54261
3 changed files with 276 additions and 0 deletions
+113
View File
@@ -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)
}