Step 12: GardenSync gRPC server with 5 RPC handlers — PushManifest (timestamp comparison, missing blob detection), PushBlobs (chunked streaming, manifest replacement), PullManifest, PullBlobs, Prune. Added store.List() and garden.ListBlobs()/DeleteBlob() for prune. In-process tests via bufconn. Step 12b: Add now recurses directories (walks files/symlinks, skips dir entries). Mirror up syncs filesystem → manifest (add new, remove deleted, rehash changed). Mirror down syncs manifest → filesystem (restore + delete untracked with optional confirm). 7 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
4.5 KiB
Go
165 lines
4.5 KiB
Go
// Package store implements a content-addressable blob store keyed by SHA-256 hash.
|
|
//
|
|
// Blobs are stored in a two-level directory structure under a blobs/ subdirectory:
|
|
//
|
|
// blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
|
|
//
|
|
// The store only handles raw bytes. It does not know about files, paths, or
|
|
// permissions — that is the garden package's job.
|
|
package store
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// validHash reports whether s is a 64-character lowercase hex string.
|
|
func validHash(s string) bool {
|
|
if len(s) != 64 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Store is a content-addressable blob store rooted at a directory on disk.
|
|
type Store struct {
|
|
root string
|
|
}
|
|
|
|
// New creates a Store rooted at root. It ensures the blobs/ subdirectory
|
|
// exists, creating it (and any parents) if needed.
|
|
func New(root string) (*Store, error) {
|
|
blobsDir := filepath.Join(root, "blobs")
|
|
if err := os.MkdirAll(blobsDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("store: create blobs directory: %w", err)
|
|
}
|
|
return &Store{root: root}, nil
|
|
}
|
|
|
|
// Write computes the SHA-256 hash of data, writes the blob to disk, and
|
|
// returns the hex-encoded hash. If a blob with the same hash already exists,
|
|
// this is a no-op (deduplication). Writes are atomic: data is written to a
|
|
// temporary file first, then renamed into place.
|
|
func (s *Store) Write(data []byte) (string, error) {
|
|
sum := sha256.Sum256(data)
|
|
hash := hex.EncodeToString(sum[:])
|
|
|
|
p := s.blobPath(hash)
|
|
|
|
// Deduplication: if the blob already exists, nothing to do.
|
|
if _, err := os.Stat(p); err == nil {
|
|
return hash, nil
|
|
}
|
|
|
|
// Ensure the parent directory exists.
|
|
dir := filepath.Dir(p)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", fmt.Errorf("store: create blob directory: %w", err)
|
|
}
|
|
|
|
// Write to a temp file in the same directory, then rename for atomicity.
|
|
tmp, err := os.CreateTemp(dir, ".blob-*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("store: create temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return "", fmt.Errorf("store: write temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return "", fmt.Errorf("store: close temp file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpName, p); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return "", fmt.Errorf("store: rename blob into place: %w", err)
|
|
}
|
|
|
|
return hash, nil
|
|
}
|
|
|
|
// Read returns the blob contents for the given hash. It returns an error if
|
|
// the hash is malformed or the blob does not exist.
|
|
func (s *Store) Read(hash string) ([]byte, error) {
|
|
if !validHash(hash) {
|
|
return nil, fmt.Errorf("store: invalid hash %q", hash)
|
|
}
|
|
|
|
data, err := os.ReadFile(s.blobPath(hash))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: read blob %s: %w", hash, err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Exists reports whether a blob with the given hash is present in the store.
|
|
// It returns false for malformed hashes.
|
|
func (s *Store) Exists(hash string) bool {
|
|
if !validHash(hash) {
|
|
return false
|
|
}
|
|
_, err := os.Stat(s.blobPath(hash))
|
|
return err == nil
|
|
}
|
|
|
|
// Delete removes the blob file for the given hash. It returns an error if the
|
|
// hash is malformed or the blob does not exist.
|
|
func (s *Store) Delete(hash string) error {
|
|
if !validHash(hash) {
|
|
return fmt.Errorf("store: invalid hash %q", hash)
|
|
}
|
|
|
|
if err := os.Remove(s.blobPath(hash)); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("store: blob %s does not exist: %w", hash, err)
|
|
}
|
|
return fmt.Errorf("store: delete blob %s: %w", hash, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns all blob hashes in the store by walking the blobs directory.
|
|
func (s *Store) List() ([]string, error) {
|
|
blobsDir := filepath.Join(s.root, "blobs")
|
|
var hashes []string
|
|
err := filepath.WalkDir(blobsDir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
name := d.Name()
|
|
if validHash(name) {
|
|
hashes = append(hashes, name)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("store: listing blobs: %w", err)
|
|
}
|
|
return hashes, nil
|
|
}
|
|
|
|
// blobPath returns the filesystem path for a blob with the given hash.
|
|
// Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
|
|
func (s *Store) blobPath(hash string) string {
|
|
return filepath.Join(s.root, "blobs", hash[:2], hash[2:4], hash)
|
|
}
|