From 045aa12c0c4dd87a2061fce9b36525daa6032b73 Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Wed, 27 May 2026 13:28:26 -0400 Subject: [PATCH] feat: add content-addressed cache for remote resources Implements a SHA-256 content-addressed cache under .fullsend-cache/resources/sha256// with atomic writes (temp+fsync+rename), integrity re-verification on read against the caller's requested hash, symlink protection via filepath.EvalSymlinks, and strict hash validation (64 lowercase hex chars only). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen --- internal/fetch/cache.go | 190 ++++++++++++++++++++++++++++ internal/fetch/cache_test.go | 234 +++++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 internal/fetch/cache.go create mode 100644 internal/fetch/cache_test.go diff --git a/internal/fetch/cache.go b/internal/fetch/cache.go new file mode 100644 index 0000000000..9d332e3c7c --- /dev/null +++ b/internal/fetch/cache.go @@ -0,0 +1,190 @@ +package fetch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +var errInvalidHash = errors.New("cache: hash must be exactly 64 lowercase hex characters") + +// CacheEntry is metadata for a cached remote resource. +type CacheEntry struct { + URL string `json:"url"` + FetchTime time.Time `json:"fetch_time"` + SHA256 string `json:"sha256"` +} + +// CachePath returns the filesystem path for the cache directory keyed by +// the given content hash. The layout is: +// +// /.fullsend-cache/resources/sha256// +// +// The hash must be exactly 64 lowercase hex characters (SHA-256 digest). +func CachePath(workspaceRoot, hash string) (string, error) { + if err := validateHash(hash); err != nil { + return "", err + } + return filepath.Join(workspaceRoot, ".fullsend-cache", "resources", "sha256", hash), nil +} + +func validateHash(hash string) error { + if len(hash) != 64 { + return errInvalidHash + } + for _, c := range hash { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return errInvalidHash + } + } + return nil +} + +// CacheGet retrieves a previously cached resource by its content hash. +// It returns (nil, nil, nil) on a cache miss (directory or files missing). +// If the cached content fails integrity re-verification, it returns an error. +func CacheGet(workspaceRoot, hash string) ([]byte, *CacheEntry, error) { + dir, err := CachePath(workspaceRoot, hash) + if err != nil { + return nil, nil, err + } + + metadataBytes, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("reading cache metadata: %w", err) + } + + var entry CacheEntry + if err := json.Unmarshal(metadataBytes, &entry); err != nil { + return nil, nil, fmt.Errorf("unmarshaling cache metadata: %w", err) + } + + content, err := os.ReadFile(filepath.Join(dir, "content")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("reading cached content: %w", err) + } + + if err := validateCachePath(workspaceRoot, dir); err != nil { + return nil, nil, err + } + + // Re-verify integrity against the caller's requested hash (the content + // address), not the stored metadata hash — if both content and metadata + // were replaced by an attacker, checking only entry.SHA256 would pass. + if got := ComputeSHA256(content); got != hash { + return nil, nil, fmt.Errorf("cache integrity check failed: expected %s, got %s", hash, got) + } + if entry.SHA256 != hash { + return nil, nil, fmt.Errorf("cache metadata corruption: metadata hash %s does not match requested hash %s", entry.SHA256, hash) + } + + return content, &entry, nil +} + +// CachePut stores content in the content-addressed cache. The content is keyed +// by its SHA-256 hash, so identical content from different URLs shares a single +// cache entry (the last URL wins in metadata — provenance of all source URLs +// is tracked via fetch audit logging, not cache metadata). Both the content +// and metadata files are written atomically using a temp-file-then-rename +// pattern with fsync for durability. +func CachePut(workspaceRoot, url string, content []byte) error { + hash := ComputeSHA256(content) + dir, err := CachePath(workspaceRoot, hash) + if err != nil { + return err + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating cache directory: %w", err) + } + + if err := validateCachePath(workspaceRoot, dir); err != nil { + return err + } + + entry := CacheEntry{ + URL: url, + FetchTime: time.Now().UTC(), + SHA256: hash, + } + + // Write content atomically. + if err := atomicWrite(dir, "content", content); err != nil { + return fmt.Errorf("writing cached content: %w", err) + } + + // Write metadata atomically. + metadataBytes, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("marshaling cache metadata: %w", err) + } + if err := atomicWrite(dir, "metadata.json", metadataBytes); err != nil { + return fmt.Errorf("writing cache metadata: %w", err) + } + + return nil +} + +// validateCachePath resolves symlinks on the cache directory and verifies +// the resolved path stays within the workspace's cache root. +func validateCachePath(workspaceRoot, dir string) error { + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return fmt.Errorf("resolving cache path: %w", err) + } + cacheRoot := filepath.Join(workspaceRoot, ".fullsend-cache") + resolvedRoot, err := filepath.EvalSymlinks(cacheRoot) + if err != nil { + return fmt.Errorf("resolving cache root: %w", err) + } + if !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) { + return fmt.Errorf("cache path escapes cache root: %s", resolved) + } + return nil +} + +// atomicWrite writes data to a temporary file in dir, then renames it to the +// final name. This ensures readers never see a partially-written file. +func atomicWrite(dir, name string, data []byte) error { + tmp, err := os.CreateTemp(dir, name+".tmp.*") + if err != nil { + return err + } + tmpName := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + + if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil { + os.Remove(tmpName) + return err + } + return nil +} diff --git a/internal/fetch/cache_test.go b/internal/fetch/cache_test.go new file mode 100644 index 0000000000..f790a44804 --- /dev/null +++ b/internal/fetch/cache_test.go @@ -0,0 +1,234 @@ +package fetch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCacheRoundTrip(t *testing.T) { + root := t.TempDir() + content := []byte("hello, cache!") + url := "https://example.com/resource.txt" + + err := CachePut(root, url, content) + require.NoError(t, err) + + got, entry, err := CacheGet(root, ComputeSHA256(content)) + require.NoError(t, err) + require.NotNil(t, entry) + + assert.Equal(t, content, got) + assert.Equal(t, url, entry.URL) + assert.Equal(t, ComputeSHA256(content), entry.SHA256) + assert.False(t, entry.FetchTime.IsZero()) +} + +func TestCacheMiss(t *testing.T) { + root := t.TempDir() + hash := ComputeSHA256([]byte("nonexistent")) + + got, entry, err := CacheGet(root, hash) + require.NoError(t, err) + assert.Nil(t, got) + assert.Nil(t, entry) +} + +func TestCachePartialEntry(t *testing.T) { + root := t.TempDir() + hash := ComputeSHA256([]byte("some content")) + dir, err := CachePath(root, hash) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(dir, 0o700)) + + // Write only metadata, no content file. + meta := CacheEntry{URL: "https://example.com/partial", SHA256: hash} + data, err := json.MarshalIndent(meta, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o600)) + + got, entry, err := CacheGet(root, hash) + require.NoError(t, err) + assert.Nil(t, got) + assert.Nil(t, entry) +} + +func TestCacheIntegrityFailure(t *testing.T) { + root := t.TempDir() + content := []byte("original content") + url := "https://example.com/integrity.txt" + + err := CachePut(root, url, content) + require.NoError(t, err) + + hash := ComputeSHA256(content) + dir, err := CachePath(root, hash) + require.NoError(t, err) + contentPath := filepath.Join(dir, "content") + + // Tamper with the cached content. + require.NoError(t, os.WriteFile(contentPath, []byte("tampered!"), 0o600)) + + got, entry, err := CacheGet(root, hash) + require.Error(t, err) + assert.Contains(t, err.Error(), "cache integrity check failed") + assert.Nil(t, got) + assert.Nil(t, entry) +} + +func TestCacheMetadataCorruption(t *testing.T) { + root := t.TempDir() + content := []byte("original content") + url := "https://example.com/integrity.txt" + + err := CachePut(root, url, content) + require.NoError(t, err) + + originalHash := ComputeSHA256(content) + dir, err := CachePath(root, originalHash) + require.NoError(t, err) + + // Replace both content and metadata with a different but internally-consistent file. + replacement := []byte("replaced content") + replacementHash := ComputeSHA256(replacement) + require.NoError(t, os.WriteFile(filepath.Join(dir, "content"), replacement, 0o600)) + meta := CacheEntry{URL: url, SHA256: replacementHash} + data, err := json.MarshalIndent(meta, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o600)) + + // CacheGet should detect that content doesn't match the requested hash. + got, entry, err := CacheGet(root, originalHash) + require.Error(t, err) + assert.Contains(t, err.Error(), "cache integrity check failed") + assert.Nil(t, got) + assert.Nil(t, entry) +} + +func TestCacheSameContentDedup(t *testing.T) { + root := t.TempDir() + content := []byte("identical content") + + err := CachePut(root, "https://example.com/a", content) + require.NoError(t, err) + + err = CachePut(root, "https://example.com/b", content) + require.NoError(t, err) + + hash := ComputeSHA256(content) + path1, err := CachePath(root, hash) + require.NoError(t, err) + path2, err := CachePath(root, hash) + require.NoError(t, err) + assert.Equal(t, path1, path2) + + got, entry, err := CacheGet(root, hash) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, content, got) + + // The second CachePut overwrites metadata, so URL reflects the last write. + assert.Equal(t, "https://example.com/b", entry.URL) +} + +func TestCachePathFormat(t *testing.T) { + hash := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + got, err := CachePath("/workspace", hash) + require.NoError(t, err) + expected := filepath.Join("/workspace", ".fullsend-cache", "resources", "sha256", hash) + assert.Equal(t, expected, got) +} + +func TestCachePathValidation(t *testing.T) { + t.Run("TraversalRejected", func(t *testing.T) { + _, err := CachePath("/workspace", "../../etc/passwd") + require.Error(t, err) + assert.True(t, errors.Is(err, errInvalidHash)) + }) + + t.Run("ShortHashRejected", func(t *testing.T) { + _, err := CachePath("/workspace", "abcdef") + require.Error(t, err) + assert.True(t, errors.Is(err, errInvalidHash)) + }) + + t.Run("UppercaseRejected", func(t *testing.T) { + _, err := CachePath("/workspace", "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890") + require.Error(t, err) + assert.True(t, errors.Is(err, errInvalidHash)) + }) + + t.Run("ValidHash", func(t *testing.T) { + path, err := CachePath("/workspace", "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + require.NoError(t, err) + assert.Contains(t, path, "abcdef1234567890") + }) +} + +func TestCacheSymlinkProtection(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + + content := []byte("symlink test content") + hash := ComputeSHA256(content) + cacheDir := filepath.Join(root, ".fullsend-cache", "resources", "sha256") + require.NoError(t, os.MkdirAll(cacheDir, 0o700)) + + // Plant a symlink in the hash directory pointing outside the cache. + require.NoError(t, os.Symlink(outside, filepath.Join(cacheDir, hash))) + + // CachePut: MkdirAll follows the symlink, then validateCachePath rejects it. + err := CachePut(root, "https://example.com/symlink", content) + require.Error(t, err) + assert.Contains(t, err.Error(), "cache path escapes cache root") + + // For CacheGet, plant metadata+content in the outside dir so reads succeed + // and the symlink check fires after. + meta := CacheEntry{URL: "https://example.com/symlink", SHA256: hash} + data, err := json.MarshalIndent(meta, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(outside, "metadata.json"), data, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "content"), content, 0o600)) + + got, entry, err := CacheGet(root, hash) + require.Error(t, err) + assert.Contains(t, err.Error(), "cache path escapes cache root") + assert.Nil(t, got) + assert.Nil(t, entry) +} + +func TestCacheConcurrentPut(t *testing.T) { + root := t.TempDir() + content := []byte("concurrent content") + hash := ComputeSHA256(content) + + const goroutines = 10 + var wg sync.WaitGroup + errs := make([]error, goroutines) + for i := range goroutines { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = CachePut(root, fmt.Sprintf("https://example.com/%d", idx), content) + }(i) + } + wg.Wait() + + for i, err := range errs { + assert.NoError(t, err, "goroutine %d", i) + } + + got, entry, err := CacheGet(root, hash) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, content, got) + assert.Equal(t, hash, entry.SHA256) +}