diff --git a/cmd/atelet/internal/filecache/doc.go b/cmd/atelet/internal/filecache/doc.go new file mode 100644 index 0000000000..4ecb3e73e8 --- /dev/null +++ b/cmd/atelet/internal/filecache/doc.go @@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package filecache is a node-local disk cache of downloaded artifacts, +// keyed by opaque identities (see Key). An artifact wanted by N concurrent +// callers is fetched once, publication into the cache is atomic and +// crash-safe, and entries are evicted under byte-budget pressure without +// breaking consumers. +// +// On-disk layout, under a store's root (which the store owns exclusively): +// +// entries// +// data # the cached file (or directory tree) +// meta.json # canonical key + creation time; debugging only +// tmp/ # in-flight fetches; same filesystem as entries/, so +// # publication is one atomic rename +// .rm-* # retired entries awaiting removal +// +// An entry directory's mtime is its last-use clock. Nothing on a correctness +// path reads meta.json: entries are matched to keys by hashing the keys. +// Everything under entries/ is a complete, published entry; SweepDebris +// clears crash leftovers from the other two locations at startup. +// +// # Contracts +// +// Eviction never invalidates what a caller already received: +// +// - GetFileTo serves a hit as a hard link, created in the same locked +// step that resolves the hit. Evicting the entry later removes only the +// cache's own link; the consumer's file stays valid. +// - GetFileCopyTo serves a private copy. The consumer owns the inode and +// may mutate it, and a copy in progress reads a held-open handle, so a +// concurrent eviction cannot corrupt it. +// - The store's min age (WithMinAge) vetoes eviction of entries younger +// than the window between publication and a consumer's use becoming +// visible. Size it for the slowest consumer. +// +// Entries are published read-only (0444): a consumer writing through its +// hard link fails with EACCES instead of corrupting the shared bytes. +// Consumers that must mutate a file use GetFileCopyTo. +// +// Keys are identities, not addresses. The store never invalidates: a key +// must name content that is immutable at its source (see URIKey); changed +// content must arrive under a new key. +package filecache diff --git a/cmd/atelet/internal/filecache/evict.go b/cmd/atelet/internal/filecache/evict.go new file mode 100644 index 0000000000..59b629d09d --- /dev/null +++ b/cmd/atelet/internal/filecache/evict.go @@ -0,0 +1,359 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// allocatedSize returns the bytes a file actually occupies on disk +// (st_blocks), not its logical length. The cache's biggest artifacts are +// sparse guest memory images, whose logical size can exceed their footprint +// by orders of magnitude, and every byte figure the store reports is +// compared against real disk usage (statfs watermarks, byte budgets). +func allocatedSize(info fs.FileInfo) int64 { + if st, ok := info.Sys().(*syscall.Stat_t); ok { + return st.Blocks * 512 + } + return info.Size() +} + +// TotalBytes sums the allocated (on-disk) bytes of all published entries' +// regular files. It is the GC driver's usage measure against the store's +// byte budget. +func (s *Store) TotalBytes(ctx context.Context) (int64, error) { + var total int64 + err := filepath.WalkDir(s.entriesDir(), func(path string, d fs.DirEntry, err error) error { + if err != nil { + // An entry retired mid-walk is not an error; skip what vanished. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if err := ctx.Err(); err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + info, err := d.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + total += allocatedSize(info) + return nil + }) + if err != nil { + return 0, fmt.Errorf("while sizing entries: %w", err) + } + return total, nil +} + +// EvictStats reports what an EvictUnused pass did (or, dry-run, would do). +type EvictStats struct { + // Retired counts entries renamed out of the cache's namespace: from + // that rename on, lookups miss and refetch, whether or not the later + // physical removal succeeded (FreedBytes tracks that part). In a dry + // run, the entries a real pass would have retired. + Retired int + // FreedBytes counts allocated (on-disk) bytes actually returned to the + // filesystem: the footprints of retired entries whose data had no + // consumer hard links left and whose physical removal completed. An + // entry retired but not removed (a RemoveAll failure, reported in the + // returned error) is excluded; its bytes sit in a .rm-* dir until + // SweepDebris. In a dry run this is the would-free estimate. + FreedBytes int64 + // PendingBytes counts allocated bytes of retired entries whose data is + // still hard-linked by a consumer: the cache's claim is gone, but the + // kernel frees the space only when the last consumer link is removed. + PendingBytes int64 + // SkippedYoung counts entries vetoed by the store's min age. + SkippedYoung int + // SkippedBusy counts entries vetoed at retire time: a hit or a fresh + // fetch moved their last-use clock after the pass listed them. + SkippedBusy int +} + +// evictCandidate is one listed entry, snapshotted lock-free at the start of +// a pass; retireEntry re-verifies it under the locks before touching it. +type evictCandidate struct { + dir string // entry dir name (the key hash) + mtime time.Time + size int64 // allocated bytes (see allocatedSize), not logical length + linked bool // data is a regular file some consumer still hard-links +} + +// EvictUnused frees cache space until targetBytes of actually-freeable +// bytes are reclaimed or no eligible entries remain, least-recently-used +// first. Entries younger than the store's min age are skipped, and a hit or +// fetch racing the pass vetoes its entry at retire time. Retiring an entry +// never invalidates files already served from it: a consumer's hard link +// keeps its bytes (counted as pending until the link goes), so eviction +// only ever costs the next caller a re-download. +// +// With dryRun, nothing is touched and the stats report what a real pass +// would have chosen. Passes are pressure-driven: the caller decides when +// and how much; a non-positive target is a no-op. +func (s *Store) EvictUnused(ctx context.Context, targetBytes int64, dryRun bool) (EvictStats, error) { + var stats EvictStats + if targetBytes <= 0 { + return stats, nil + } + s.evictMu.Lock() + defer s.evictMu.Unlock() + + var errs []error + // A .rm-* dir outlives a pass only when its RemoveAll failed. Retry + // those first — only eviction creates them while the store serves, and + // evictMu is held, so the retry races nothing — instead of leaving the + // bytes to the next restart's SweepDebris. + if !dryRun { + if err := s.removeRetiredLeftovers(); err != nil { + errs = append(errs, err) + } + } + candidates, err := s.listCandidates(ctx) + if err != nil { + // Per-entry listing failures: the pass still works the entries it + // could see, and the error keeps a broken cache (permissions, I/O) + // distinguishable from an empty one. + errs = append(errs, err) + } + + // Age gate, then order: entries nobody links first (evicting a linked + // entry frees nothing now), least recently used within each group. + now := time.Now() + eligible := candidates[:0] + for _, c := range candidates { + if now.Sub(c.mtime) < s.minAge { + stats.SkippedYoung++ + continue + } + eligible = append(eligible, c) + } + sort.Slice(eligible, func(i, j int) bool { + if eligible[i].linked != eligible[j].linked { + return !eligible[i].linked + } + return eligible[i].mtime.Before(eligible[j].mtime) + }) + + // Victim selection runs against selectedBytes, not stats.FreedBytes: + // freed is only credited once physical removal succeeds below. + type retiredEntry struct { + path string + freed int64 // c.size for unlinked victims, 0 for linked ones + } + var retired []retiredEntry + var selectedBytes int64 + for _, c := range eligible { + if selectedBytes >= targetBytes { + break + } + if err := ctx.Err(); err != nil { + errs = append(errs, err) + break + } + var freed int64 + if !c.linked { + freed = c.size + } + if !dryRun { + rmPath, ok, err := s.retireEntry(c) + if err != nil { + errs = append(errs, fmt.Errorf("while retiring entry %s: %w", c.dir, err)) + continue + } + if !ok { + stats.SkippedBusy++ + continue + } + retired = append(retired, retiredEntry{path: rmPath, freed: freed}) + } else { + stats.FreedBytes += freed + } + stats.Retired++ + stats.PendingBytes += c.size - freed + selectedBytes += freed + } + + // The slow physical deletion happens after all retires, outside hitMu + // and the singleflight, so hits and fetches never wait on it (evictMu + // stays held: only a concurrent pass would wait, and serializing passes + // is its job). A crash before it finishes leaves .rm-* dirs for + // SweepDebris, as does a removal failure here — those bytes are not + // counted freed. + for _, r := range retired { + if err := os.RemoveAll(r.path); err != nil { + errs = append(errs, fmt.Errorf("while removing retired entry %s: %w", filepath.Base(r.path), err)) + continue + } + stats.FreedBytes += r.freed + } + return stats, errors.Join(errs...) +} + +// removeRetiredLeftovers retries the physical removal of .rm-* dirs an +// earlier pass failed to delete. The freed bytes are not credited to any +// stats: they were already selected (and possibly counted) by the pass that +// retired them. +func (s *Store) removeRetiredLeftovers() error { + children, err := os.ReadDir(s.root) + if err != nil { + return fmt.Errorf("while listing store root: %w", err) + } + var errs []error + for _, child := range children { + if !strings.HasPrefix(child.Name(), rmPrefix) { + continue + } + if err := os.RemoveAll(filepath.Join(s.root, child.Name())); err != nil { + errs = append(errs, fmt.Errorf("while removing retired leftover %q: %w", child.Name(), err)) + } + } + return errors.Join(errs...) +} + +// listCandidates snapshots the published entries. Deliberately lock-free +// and therefore stale: retireEntry re-verifies every victim before acting, +// so racing hits and fetches only make a candidate disappear, never a +// wrong eviction. Entries that vanish mid-listing are skipped silently; any +// other per-entry failure is reported in the joined error alongside the +// candidates that did list, so a broken cache (permissions, I/O) never +// masquerades as an empty one. +func (s *Store) listCandidates(ctx context.Context) ([]evictCandidate, error) { + children, err := os.ReadDir(s.entriesDir()) + if err != nil { + return nil, fmt.Errorf("while listing entries: %w", err) + } + var errs []error + candidates := make([]evictCandidate, 0, len(children)) + for _, child := range children { + if err := ctx.Err(); err != nil { + return nil, err + } + if !child.IsDir() { + continue + } + entryDir := filepath.Join(s.entriesDir(), child.Name()) + fi, err := os.Stat(entryDir) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("while inspecting entry %s: %w", child.Name(), err)) + } + continue // retired mid-listing + } + size, linked, err := s.sizeEntry(entryDir) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("while sizing entry %s: %w", child.Name(), err)) + } + continue + } + candidates = append(candidates, evictCandidate{ + dir: child.Name(), + mtime: fi.ModTime(), + size: size, + linked: linked, + }) + } + return candidates, errors.Join(errs...) +} + +// sizeEntry sums an entry's regular files' allocated bytes and reports +// whether its data file carries consumer hard links (a directory-tree entry +// cannot, so it reports unlinked and relies on the min age and, later, a +// root set). Files that vanish mid-walk (a concurrent retire) are skipped. +func (s *Store) sizeEntry(entryDir string) (size int64, linked bool, err error) { + err = filepath.WalkDir(entryDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if !d.Type().IsRegular() { + return nil + } + info, err := d.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + size += allocatedSize(info) + if path == filepath.Join(entryDir, dataName) { + if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 { + linked = true + } + } + return nil + }) + if err != nil { + return 0, false, err + } + return size, linked, nil +} + +// retireEntry removes c from the cache's namespace if it is still exactly +// the entry the pass listed, returning the renamed .rm-* path and whether +// it retired. It runs inside the key's singleflight (joining any in-flight +// fetch rather than racing it) and takes hitMu exclusively for the final +// re-check and rename, so a hit cannot be served from an entry mid-retire. +// A moved last-use clock is a veto, not an error. +func (s *Store) retireEntry(c evictCandidate) (string, bool, error) { + var rmPath string + var retired bool + _, err, _ := s.sf.Do(c.dir, func() (any, error) { + s.hitMu.Lock() + defer s.hitMu.Unlock() + + entryDir := filepath.Join(s.entriesDir(), c.dir) + fi, err := os.Stat(entryDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil // already gone + } + if err != nil { + return nil, err + } + if !fi.ModTime().Equal(c.mtime) { + return nil, nil // hit or refetched since the listing: veto + } + // Unique suffix: a crashed pass may have left .rm--* behind and + // the key may have been refetched and retired again before a sweep. + p := filepath.Join(s.root, rmPrefix+c.dir+"-"+strconv.FormatInt(time.Now().UnixNano(), 36)) + if err := os.Rename(entryDir, p); err != nil { + return nil, err + } + rmPath, retired = p, true + return nil, nil + }) + return rmPath, retired, err +} diff --git a/cmd/atelet/internal/filecache/evict_test.go b/cmd/atelet/internal/filecache/evict_test.go new file mode 100644 index 0000000000..4858c342a9 --- /dev/null +++ b/cmd/atelet/internal/filecache/evict_test.go @@ -0,0 +1,389 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const evictAll = int64(1) << 40 + +// cacheEntry materializes an entry through the real retrieval path, backdates +// its last-use clock by age, and returns its key. keepLink controls whether a +// consumer hard link survives (dst removed = the actor dir was wiped). +func cacheEntry(t *testing.T, s *Store, name, content string, age time.Duration, keepLink bool) Key { + t.Helper() + key := URIKey("test://" + name) + fetch, _ := countingFetcher(content) + dst := dstPath(t, s, "evict-"+name) + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatalf("GetFileTo(%s): %v", name, err) + } + if !keepLink { + if err := os.Remove(dst); err != nil { + t.Fatal(err) + } + } + stale := time.Now().Add(-age) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + return key +} + +func entryExists(t *testing.T, s *Store, key Key) bool { + t.Helper() + _, err := os.Stat(s.entryDir(key)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + return err == nil +} + +// noRetiredLeftovers asserts the two-phase retire completed: no .rm-* dirs +// remain at the store root. +func noRetiredLeftovers(t *testing.T, s *Store) { + t.Helper() + children, err := os.ReadDir(s.root) + if err != nil { + t.Fatal(err) + } + for _, child := range children { + if strings.HasPrefix(child.Name(), rmPrefix) { + t.Errorf("retired dir %q left behind after pass", child.Name()) + } + } +} + +func TestEvictUnusedNonPositiveTargetIsNoop(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "kept", "x", time.Hour, false) + + stats, err := s.EvictUnused(context.Background(), 0, false) + if err != nil { + t.Fatalf("EvictUnused(0): %v", err) + } + if stats != (EvictStats{}) { + t.Errorf("stats = %+v, want zero", stats) + } + if !entryExists(t, s, key) { + t.Error("entry evicted by zero-target pass") + } +} + +func TestEvictUnusedTakesLeastRecentlyUsedAndStopsAtTarget(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + oldest := cacheEntry(t, s, "oldest", "aaaaa", 3*time.Hour, false) + middle := cacheEntry(t, s, "middle", "bbbbb", 2*time.Hour, false) + newest := cacheEntry(t, s, "newest", "ccccc", time.Hour, false) + + // A 1-byte target forces exactly one eviction, which must be the LRU. + stats, err := s.EvictUnused(context.Background(), 1, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes < 5 { + t.Errorf("stats = %+v, want Retired=1 and FreedBytes >= 5", stats) + } + if entryExists(t, s, oldest) { + t.Error("LRU entry survived") + } + if !entryExists(t, s, middle) || !entryExists(t, s, newest) { + t.Error("pass evicted beyond its target") + } + noRetiredLeftovers(t, s) +} + +func TestEvictUnusedRespectsMinAge(t *testing.T) { + s := newTestStore(t, WithMinAge(time.Hour)) + young := cacheEntry(t, s, "young", "x", 30*time.Minute, false) + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 0 || stats.SkippedYoung != 1 { + t.Errorf("stats = %+v, want Retired=0 SkippedYoung=1", stats) + } + if !entryExists(t, s, young) { + t.Error("entry younger than minAge evicted") + } +} + +func TestEvictUnusedPrefersUnlinkedOverOlderLinked(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + // The linked entry is older; pure LRU would take it first. The pass must + // prefer the unlinked one, whose bytes actually come back. + linked := cacheEntry(t, s, "linked", "xx", 3*time.Hour, true) + unlinked := cacheEntry(t, s, "unlinked", "yy", time.Hour, false) + + stats, err := s.EvictUnused(context.Background(), 1, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes == 0 || stats.PendingBytes != 0 { + t.Errorf("stats = %+v, want one eviction with freed bytes only", stats) + } + if entryExists(t, s, unlinked) { + t.Error("unlinked entry survived") + } + if !entryExists(t, s, linked) { + t.Error("linked entry evicted while an unlinked one satisfied the target") + } +} + +func TestEvictUnusedLinkedEntryIsSafeForConsumer(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := URIKey("test://held") + fetch, calls := countingFetcher("held bytes") + dst := dstPath(t, s, "held") + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.FreedBytes != 0 || stats.PendingBytes == 0 { + t.Errorf("stats = %+v, want one eviction counted as pending bytes", stats) + } + if entryExists(t, s, key) { + t.Error("entry still published after eviction") + } + // The consumer's link is untouched: eviction cost is a re-download, + // never a broken consumer. + got, err := os.ReadFile(dst) + if err != nil || string(got) != "held bytes" { + t.Errorf("consumer link after eviction: %q, %v", got, err) + } + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "held2"), fetch); err != nil { + t.Fatalf("GetFileTo after eviction: %v", err) + } + if calls.Load() != 2 { + t.Errorf("fetch ran %d times, want 2 (original + post-eviction)", calls.Load()) + } +} + +func TestEvictUnusedDryRunTouchesNothing(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + a := cacheEntry(t, s, "a", "aa", 2*time.Hour, false) + b := cacheEntry(t, s, "b", "bb", time.Hour, true) + + stats, err := s.EvictUnused(context.Background(), evictAll, true) + if err != nil { + t.Fatalf("EvictUnused(dryRun): %v", err) + } + if stats.Retired != 2 || stats.FreedBytes == 0 || stats.PendingBytes == 0 { + t.Errorf("stats = %+v, want both entries reported (one freed, one pending)", stats) + } + if !entryExists(t, s, a) || !entryExists(t, s, b) { + t.Error("dry run removed entries") + } + noRetiredLeftovers(t, s) +} + +func TestEvictUnusedRemovalFailureIsNotCountedFreed(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + s := newTestStore(t, WithMinAge(0)) + // A tree entry with a write-protected subdirectory: listing and the + // retire rename work, but RemoveAll cannot unlink the file inside. + key := publishTestEntry(t, s, "stuck", map[string]string{ + filepath.Join(dataName, "locked", "f"): "12345", + }) + locked := filepath.Join(s.entryDir(key), dataName, "locked") + if err := os.Chmod(locked, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { // let TempDir cleanup succeed wherever the dir ended up + matches, _ := filepath.Glob(filepath.Join(s.root, rmPrefix+"*", dataName, "locked")) + for _, m := range append(matches, locked) { + _ = os.Chmod(m, 0o700) + } + }) + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err == nil { + t.Fatal("EvictUnused succeeded despite unremovable entry, want error") + } + if stats.FreedBytes != 0 { + t.Errorf("FreedBytes = %d for a failed removal, want 0", stats.FreedBytes) + } + if stats.Retired != 1 { + t.Errorf("Retired = %d, want 1 (the retire itself succeeded)", stats.Retired) + } + if entryExists(t, s, key) { + t.Error("entry still published after retire") + } +} + +// TestEvictUnusedFreedBytesAreAllocatedNotLogical pins eviction accounting +// on a sparse entry: crediting logical length would let a pass "meet" a +// disk-pressure target while freeing almost nothing. +func TestEvictUnusedFreedBytesAreAllocatedNotLogical(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + const logical = 64 << 20 + key := URIKey("test://sparse") + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "sparse"), sparseTestFetcher(logical)); err != nil { + t.Fatal(err) + } + if alloc := allocatedBytes(t, s.dataPath(key)); alloc >= logical/2 { + t.Skipf("cached file did not end up sparse (%d of %d bytes allocated)", alloc, int64(logical)) + } + if err := os.Remove(dstPath(t, s, "sparse")); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 { + t.Fatalf("Retired = %d, want 1", stats.Retired) + } + if stats.FreedBytes == 0 || stats.FreedBytes >= logical/2 { + t.Errorf("FreedBytes = %d for a sparse entry (logical %d); want its allocated footprint", stats.FreedBytes, int64(logical)) + } +} + +// TestEvictUnusedReportsUnreadableEntries pins finding an unreadable entry +// as an error, not silence: a cache broken by permissions or I/O faults +// must be distinguishable from one with nothing to evict, and the healthy +// entries must still be processed. +func TestEvictUnusedReportsUnreadableEntries(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + s := newTestStore(t, WithMinAge(0)) + healthy := cacheEntry(t, s, "healthy", "bytes", time.Hour, false) + broken := publishTestEntry(t, s, "broken", map[string]string{dataName: "x"}) + if err := os.Chmod(s.entryDir(broken), 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(s.entryDir(broken), 0o755) }) + + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err == nil { + t.Fatal("EvictUnused returned nil error despite an unreadable entry") + } + if !strings.Contains(err.Error(), broken.dir) { + t.Errorf("error %q does not name the unreadable entry %s", err, broken.dir) + } + if stats.Retired != 1 || entryExists(t, s, healthy) { + t.Errorf("healthy entry not evicted alongside the failure: stats=%+v", stats) + } +} + +// TestEvictUnusedRemovesRetiredLeftovers pins the retry: a .rm-* dir left +// by a pass whose RemoveAll failed is deleted by the next pass instead of +// waiting for a restart's SweepDebris. +func TestEvictUnusedRemovesRetiredLeftovers(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + leftover := filepath.Join(s.root, rmPrefix+"aaaa-stuck") + if err := os.MkdirAll(leftover, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(leftover, dataName), []byte("stranded"), 0o444); err != nil { + t.Fatal(err) + } + + if _, err := s.EvictUnused(context.Background(), evictAll, false); err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if _, err := os.Stat(leftover); !os.IsNotExist(err) { + t.Errorf("retired leftover still present after a pass: %v", err) + } +} + +func TestRetireEntryVetoesWhenLastUseMoved(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "busy", "x", time.Hour, false) + + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + listed := evictCandidate{dir: key.dir, mtime: fi.ModTime(), size: 1} + + // A hit lands between the listing and the retire. + now := time.Now() + if err := os.Chtimes(s.entryDir(key), now, now); err != nil { + t.Fatal(err) + } + + rmPath, retired, err := s.retireEntry(listed) + if err != nil { + t.Fatalf("retireEntry: %v", err) + } + if retired || rmPath != "" { + t.Errorf("retireEntry retired a touched entry (rmPath=%q)", rmPath) + } + if !entryExists(t, s, key) { + t.Error("touched entry vanished") + } +} + +func TestRetireEntryRetiresUnchangedEntry(t *testing.T) { + s := newTestStore(t, WithMinAge(0)) + key := cacheEntry(t, s, "stale", "x", time.Hour, false) + + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + rmPath, retired, err := s.retireEntry(evictCandidate{dir: key.dir, mtime: fi.ModTime(), size: 1}) + if err != nil { + t.Fatalf("retireEntry: %v", err) + } + if !retired { + t.Fatal("retireEntry did not retire an unchanged entry") + } + if entryExists(t, s, key) { + t.Error("entry still published after retire") + } + if !strings.HasPrefix(filepath.Base(rmPath), rmPrefix) { + t.Errorf("retired path %q lacks the %q prefix", rmPath, rmPrefix) + } + if _, err := os.Stat(rmPath); err != nil { + t.Errorf("retired dir missing before removal phase: %v", err) + } + // An interrupted pass leaves the retired dir to the startup sweep. + stats, err := s.SweepDebris(context.Background()) + if err != nil { + t.Fatal(err) + } + if stats.RetiredRemoved != 1 { + t.Errorf("sweep stats = %+v, want RetiredRemoved=1", stats) + } +} diff --git a/cmd/atelet/internal/filecache/filecache_test.go b/cmd/atelet/internal/filecache/filecache_test.go new file mode 100644 index 0000000000..78bdfa3540 --- /dev/null +++ b/cmd/atelet/internal/filecache/filecache_test.go @@ -0,0 +1,289 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// readEntryMeta loads an entry dir's meta.json sidecar. Test-only: nothing +// on a production path reads the sidecar back. +func readEntryMeta(entryDir string) (entryMeta, error) { + data, err := os.ReadFile(filepath.Join(entryDir, metaName)) + if err != nil { + return entryMeta{}, fmt.Errorf("while reading entry meta: %w", err) + } + var m entryMeta + if err := json.Unmarshal(data, &m); err != nil { + return entryMeta{}, fmt.Errorf("while parsing entry meta: %w", err) + } + return m, nil +} + +// allocatedBytes reports how much disk a file actually occupies, which is +// less than its logical size when it has holes. +func allocatedBytes(t *testing.T, path string) int64 { + t.Helper() + var st syscall.Stat_t + if err := syscall.Stat(path, &st); err != nil { + t.Fatalf("stat %q: %v", path, err) + } + return st.Blocks * 512 +} + +// sparseTestFetcher returns a FileFetcher producing a mostly-hole file: +// size logical bytes with one 4 KiB data extent. Tests that depend on the +// result being genuinely sparse must skip when the filesystem materialized +// it (see allocatedBytes). +func sparseTestFetcher(size int64) FileFetcher { + return func(ctx context.Context, dstPath string) error { + f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if err := f.Truncate(size); err != nil { + return err + } + if _, err := f.WriteAt(make([]byte, 4<<10), size/2); err != nil { + return err + } + return f.Close() + } +} + +func newTestStore(t *testing.T, opts ...Option) *Store { + t.Helper() + s, err := New(filepath.Join(t.TempDir(), "cache"), opts...) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +// publishTestEntry plants a published entry with the given data files +// (relative name -> content), bypassing retrieval, and returns its key. +func publishTestEntry(t *testing.T, s *Store, name string, files map[string]string) Key { + t.Helper() + k := URIKey("test://" + name) + if len(files) == 0 { + if err := os.MkdirAll(s.entryDir(k), 0o755); err != nil { + t.Fatal(err) + } + } + for rel, content := range files { + path := filepath.Join(s.entryDir(k), rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o444); err != nil { + t.Fatal(err) + } + } + if err := writeEntryMeta(s.entryDir(k), k, time.Now()); err != nil { + t.Fatalf("writeEntryMeta: %v", err) + } + return k +} + +func TestNewCreatesLayout(t *testing.T) { + root := filepath.Join(t.TempDir(), "cache") + s, err := New(root) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, dir := range []string{s.entriesDir(), s.tmpDir()} { + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + t.Errorf("stat %q: err=%v, isDir=%v", dir, err, err == nil && fi.IsDir()) + } + } + + // Reopening an existing root keeps published entries. + k := publishTestEntry(t, s, "survivor", map[string]string{dataName: "x"}) + if _, err := New(root); err != nil { + t.Fatalf("New (reopen): %v", err) + } + if _, err := os.Stat(s.dataPath(k)); err != nil { + t.Errorf("entry lost across reopen: %v", err) + } +} + +func TestNewDefaultsAndOptions(t *testing.T) { + s := newTestStore(t) + if s.minAge != defaultMinAge { + t.Errorf("minAge = %v, want default %v", s.minAge, defaultMinAge) + } + if s.fetchTimeout != defaultFetchTimeout { + t.Errorf("fetchTimeout = %v, want default %v", s.fetchTimeout, defaultFetchTimeout) + } + + s = newTestStore(t, WithMinAge(time.Second), WithFetchTimeout(time.Minute)) + if s.minAge != time.Second { + t.Errorf("minAge = %v, want %v", s.minAge, time.Second) + } + if s.fetchTimeout != time.Minute { + t.Errorf("fetchTimeout = %v, want %v", s.fetchTimeout, time.Minute) + } +} + +func TestEntryMetaRoundTrip(t *testing.T) { + s := newTestStore(t) + k := publishTestEntry(t, s, "meta", map[string]string{dataName: "x"}) + + m, err := readEntryMeta(s.entryDir(k)) + if err != nil { + t.Fatalf("readEntryMeta: %v", err) + } + if m.Key != k.String() { + t.Errorf("meta key = %q, want %q", m.Key, k.String()) + } + if m.CreatedAt.IsZero() { + t.Error("meta createdAt is zero") + } +} + +func TestSweepDebris(t *testing.T) { + s := newTestStore(t) + kept := publishTestEntry(t, s, "kept", map[string]string{dataName: "x"}) + + // Unfinished fetches: a bare temp file and a temp extraction dir. + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(s.tmpDir(), "dl-2", "nested"), 0o700); err != nil { + t.Fatal(err) + } + // An eviction that renamed but never removed. + if err := os.MkdirAll(filepath.Join(s.root, rmPrefix+"deadbeef", "nested"), 0o755); err != nil { + t.Fatal(err) + } + + stats, err := s.SweepDebris(context.Background()) + if err != nil { + t.Fatalf("SweepDebris: %v", err) + } + if stats.TmpRemoved != 2 || stats.RetiredRemoved != 1 { + t.Errorf("stats = %+v, want TmpRemoved=2 RetiredRemoved=1", stats) + } + + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir not empty after sweep: %d children", len(tmpChildren)) + } + if _, err := os.Stat(filepath.Join(s.root, rmPrefix+"deadbeef")); !os.IsNotExist(err) { + t.Errorf("retired dir survived sweep: err=%v", err) + } + if _, err := os.Stat(s.dataPath(kept)); err != nil { + t.Errorf("published entry removed by sweep: %v", err) + } + + // A clean store sweeps to zero. + stats, err = s.SweepDebris(context.Background()) + if err != nil { + t.Fatalf("SweepDebris (clean): %v", err) + } + if stats != (SweepStats{}) { + t.Errorf("stats on clean store = %+v, want zero", stats) + } +} + +func TestSweepDebrisCanceled(t *testing.T) { + s := newTestStore(t) + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), nil, 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := s.SweepDebris(ctx); err == nil { + t.Error("SweepDebris with canceled ctx succeeded, want error") + } +} + +func TestTotalBytes(t *testing.T) { + s := newTestStore(t) + + total, err := s.TotalBytes(context.Background()) + if err != nil { + t.Fatalf("TotalBytes (empty): %v", err) + } + if total != 0 { + t.Errorf("TotalBytes on empty store = %d, want 0", total) + } + + publishTestEntry(t, s, "file", map[string]string{dataName: "12345"}) + publishTestEntry(t, s, "tree", map[string]string{ + filepath.Join(dataName, "runsc"): "1234567890", + filepath.Join(dataName, "gvisor-bin", "help"): "123", + }) + // Unpublished bytes in tmp/ must not count. + if err := os.WriteFile(filepath.Join(s.tmpDir(), "dl-1"), []byte("zzzz"), 0o600); err != nil { + t.Fatal(err) + } + + total, err = s.TotalBytes(context.Background()) + if err != nil { + t.Fatalf("TotalBytes: %v", err) + } + // The measure is allocated (on-disk) bytes: the sum of the entries' + // regular files' footprints — three data files plus two meta.json + // sidecars — and nothing else. + var want int64 + for _, rel := range []string{ + filepath.Join(s.entryDir(URIKey("test://file")), dataName), + filepath.Join(s.entryDir(URIKey("test://file")), metaName), + filepath.Join(s.entryDir(URIKey("test://tree")), dataName, "runsc"), + filepath.Join(s.entryDir(URIKey("test://tree")), dataName, "gvisor-bin", "help"), + filepath.Join(s.entryDir(URIKey("test://tree")), metaName), + } { + want += allocatedBytes(t, rel) + } + if total != want { + t.Errorf("TotalBytes = %d, want %d (allocated bytes of the published files)", total, want) + } +} + +// TestTotalBytesCountsAllocatedNotLogical pins the accounting unit against +// the cache's most important artifact shape: a sparse guest memory image. +// Counting logical length would report a near-empty cache as huge, driving +// eviction that frees nothing. +func TestTotalBytesCountsAllocatedNotLogical(t *testing.T) { + s := newTestStore(t) + const logical = 64 << 20 + key := URIKey("test://sparse") + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "sparse"), sparseTestFetcher(logical)); err != nil { + t.Fatal(err) + } + if alloc := allocatedBytes(t, s.dataPath(key)); alloc >= logical/2 { + t.Skipf("cached file did not end up sparse (%d of %d bytes allocated); this filesystem cannot report holes", alloc, int64(logical)) + } + + total, err := s.TotalBytes(context.Background()) + if err != nil { + t.Fatal(err) + } + if total >= logical/2 { + t.Errorf("TotalBytes = %d for a sparse entry with %d allocated bytes; logical sizes are being counted", total, allocatedBytes(t, s.dataPath(key))) + } +} diff --git a/cmd/atelet/internal/filecache/getfilecopyto.go b/cmd/atelet/internal/filecache/getfilecopyto.go new file mode 100644 index 0000000000..d3c49acc99 --- /dev/null +++ b/cmd/atelet/internal/filecache/getfilecopyto.go @@ -0,0 +1,92 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "time" + + "github.com/agent-substrate/substrate/cmd/atelet/internal/sparsefile" +) + +// GetFileCopyTo materializes the artifact identified by key at dst as a +// private, hole-preserving copy, fetching it with fetch if it is not +// cached. Unlike GetFileTo's hard link, the caller owns the resulting inode +// (mode 0600) and may mutate it in place, and dst may be on any filesystem. +// dst must be an absolute path that does not exist yet. +// +// Concurrent calls for one key share a single fetch, as in GetFileTo. A +// copy in progress reads a held-open handle, so a concurrent eviction +// cannot corrupt it. +func (s *Store) GetFileCopyTo(ctx context.Context, key Key, dst string, fetch FileFetcher) error { + return s.getTo(ctx, key, dst, fetch, s.copyOut) +} + +// copyOut copies key's published data file to dst, reporting false (and no +// error) on a cache miss. Only the open runs under the hit lock; the copy +// itself proceeds outside it, safe against a concurrent retire because it +// reads the held-open handle, not the name. +func (s *Store) copyOut(key Key, dst string) (bool, error) { + src, err := s.openData(key) + if err != nil || src == nil { + return false, err + } + defer src.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if errors.Is(err, fs.ErrExist) { + return false, fmt.Errorf("destination %s already exists: %w", dst, err) + } + return false, fmt.Errorf("while creating %s: %w", dst, err) + } + if _, err := sparsefile.Copy(src, out); err != nil { + _ = out.Close() + _ = os.Remove(dst) + return false, fmt.Errorf("while copying %v to %s: %w", key, dst, err) + } + if err := out.Close(); err != nil { + _ = os.Remove(dst) + return false, fmt.Errorf("while finishing copy of %v to %s: %w", key, dst, err) + } + return true, nil +} + +// openData opens key's published data under the hit lock and touches the +// entry's last-use clock, returning (nil, nil) on a miss. The open handle +// protects the caller afterwards: eviction may retire the entry the moment +// the lock is released, but the inode's bytes stay readable while the +// handle is open. +func (s *Store) openData(key Key) (*os.File, error) { + s.hitMu.RLock() + defer s.hitMu.RUnlock() + + f, err := os.Open(s.dataPath(key)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("while opening cached %v: %w", key, err) + } + // Last-use touch for eviction's LRU ordering; best-effort, a miss only + // ages the entry. + now := time.Now() + _ = os.Chtimes(s.entryDir(key), now, now) + return f, nil +} diff --git a/cmd/atelet/internal/filecache/getfilecopyto_test.go b/cmd/atelet/internal/filecache/getfilecopyto_test.go new file mode 100644 index 0000000000..3a9dcf038b --- /dev/null +++ b/cmd/atelet/internal/filecache/getfilecopyto_test.go @@ -0,0 +1,224 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "fmt" + "os" + "sync" + "syscall" + "testing" + "time" +) + +func TestGetFileCopyToMissThenHit(t *testing.T) { + s := newTestStore(t) + key := URIKey("golden", "memory") + fetch, calls := countingFetcher("golden memory") + ctx := context.Background() + + first := dstPath(t, s, "first") + second := dstPath(t, s, "second") + for _, dst := range []string{first, second} { + if err := s.GetFileCopyTo(ctx, key, dst, fetch); err != nil { + t.Fatalf("GetFileCopyTo(%s): %v", dst, err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "golden memory" { + t.Fatalf("copy %s = %q, %v", dst, got, err) + } + } + if n := calls.Load(); n != 1 { + t.Errorf("two copies fetched %d times, want 1", n) + } + + // Private inodes: the copies share nothing with each other or the cache, + // and the caller may write them. + fi1, err1 := os.Stat(first) + fi2, err2 := os.Stat(second) + if err1 != nil || err2 != nil { + t.Fatal(err1, err2) + } + if os.SameFile(fi1, fi2) { + t.Error("copies share an inode; each caller must own its own") + } + if st, ok := fi1.Sys().(*syscall.Stat_t); ok && st.Nlink != 1 { + t.Errorf("copy has %d links; a copy must not be linked to the cache", st.Nlink) + } + if fi1.Mode().Perm()&0o200 == 0 { + t.Errorf("copy mode %v is not owner-writable; copy mode exists for mutating consumers", fi1.Mode()) + } +} + +func TestGetFileCopyToMutationIsolation(t *testing.T) { + s := newTestStore(t) + key := URIKey("golden", "config") + fetch, calls := countingFetcher("original") + ctx := context.Background() + + first := dstPath(t, s, "first") + if err := s.GetFileCopyTo(ctx, key, first, fetch); err != nil { + t.Fatal(err) + } + // The consumer rewrites its staged file in place — the whole reason copy + // mode exists (ateom-microvm does this to config.json). + if err := os.WriteFile(first, []byte("mutated by consumer"), 0o600); err != nil { + t.Fatalf("consumer write to its copy: %v", err) + } + + second := dstPath(t, s, "second") + if err := s.GetFileCopyTo(ctx, key, second, fetch); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(second); err != nil || string(got) != "original" { + t.Errorf("copy after consumer mutation = %q, %v; the cache copy was poisoned", got, err) + } + if n := calls.Load(); n != 1 { + t.Errorf("fetches = %d, want 1 (mutation must not invalidate the entry)", n) + } +} + +func TestGetFileCopyToConcurrentSingleFlight(t *testing.T) { + s := newTestStore(t) + key := URIKey("golden", "memory") + fetch, calls := countingFetcher("golden memory") + + const copiers = 8 + errs := make([]error, copiers) + var wg sync.WaitGroup + for i := range copiers { + dst := dstPath(t, s, fmt.Sprintf("copy-%d", i)) + wg.Go(func() { + errs[i] = s.GetFileCopyTo(context.Background(), key, dst, fetch) + }) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("copier %d: %v", i, err) + } + got, err := os.ReadFile(dstPath(t, s, fmt.Sprintf("copy-%d", i))) + if err != nil || string(got) != "golden memory" { + t.Fatalf("copier %d content %q, %v", i, got, err) + } + } + if n := calls.Load(); n != 1 { + t.Errorf("%d concurrent copiers fetched %d times, want 1", copiers, n) + } +} + +func TestGetFileCopyToEvictedEntryRefetches(t *testing.T) { + s := newTestStore(t, WithMinAge(time.Millisecond)) + key := URIKey("golden", "memory") + fetch, calls := countingFetcher("golden memory") + ctx := context.Background() + + first := dstPath(t, s, "first") + if err := s.GetFileCopyTo(ctx, key, first, fetch); err != nil { + t.Fatal(err) + } + + // A copied-out entry carries no consumer links, so eviction is free to + // take it once past min age. + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), old, old); err != nil { + t.Fatal(err) + } + stats, err := s.EvictUnused(ctx, evictAll, false) + if err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if stats.Retired != 1 || stats.PendingBytes != 0 { + t.Errorf("evicting a copied-out entry: retired=%d pending=%d, want 1/0", stats.Retired, stats.PendingBytes) + } + + // The earlier copy is untouched, and the next copy refetches. + if got, err := os.ReadFile(first); err != nil || string(got) != "golden memory" { + t.Errorf("existing copy after eviction = %q, %v", got, err) + } + second := dstPath(t, s, "second") + if err := s.GetFileCopyTo(ctx, key, second, fetch); err != nil { + t.Fatal(err) + } + if n := calls.Load(); n != 2 { + t.Errorf("fetches = %d, want 2 (miss after eviction)", n) + } +} + +func TestGetFileCopyToPreservesSparseness(t *testing.T) { + s := newTestStore(t) + key := URIKey("golden", "sparse") + const size = 16 << 20 + sparseFetcher := func(ctx context.Context, dstPath string) error { + f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if err := f.Truncate(size); err != nil { + return err + } + if _, err := f.WriteAt(make([]byte, 4<<10), 1<<20); err != nil { + return err + } + return f.Close() + } + + dst := dstPath(t, s, "sparse-copy") + if err := s.GetFileCopyTo(context.Background(), key, dst, sparseFetcher); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if fi.Size() != size { + t.Errorf("copy logical size %d, want %d", fi.Size(), int64(size)) + } + + srcAlloc := allocatedBytes(t, s.dataPath(key)) + if srcAlloc >= size/2 { + t.Skipf("cached entry did not end up sparse (%d of %d bytes allocated); this filesystem cannot report holes", srcAlloc, int64(size)) + } + if dstAlloc := allocatedBytes(t, dst); dstAlloc >= size/2 { + t.Errorf("copy allocated %d of %d bytes: holes were filled in", dstAlloc, int64(size)) + } +} + +func TestGetFileCopyToRejectsBadArguments(t *testing.T) { + s := newTestStore(t) + fetch, _ := countingFetcher("x") + ctx := context.Background() + + if err := s.GetFileCopyTo(ctx, Key{}, dstPath(t, s, "zero"), fetch); err == nil { + t.Error("zero key accepted") + } + if err := s.GetFileCopyTo(ctx, URIKey("k"), "relative/path", fetch); err == nil { + t.Error("relative destination accepted") + } + if err := s.GetFileCopyTo(ctx, URIKey("k"), dstPath(t, s, "nilfetch"), nil); err == nil { + t.Error("nil fetcher accepted") + } + + exists := dstPath(t, s, "exists") + if err := os.WriteFile(exists, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.GetFileCopyTo(ctx, URIKey("k"), exists, fetch); err == nil { + t.Error("existing destination accepted") + } + if got, _ := os.ReadFile(exists); string(got) != "occupied" { + t.Errorf("existing destination was overwritten: %q", got) + } +} diff --git a/cmd/atelet/internal/filecache/getfileto.go b/cmd/atelet/internal/filecache/getfileto.go new file mode 100644 index 0000000000..077b4c224b --- /dev/null +++ b/cmd/atelet/internal/filecache/getfileto.go @@ -0,0 +1,206 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "syscall" + "time" +) + +// FileFetcher downloads a single-file artifact to dstPath, creating the file +// itself so it can seek and truncate for sparse output. The store runs it at +// most once per key across concurrent callers, detached from their contexts +// and bounded by the fetch timeout. Its error reaches every waiting caller +// wrapped with %w, so errors.Is classification sees through the store. +type FileFetcher func(ctx context.Context, dstPath string) error + +// serveHit materializes a published cache entry at the caller's destination: +// (*Store).linkOut serves a hard link, (*Store).copyOut a private copy. It +// reports false, with no error, on a cache miss. +type serveHit func(key Key, dst string) (bool, error) + +// linkRetries bounds the fetch-then-serve loop. An entry can be evicted +// between a fetch publishing and this caller's serve only if it sat unused +// past the store's min age, so a single retry is already an anomaly; more +// than a few means something else is deleting under the store root. +const linkRetries = 3 + +// GetFileTo materializes the artifact identified by key at dst, fetching it +// with fetch if it is not cached. dst must be an absolute path that does not +// exist yet and lives on the cache's filesystem (the same mount, not just +// the same disk): on success it is a hard link to the read-only cache +// copy, so the caller keeps a valid file regardless of later eviction, and +// the entry is published mode 0444 so an in-place write fails loudly rather +// than corrupting the shared bytes. +// +// Concurrent calls for one key share a single fetch. A caller whose ctx is +// canceled returns early with ctx.Err() while the fetch keeps running for +// the others; there is no negative caching, so after a failed fetch the +// next call starts fresh. +func (s *Store) GetFileTo(ctx context.Context, key Key, dst string, fetch FileFetcher) error { + return s.getTo(ctx, key, dst, fetch, s.linkOut) +} + +// getTo is the read-through loop shared by GetFileTo and GetFileCopyTo: +// serve a hit, else run the singleflight fetch and retry. serve is the only +// difference between the two public methods. +func (s *Store) getTo(ctx context.Context, key Key, dst string, fetch FileFetcher, serve serveHit) error { + if key.isZero() { + return errors.New("filecache: zero Key (use a Key constructor)") + } + if !filepath.IsAbs(dst) { + return fmt.Errorf("filecache: destination %q is not an absolute path", dst) + } + if fetch == nil { + return errors.New("filecache: nil FileFetcher") + } + for attempt := 0; ; attempt++ { + served, err := serve(key, dst) + if err != nil { + return err + } + if served { + slog.InfoContext(ctx, "filecache: hit", + slog.String("key", key.String()), + slog.String("dst", dst), + slog.Int("attempt", attempt)) + return nil + } + if attempt >= linkRetries { + return fmt.Errorf("entry for %v vanished after %d fetches; is something else deleting under the store root?", key, attempt) + } + if err := s.fetchFlight(ctx, key, fetch); err != nil { + return err + } + } +} + +// linkOut links key's published data file to dst and touches the entry's +// last-use clock, reporting false (and no error) on a cache miss. The +// shared hitMu holds eviction's final re-check out of the stat-to-link +// window, so a published entry cannot be retired mid-hit. +func (s *Store) linkOut(key Key, dst string) (bool, error) { + s.hitMu.RLock() + defer s.hitMu.RUnlock() + + if _, err := os.Stat(s.dataPath(key)); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("while checking cache for %v: %w", key, err) + } + if err := os.Link(s.dataPath(key), dst); err != nil { + if errors.Is(err, fs.ErrExist) { + return false, fmt.Errorf("destination %s already exists: %w", dst, err) + } + if errors.Is(err, syscall.EXDEV) { + return false, fmt.Errorf("destination %s is not on the cache's filesystem (link-out requires one filesystem): %w", dst, err) + } + return false, fmt.Errorf("while linking %v to %s: %w", key, dst, err) + } + // Last-use touch for eviction's LRU ordering; best-effort, a miss only + // ages the entry. + now := time.Now() + _ = os.Chtimes(s.entryDir(key), now, now) + return true, nil +} + +// fetchFlight runs (or joins) the singleflight fetch for key and waits for +// it or for ctx. The flight itself runs on a context detached from the +// callers' — bounded only by the store's fetch timeout — so one canceled +// caller never aborts a download other callers are waiting on. +func (s *Store) fetchFlight(ctx context.Context, key Key, fetch FileFetcher) error { + ch := s.sf.DoChan(key.dir, func() (any, error) { + return nil, s.fetchAndPublish(context.WithoutCancel(ctx), key, fetch) + }) + select { + case res := <-ch: + if res.Err != nil { + return fmt.Errorf("while fetching %v: %w", key, res.Err) + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// fetchAndPublish runs one fetch into tmp/ and atomically publishes the +// result under entries/. On any failure the temp dir is removed, so a bad +// fetch is never visible in the cache; a crash instead leaves it for +// SweepDebris. +func (s *Store) fetchAndPublish(ctx context.Context, key Key, fetch FileFetcher) error { + ctx, cancel := context.WithTimeout(ctx, s.fetchTimeout) + defer cancel() + + // A flight that completed between this caller's miss and this flight + // starting may have published already. + if _, err := os.Stat(s.dataPath(key)); err == nil { + return nil + } + + tmpDir, err := os.MkdirTemp(s.tmpDir(), key.dir+"-") + if err != nil { + return fmt.Errorf("while creating fetch temp dir: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(tmpDir) + } + }() + + dataPath := filepath.Join(tmpDir, dataName) + if err := fetch(ctx, dataPath); err != nil { + return err + } + fi, err := os.Stat(dataPath) + if err != nil { + return fmt.Errorf("fetcher succeeded but produced no file: %w", err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("fetcher produced %v, want a regular file", fi.Mode()) + } + + // Read-only before publication: cached bytes are shared through hard + // links, so an in-place write by any consumer must fail rather than + // poison the copy every later caller links. + if err := os.Chmod(dataPath, 0o444); err != nil { + return fmt.Errorf("while making fetched file read-only: %w", err) + } + if err := writeEntryMeta(tmpDir, key, time.Now()); err != nil { + return err + } + if err := os.Chmod(tmpDir, 0o755); err != nil { // MkdirTemp created it 0700 + return fmt.Errorf("while setting entry dir mode: %w", err) + } + if err := os.Rename(tmpDir, s.entryDir(key)); err != nil { + // Within one process the singleflight makes a rename race + // unreachable; tolerating a loss anyway keeps overlap with a + // crashed predecessor's published entry safe. + if errors.Is(err, syscall.EEXIST) || errors.Is(err, syscall.ENOTEMPTY) { + return nil + } + return fmt.Errorf("while publishing entry: %w", err) + } + published = true + return nil +} diff --git a/cmd/atelet/internal/filecache/getfileto_test.go b/cmd/atelet/internal/filecache/getfileto_test.go new file mode 100644 index 0000000000..25c0d993d6 --- /dev/null +++ b/cmd/atelet/internal/filecache/getfileto_test.go @@ -0,0 +1,381 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" +) + +// countingFetcher returns a FileFetcher writing content, and a counter of +// how many times it ran. +func countingFetcher(content string) (FileFetcher, *atomic.Int32) { + var calls atomic.Int32 + return func(ctx context.Context, dstPath string) error { + calls.Add(1) + return os.WriteFile(dstPath, []byte(content), 0o600) + }, &calls +} + +// dstPath returns a fresh destination path (which must not exist yet) in a +// per-test consumer dir on the same filesystem as the store. +func dstPath(t *testing.T, s *Store, name string) string { + t.Helper() + dir := filepath.Join(s.root, "..", "consumer") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return filepath.Join(dir, name) +} + +func TestGetFileToMissThenHit(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, calls := countingFetcher("golden bytes") + + dst1 := dstPath(t, s, "d1") + if err := s.GetFileTo(context.Background(), key, dst1, fetch); err != nil { + t.Fatalf("GetFileTo (miss): %v", err) + } + got, err := os.ReadFile(dst1) + if err != nil || string(got) != "golden bytes" { + t.Fatalf("dst content = %q, %v; want %q", got, err, "golden bytes") + } + fi, err := os.Stat(dst1) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o444 { + t.Errorf("dst mode = %v, want 0444", fi.Mode().Perm()) + } + + dst2 := dstPath(t, s, "d2") + if err := s.GetFileTo(context.Background(), key, dst2, fetch); err != nil { + t.Fatalf("GetFileTo (hit): %v", err) + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } + + // Both destinations and the cache copy share one inode. + fi2, err := os.Stat(dst2) + if err != nil { + t.Fatal(err) + } + cfi, err := os.Stat(s.dataPath(key)) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(fi, fi2) || !os.SameFile(fi, cfi) { + t.Error("dst1, dst2, and cache copy are not one inode") + } + + // Nothing left in flight. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d leftover children", len(tmpChildren)) + } +} + +func TestGetFileToConcurrentCallersShareOneFetch(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(ctx context.Context, dstPath string) error { + calls.Add(1) + <-release // hold every caller in one flight + return os.WriteFile(dstPath, []byte("x"), 0o600) + } + + const n = 16 + errs := make([]error, n) + var started, done sync.WaitGroup + for i := range n { + started.Add(1) + done.Go(func() { + started.Done() + errs[i] = s.GetFileTo(context.Background(), key, dstPath(t, s, fmt.Sprintf("d%d", i)), fetch) + }) + } + started.Wait() + close(release) + done.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("caller %d: %v", i, err) + } + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } +} + +func TestGetFileToCanceledWaiterDoesNotAbortFlight(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + + var calls atomic.Int32 + entered := make(chan struct{}) + release := make(chan struct{}) + fetch := func(ctx context.Context, dstPath string) error { + calls.Add(1) + close(entered) + select { + case <-release: + case <-ctx.Done(): // must NOT fire on the caller's cancel + return ctx.Err() + } + return os.WriteFile(dstPath, []byte("x"), 0o600) + } + + ctx, cancel := context.WithCancel(context.Background()) + callerErr := make(chan error, 1) + go func() { + callerErr <- s.GetFileTo(ctx, key, dstPath(t, s, "canceled"), fetch) + }() + <-entered + cancel() + if err := <-callerErr; !errors.Is(err, context.Canceled) { + t.Fatalf("canceled caller returned %v, want context.Canceled", err) + } + + // The flight is still running detached; releasing it publishes the + // entry, and a later caller hits the cache with no second fetch. + close(release) + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "later"), fetch); err != nil { + t.Fatalf("GetFileTo after canceled waiter: %v", err) + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } +} + +func TestGetFileToFetchErrorReachesAllWaitersThenRetries(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetchErr := errors.New("bucket unreachable") + + var calls atomic.Int32 + release := make(chan struct{}) + failing := func(ctx context.Context, dstPath string) error { + calls.Add(1) + <-release + return fetchErr + } + + const n = 4 + errs := make([]error, n) + var done sync.WaitGroup + for i := range n { + done.Go(func() { + errs[i] = s.GetFileTo(context.Background(), key, dstPath(t, s, fmt.Sprintf("f%d", i)), failing) + }) + } + // The one-fetch assertion below is only sound once every caller has + // joined the flight; a failed flight publishes nothing, so a late + // caller would (correctly) start a second fetch. synctest.Wait + // returns when all callers are durably blocked on the shared flight + // and the fetch on release — from here the outcome is deterministic. + synctest.Wait() + close(release) + done.Wait() + + for i, err := range errs { + if !errors.Is(err, fetchErr) { + t.Errorf("caller %d: %v, want the fetch error", i, err) + } + } + if calls.Load() != 1 { + t.Fatalf("failing fetch ran %d times, want 1", calls.Load()) + } + + // No debris and no published entry after the failure. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d children after failed fetch", len(tmpChildren)) + } + if _, err := os.Stat(s.entryDir(key)); !os.IsNotExist(err) { + t.Errorf("entry published despite failed fetch: err=%v", err) + } + + // No negative caching: the next call fetches fresh and succeeds. + ok, okCalls := countingFetcher("recovered") + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "retry"), ok); err != nil { + t.Fatalf("GetFileTo (retry): %v", err) + } + if okCalls.Load() != 1 { + t.Errorf("retry fetch ran %d times, want 1", okCalls.Load()) + } + }) +} + +// TestGetFileToFetchTimeoutBoundsAHungFetch pins the store's only bound on +// a runaway fetch: flights run detached from caller contexts, so a fetcher +// that never returns must be cut off by WithFetchTimeout — the caller gets +// the deadline error, nothing is published, and the key recovers. Run under +// synctest so the timeout fires on the fake clock instead of real time. +func TestGetFileToFetchTimeoutBoundsAHungFetch(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := newTestStore(t, WithFetchTimeout(30*time.Second)) + key := URIKey("gs://bucket/golden", "hung.img") + + var calls atomic.Int32 + hung := func(ctx context.Context, dstPath string) error { + calls.Add(1) + <-ctx.Done() // never produces the file + return ctx.Err() + } + err := s.GetFileTo(context.Background(), key, dstPath(t, s, "hung"), hung) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("hung fetch returned %v, want context.DeadlineExceeded", err) + } + if calls.Load() != 1 { + t.Errorf("fetch ran %d times, want 1", calls.Load()) + } + + // The abandoned flight leaves no debris and no published entry. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d children after timed-out fetch", len(tmpChildren)) + } + if _, err := os.Stat(s.entryDir(key)); !os.IsNotExist(err) { + t.Errorf("entry published despite timed-out fetch: err=%v", err) + } + + // The key is not poisoned: a healthy fetch succeeds next. + ok, okCalls := countingFetcher("recovered") + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "after"), ok); err != nil { + t.Fatalf("GetFileTo after timeout: %v", err) + } + if okCalls.Load() != 1 { + t.Errorf("recovery fetch ran %d times, want 1", okCalls.Load()) + } + }) +} + +func TestGetFileToRejectsExistingDst(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("x") + + dst := dstPath(t, s, "occupied") + if err := os.WriteFile(dst, []byte("previous"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.GetFileTo(context.Background(), key, dst, fetch); err == nil { + t.Fatal("GetFileTo to existing dst succeeded, want error") + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != "previous" { + t.Errorf("existing dst clobbered: %q, %v", got, err) + } +} + +func TestGetFileToRejectsBadArguments(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, calls := countingFetcher("x") + + if err := s.GetFileTo(context.Background(), Key{}, dstPath(t, s, "zero"), fetch); err == nil { + t.Error("GetFileTo with zero key succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, "", fetch); err == nil { + t.Error("GetFileTo with empty dst succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, "relative/dst", fetch); err == nil { + t.Error("GetFileTo with relative dst succeeded, want error") + } + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "nilfetch"), nil); err == nil { + t.Error("GetFileTo with nil fetcher succeeded, want error") + } + if calls.Load() != 0 { + t.Errorf("fetch ran %d times for rejected arguments, want 0", calls.Load()) + } +} + +func TestGetFileToFetcherProducingNoFileFails(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + noop := func(ctx context.Context, dstPath string) error { return nil } + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "empty"), noop); err == nil { + t.Fatal("GetFileTo with file-less fetcher succeeded, want error") + } + if _, err := os.Stat(s.entryDir(key)); !os.IsNotExist(err) { + t.Errorf("entry published despite file-less fetcher: err=%v", err) + } +} + +func TestGetFileToHitTouchesLastUse(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("x") + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "first"), fetch); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(key), stale, stale); err != nil { + t.Fatal(err) + } + + if err := s.GetFileTo(context.Background(), key, dstPath(t, s, "second"), fetch); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(s.entryDir(key)) + if err != nil { + t.Fatal(err) + } + if !fi.ModTime().After(stale.Add(time.Minute)) { + t.Errorf("entry mtime %v not refreshed by hit (stale mark %v)", fi.ModTime(), stale) + } +} + +func TestGetFileToPublishedCopyIsWriteProtected(t *testing.T) { + s := newTestStore(t) + key := URIKey("gs://bucket/golden", "mem.img") + fetch, _ := countingFetcher("precious") + + dst := dstPath(t, s, "d") + if err := s.GetFileTo(context.Background(), key, dst, fetch); err != nil { + t.Fatal(err) + } + // The mutation tripwire: writing through the consumer's link must fail, + // not silently poison the shared copy. + if err := os.WriteFile(dst, []byte("mutated"), 0o444); err == nil { + t.Error("write through consumer link succeeded, want permission error") + } +} diff --git a/cmd/atelet/internal/filecache/key.go b/cmd/atelet/internal/filecache/key.go new file mode 100644 index 0000000000..72102f8e71 --- /dev/null +++ b/cmd/atelet/internal/filecache/key.go @@ -0,0 +1,77 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// Key identifies a cache entry. It is an identity, not an address: it says +// nothing about where the bytes come from (the fetcher does), only what they +// are, so two sources serving the same identity share one entry. +// +// Keys are built via constructors only. The zero Key is invalid. +type Key struct { + // canonical is the unambiguous string form, recorded in the entry's + // meta.json for debugging. A constructor-specific prefix keeps the key + // spaces disjoint (a sha256 digest can never equal a URI key). + canonical string + // dir is hex(sha256(canonical)): the entry's directory name under + // entries/. Hashing (rather than escaping the canonical form) yields a + // fixed-length, filesystem-safe name for arbitrarily long keys, and lets + // a GC root set be matched against entry dirs by hashing its keys. + dir string +} + +// keyPartSeparator joins URIKey parts unambiguously: NUL cannot appear in a +// URI or file name, so ("a/b","c") and ("a","b/c") canonicalize differently. +const keyPartSeparator = "\x00" + +// SHA256Key returns the key for a content-addressed artifact, identified by +// the lowercase hex sha256 of its bytes. Uppercase digits are normalized so +// equal digests always yield equal keys. +func SHA256Key(hexDigest string) (Key, error) { + d := strings.ToLower(hexDigest) + if len(d) != sha256.Size*2 { + return Key{}, fmt.Errorf("sha256 key: digest %q has length %d, want %d", hexDigest, len(d), sha256.Size*2) + } + if _, err := hex.DecodeString(d); err != nil { + return Key{}, fmt.Errorf("sha256 key: digest %q is not hex: %w", hexDigest, err) + } + return newKey("sha256:" + d), nil +} + +// URIKey returns the key for an artifact identified by an immutable source, +// e.g. URIKey(goldenSnapshotURI, fileName). The parts must identify content +// that never changes underneath them; the cache has no invalidation, so a +// republished URI would serve stale bytes forever. Callers pass at least one +// non-empty part. +func URIKey(parts ...string) Key { + return newKey("uri:" + strings.Join(parts, keyPartSeparator)) +} + +func newKey(canonical string) Key { + sum := sha256.Sum256([]byte(canonical)) + return Key{canonical: canonical, dir: hex.EncodeToString(sum[:])} +} + +// String returns the canonical form, for logs and meta.json. +func (k Key) String() string { return k.canonical } + +// isZero reports whether k was not built by a constructor. +func (k Key) isZero() bool { return k.dir == "" } diff --git a/cmd/atelet/internal/filecache/key_test.go b/cmd/atelet/internal/filecache/key_test.go new file mode 100644 index 0000000000..ad27d9647c --- /dev/null +++ b/cmd/atelet/internal/filecache/key_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "regexp" + "strings" + "testing" +) + +const testDigest = "af2a7458c2c05df1a01d0b2f335f4849a2de84e83160fefdc31a6266015642d4" + +var entryDirNameRE = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func TestSHA256Key(t *testing.T) { + k, err := SHA256Key(testDigest) + if err != nil { + t.Fatalf("SHA256Key(%q): %v", testDigest, err) + } + if want := "sha256:" + testDigest; k.String() != want { + t.Errorf("String() = %q, want %q", k.String(), want) + } + if !entryDirNameRE.MatchString(k.dir) { + t.Errorf("dir = %q, want 64 lowercase hex chars", k.dir) + } + + upper, err := SHA256Key(strings.ToUpper(testDigest)) + if err != nil { + t.Fatalf("SHA256Key(upper): %v", err) + } + if upper != k { + t.Errorf("uppercase digest yielded a different key: %q vs %q", upper.dir, k.dir) + } +} + +func TestSHA256KeyRejectsBadDigests(t *testing.T) { + for _, digest := range []string{ + "", + "abc123", // too short + testDigest + "00", // too long + testDigest[:63] + "g", // not hex + } { + if _, err := SHA256Key(digest); err == nil { + t.Errorf("SHA256Key(%q) succeeded, want error", digest) + } + } +} + +func TestURIKey(t *testing.T) { + base := URIKey("gs://bucket/golden-v3", "mem.img") + if base != URIKey("gs://bucket/golden-v3", "mem.img") { + t.Error("equal parts yielded different keys") + } + if !entryDirNameRE.MatchString(base.dir) { + t.Errorf("dir = %q, want 64 lowercase hex chars", base.dir) + } + + // Part boundaries must be unambiguous: shifting a separator across a + // part boundary, or adding an empty part, is a different identity. + distinct := []Key{ + base, + URIKey("gs://bucket/golden-v3/mem.img"), + URIKey("gs://bucket/golden-v3", "mem.img", ""), + URIKey("gs://bucket", "golden-v3/mem.img"), + } + for i, a := range distinct { + for j, b := range distinct { + if i != j && a == b { + t.Errorf("keys %d and %d collide: %q", i, j, a.dir) + } + } + } +} + +func TestKeySpacesAreDisjoint(t *testing.T) { + // A URI that happens to spell a digest must not collide with the + // content-addressed key for that digest. + sha, err := SHA256Key(testDigest) + if err != nil { + t.Fatal(err) + } + if uri := URIKey(testDigest); uri == sha { + t.Errorf("URIKey and SHA256Key collide for %q", testDigest) + } +} diff --git a/cmd/atelet/internal/filecache/store.go b/cmd/atelet/internal/filecache/store.go new file mode 100644 index 0000000000..b9d1649682 --- /dev/null +++ b/cmd/atelet/internal/filecache/store.go @@ -0,0 +1,143 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + entriesDirName = "entries" + tmpDirName = "tmp" + // rmPrefix marks a retired entry awaiting removal, at the store root (not + // under entries/, so a retired entry is invisible to lookups and GC + // listings the moment it is renamed). + rmPrefix = ".rm-" + + dataName = "data" + metaName = "meta.json" + + // defaultMinAge is the default eviction minimum age (see WithMinAge). + defaultMinAge = 10 * time.Minute + // defaultFetchTimeout is the default per-fetch bound (see + // WithFetchTimeout). Generous enough for multi-GiB artifacts on a busy + // node. + defaultFetchTimeout = 10 * time.Minute +) + +// Store is one on-disk cache. It is safe for concurrent use and assumes it +// is the only writer under its root (one atelet per node). +type Store struct { + root string + + // minAge vetoes eviction of any entry younger than this, covering the + // window between publication and the consumer's use becoming visible to + // GC (a hardlink's Nlink, or a root-set record). + minAge time.Duration + + // fetchTimeout bounds each fetch. Fetches run detached from the contexts + // of the callers waiting on them, so this is the only bound on how long + // one can run. + fetchTimeout time.Duration + + // sf collapses concurrent fetches of the same key into one flight. + // Eviction retires entries inside the same flight, so a retire can + // never race a fetch of the key it is removing. + sf singleflight.Group + + // hitMu closes the hit-vs-evict window: held shared while serving a hit + // (stat, link or open, and last-use touch), exclusive by eviction's + // final re-check and retire rename. An entry therefore cannot vanish + // mid-hit. Uncontended except during an eviction pass. + hitMu sync.RWMutex + + // evictMu serializes EvictUnused passes (concurrent passes would fight + // over the same candidates for no benefit). + evictMu sync.Mutex +} + +// Option configures a Store. +type Option func(*Store) + +// WithMinAge sets the eviction minimum age. +func WithMinAge(d time.Duration) Option { + return func(s *Store) { s.minAge = d } +} + +// WithFetchTimeout sets the per-fetch bound. +func WithFetchTimeout(d time.Duration) Option { + return func(s *Store) { s.fetchTimeout = d } +} + +// New opens (creating if needed) the store rooted at root. +func New(root string, opts ...Option) (*Store, error) { + s := &Store{ + root: root, + minAge: defaultMinAge, + fetchTimeout: defaultFetchTimeout, + } + for _, opt := range opts { + opt(s) + } + // Entries are world-readable (their files get hard-linked into consumer + // dirs and, later, consumed in place); tmp holds unpublished fetches and + // stays private. + if err := os.MkdirAll(s.entriesDir(), 0o755); err != nil { + return nil, fmt.Errorf("while creating entries dir: %w", err) + } + if err := os.MkdirAll(s.tmpDir(), 0o700); err != nil { + return nil, fmt.Errorf("while creating tmp dir: %w", err) + } + return s, nil +} + +func (s *Store) entriesDir() string { return filepath.Join(s.root, entriesDirName) } +func (s *Store) tmpDir() string { return filepath.Join(s.root, tmpDirName) } + +// entryDir is the published location of k's entry. +func (s *Store) entryDir(k Key) string { return filepath.Join(s.entriesDir(), k.dir) } + +// dataPath is the published location of k's cached file (or tree). +func (s *Store) dataPath(k Key) string { return filepath.Join(s.entryDir(k), dataName) } + +// entryMeta is the debugging sidecar written next to an entry's data. It is +// never read on a correctness path; a missing or corrupt one affects +// nothing. +type entryMeta struct { + // Key is the canonical key string, so an operator staring at du output + // can tell what an entry holds. + Key string `json:"key"` + CreatedAt time.Time `json:"createdAt"` +} + +// writeEntryMeta writes the meta.json sidecar into an (unpublished) entry +// dir. +func writeEntryMeta(entryDir string, k Key, createdAt time.Time) error { + data, err := json.Marshal(entryMeta{Key: k.String(), CreatedAt: createdAt}) + if err != nil { + return fmt.Errorf("while marshaling entry meta: %w", err) + } + if err := os.WriteFile(filepath.Join(entryDir, metaName), data, 0o644); err != nil { + return fmt.Errorf("while writing entry meta: %w", err) + } + return nil +} diff --git a/cmd/atelet/internal/filecache/stress_test.go b/cmd/atelet/internal/filecache/stress_test.go new file mode 100644 index 0000000000..d0fa46238b --- /dev/null +++ b/cmd/atelet/internal/filecache/stress_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "fmt" + "math/rand" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestStressGetsAgainstEviction runs linkers, copiers, and evictors against +// the same keys under the race detector. The invariant: every get succeeds +// with correct content — eviction may force refetches, never a failure or +// corruption. The store's min age must exceed a get's publish-to-use +// window, the same sizing contract production relies on. +// +// Two seeded keys stay idle (getters never touch them), so the first +// eviction pass retires something regardless of scheduling, and a +// post-storm get of an idle key checks the eviction-forced refetch. +// Hot-key evictions remain best-effort: constant hits legitimately keep +// them young. +func TestStressGetsAgainstEviction(t *testing.T) { + s := newTestStore(t, WithMinAge(200*time.Millisecond)) + const keys = 8 + // Getters draw only from the first hotKeys keys; the rest exist to be + // evicted deterministically. + const hotKeys = keys - 2 + const getters = 4 + duration := 700 * time.Millisecond + if testing.Short() { + duration = 200 * time.Millisecond + } + + content := func(k int) string { return fmt.Sprintf("content-%d", k) } + var fetches atomic.Int32 + fetcherFor := func(k int) FileFetcher { + return func(ctx context.Context, dstPath string) error { + fetches.Add(1) + return os.WriteFile(dstPath, []byte(content(k)), 0o600) + } + } + + // Pre-populate every key and age the entries past minAge so evictors + // have real work from the start; drop the seed links so entries are + // evictable as unlinked. + for k := range keys { + seed := dstPath(t, s, fmt.Sprintf("seed-%d", k)) + if err := s.GetFileTo(context.Background(), URIKey("stress", fmt.Sprint(k)), seed, fetcherFor(k)); err != nil { + t.Fatal(err) + } + if err := os.Remove(seed); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(s.entryDir(URIKey("stress", fmt.Sprint(k))), old, old); err != nil { + t.Fatal(err) + } + } + + stop := make(chan struct{}) + errCh := make(chan error, getters+2) + var wg sync.WaitGroup + + for g := range getters { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(g))) + // Half the getters serve hard links, half private copies, so + // the storm covers both hit paths against eviction. + get := s.GetFileTo + if g%2 == 1 { + get = s.GetFileCopyTo + } + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + k := rng.Intn(hotKeys) + dst := dstPath(t, s, fmt.Sprintf("g%d-i%d", g, i)) + if err := get(context.Background(), URIKey("stress", fmt.Sprint(k)), dst, fetcherFor(k)); err != nil { + errCh <- fmt.Errorf("getter %d: %w", g, err) + return + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != content(k) { + errCh <- fmt.Errorf("getter %d key %d: content %q, err %v", g, k, got, err) + return + } + // Dropping some links keeps a supply of unlinked entries so + // evictors exercise the freeing path, not just pending. + if i%2 == 0 { + if err := os.Remove(dst); err != nil { + errCh <- err + return + } + } + } + }) + } + var retired atomic.Int64 + for range 2 { + wg.Go(func() { + for { + select { + case <-stop: + return + default: + } + stats, err := s.EvictUnused(context.Background(), evictAll, false) + if err != nil { + errCh <- fmt.Errorf("evictor: %w", err) + return + } + retired.Add(int64(stats.Retired)) + time.Sleep(5 * time.Millisecond) + } + }) + } + + time.Sleep(duration) + close(stop) + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } + + // The idle seeds are aged, unlinked, and never touched, so any single + // eviction pass must have retired them: a storm that retired nothing + // means eviction never ran at all. + if retired.Load() < keys-hotKeys { + t.Errorf("storm retired %d entries; the %d idle seeds alone should have been retired", retired.Load(), keys-hotKeys) + } + // And the flip side of eviction is a refetch: getting an evicted idle + // key again must run the fetcher, not find a stale entry. + preRefetch := fetches.Load() + idle := keys - 1 + refetched := dstPath(t, s, "post-storm-refetch") + if err := s.GetFileTo(context.Background(), URIKey("stress", fmt.Sprint(idle)), refetched, fetcherFor(idle)); err != nil { + t.Fatalf("post-storm get of evicted idle key: %v", err) + } + if got, err := os.ReadFile(refetched); err != nil || string(got) != content(idle) { + t.Errorf("post-storm refetch content %q, err %v", got, err) + } + if fetches.Load() != preRefetch+1 { + t.Errorf("post-storm get of an evicted idle key ran %d fetches, want 1 (was it never evicted?)", fetches.Load()-preRefetch) + } + + // The storm must not leave half-states behind: tmp/ is empty (every + // fetch published or cleaned up) and any .rm-* residue is sweepable. + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + t.Fatal(err) + } + if len(tmpChildren) != 0 { + t.Errorf("tmp dir has %d children after storm", len(tmpChildren)) + } + if _, err := s.SweepDebris(context.Background()); err != nil { + t.Errorf("SweepDebris after storm: %v", err) + } + if total, err := s.TotalBytes(context.Background()); err != nil || total < 0 { + t.Errorf("TotalBytes after storm: %d, %v", total, err) + } + t.Logf("storm: %d fetches across %d keys", fetches.Load(), keys) + + // Surviving consumer links must read back intact even if their entries + // were evicted during the storm. + consumer := filepath.Join(s.root, "..", "consumer") + kept, err := filepath.Glob(filepath.Join(consumer, "g*-i*")) + if err != nil { + t.Fatal(err) + } + for _, p := range kept { + if got, err := os.ReadFile(p); err != nil || len(got) == 0 { + t.Errorf("surviving link %s unreadable: %q, %v", filepath.Base(p), got, err) + } + } +} diff --git a/cmd/atelet/internal/filecache/sweep.go b/cmd/atelet/internal/filecache/sweep.go new file mode 100644 index 0000000000..e47a1a70c7 --- /dev/null +++ b/cmd/atelet/internal/filecache/sweep.go @@ -0,0 +1,83 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package filecache + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SweepStats reports what a SweepDebris pass removed. +type SweepStats struct { + // TmpRemoved counts removed tmp/ children (unfinished fetches). + TmpRemoved int + // RetiredRemoved counts removed .rm-* dirs (interrupted evictions). + RetiredRemoved int +} + +// SweepDebris removes crash debris: everything under tmp/ (fetches a crash +// cut short) and every .rm-* dir at the store root (evictions that renamed +// but never removed). It must run once at startup, before the store serves +// requests, and not again: a later sweep would delete the working +// directories of in-flight fetches. No periodic schedule is needed either — +// debris only appears at crash or eviction time, and eviction passes retry +// leftover .rm-* removals themselves. +// +// Removal failures are joined and reported after the sweep visits +// everything, so one bad path does not shadow the rest; published entries +// are never touched. +func (s *Store) SweepDebris(ctx context.Context) (SweepStats, error) { + var stats SweepStats + var errs []error + + tmpChildren, err := os.ReadDir(s.tmpDir()) + if err != nil { + errs = append(errs, fmt.Errorf("while listing tmp dir: %w", err)) + } + for _, child := range tmpChildren { + if err := ctx.Err(); err != nil { + return stats, err + } + if err := os.RemoveAll(filepath.Join(s.tmpDir(), child.Name())); err != nil { + errs = append(errs, fmt.Errorf("while removing tmp debris %q: %w", child.Name(), err)) + continue + } + stats.TmpRemoved++ + } + + rootChildren, err := os.ReadDir(s.root) + if err != nil { + errs = append(errs, fmt.Errorf("while listing store root: %w", err)) + } + for _, child := range rootChildren { + if err := ctx.Err(); err != nil { + return stats, err + } + if !strings.HasPrefix(child.Name(), rmPrefix) { + continue + } + if err := os.RemoveAll(filepath.Join(s.root, child.Name())); err != nil { + errs = append(errs, fmt.Errorf("while removing retired entry %q: %w", child.Name(), err)) + continue + } + stats.RetiredRemoved++ + } + + return stats, errors.Join(errs...) +} diff --git a/cmd/atelet/copyrange_linux.go b/cmd/atelet/internal/sparsefile/copyrange_linux.go similarity index 99% rename from cmd/atelet/copyrange_linux.go rename to cmd/atelet/internal/sparsefile/copyrange_linux.go index 01f0320261..a450d6976c 100644 --- a/cmd/atelet/copyrange_linux.go +++ b/cmd/atelet/internal/sparsefile/copyrange_linux.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package sparsefile import ( "errors" diff --git a/cmd/atelet/copyrange_linux_test.go b/cmd/atelet/internal/sparsefile/copyrange_linux_test.go similarity index 99% rename from cmd/atelet/copyrange_linux_test.go rename to cmd/atelet/internal/sparsefile/copyrange_linux_test.go index 4ea3c009a3..4ee200dbca 100644 --- a/cmd/atelet/copyrange_linux_test.go +++ b/cmd/atelet/internal/sparsefile/copyrange_linux_test.go @@ -14,7 +14,7 @@ //go:build linux -package main +package sparsefile import ( "bytes" diff --git a/cmd/atelet/copyrange_other.go b/cmd/atelet/internal/sparsefile/copyrange_other.go similarity index 97% rename from cmd/atelet/copyrange_other.go rename to cmd/atelet/internal/sparsefile/copyrange_other.go index 6747757cbe..078d416438 100644 --- a/cmd/atelet/copyrange_other.go +++ b/cmd/atelet/internal/sparsefile/copyrange_other.go @@ -14,7 +14,7 @@ //go:build !linux -package main +package sparsefile // kernelCopyRange has no implementation off Linux (atelet runs on Linux; this keeps // the package building for local development on other platforms), so callers copy diff --git a/cmd/atelet/internal/sparsefile/sparsefile.go b/cmd/atelet/internal/sparsefile/sparsefile.go new file mode 100644 index 0000000000..9fb0d66472 --- /dev/null +++ b/cmd/atelet/internal/sparsefile/sparsefile.go @@ -0,0 +1,197 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package sparsefile copies files while preserving holes. +// +// The biggest thing atelet copies is a guest memory image, which is mostly +// unallocated: a plain io.Copy reads holes as zeroes and writes them as +// data, inflating a snapshot to its full logical size. That costs disk on +// every copy, and it destroys the sparseness later stages rely on to tell +// which parts of guest RAM actually hold anything. +package sparsefile + +import ( + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +// errSparseUnsupported means the source filesystem cannot report holes, so the caller +// should fall back to a dense copy. +var errSparseUnsupported = errors.New("filesystem cannot report holes") + +// errKernelCopyUnsupported means this platform, kernel or filesystem cannot copy a +// range in the kernel, so the caller should copy through userspace instead. +var errKernelCopyUnsupported = errors.New("kernel range copy unsupported") + +// CopyFile copies src to dst, preserving holes where it can, and returns the number of +// logical bytes copied. dst is created (truncated if it exists), like os.Create. +func CopyFile(src, dst string) (int64, error) { + sourceFileStat, err := os.Stat(src) + if err != nil { + return 0, err + } + + if !sourceFileStat.Mode().IsRegular() { + return 0, fmt.Errorf("%s is not a regular file", src) + } + + source, err := os.Open(src) + if err != nil { + return 0, err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return 0, err + } + + n, err := copyTo(source, destination, sourceFileStat.Size()) + return n, errors.Join(err, destination.Close()) +} + +// Copy copies the open handle src into the open handle dst, preserving holes +// where it can, and returns the number of logical bytes copied. Neither +// handle is closed, and both handles' offsets are clobbered. It exists for +// callers that must control how the endpoints are opened — e.g. a source +// opened before its directory entry could vanish, or a destination created +// with O_EXCL. +// +// dst must be empty (freshly created): holes are never written, so any +// pre-existing destination bytes would show through them, silently +// corrupting the copy. A non-empty destination is rejected up front. +func Copy(src, dst *os.File) (int64, error) { + fi, err := src.Stat() + if err != nil { + return 0, err + } + if !fi.Mode().IsRegular() { + return 0, fmt.Errorf("%s is not a regular file", src.Name()) + } + dfi, err := dst.Stat() + if err != nil { + return 0, err + } + if dfi.Size() != 0 { + return 0, fmt.Errorf("destination %s is not empty (%d bytes); stale bytes would show through the copy's holes", dst.Name(), dfi.Size()) + } + return copyTo(src, dst, fi.Size()) +} + +// copyTo copies size bytes from source to destination, sparsely when the +// source filesystem reports holes and densely otherwise. +func copyTo(source, destination *os.File, size int64) (int64, error) { + switch err := copySparse(source, destination, size); { + case err == nil: + return size, nil + case !errors.Is(err, errSparseUnsupported): + return 0, err + } + // Unsupported: nothing has been written yet, but probing moved the read + // offset, so rewind before the dense copy below. + if _, err := source.Seek(0, io.SeekStart); err != nil { + return 0, err + } + return io.Copy(destination, source) +} + +// copySparse writes only src's populated extents to dst, located with SEEK_DATA and +// SEEK_HOLE, leaving the rest of dst unallocated. It reports errSparseUnsupported +// before writing anything if the filesystem cannot report holes. +// +// Extents are copied in the kernel where possible. The dense io.Copy this replaces got +// that for free (os.File's ReadFrom uses copy_file_range), so without it a fully +// populated file — a guest that really did touch all its RAM — would copy slower than +// before. +func copySparse(src, dst *os.File, size int64) error { + fd := int(src.Fd()) + + // Probe first so an unsupported filesystem falls back with dst untouched. ENXIO + // means the seek ran but found no data at all, i.e. the file is one big hole. + if _, err := unix.Seek(fd, 0, unix.SEEK_DATA); err != nil { + if errors.Is(err, unix.ENXIO) { + return dst.Truncate(size) + } + return errSparseUnsupported + } + if err := dst.Truncate(size); err != nil { + return err + } + + // The kernel copies extents until it declines (an old kernel, or source + // and destination on different filesystems); the rest goes through + // userspace. + useKernel := true + var buf []byte + + for off := int64(0); off < size; { + dataOff, err := unix.Seek(fd, off, unix.SEEK_DATA) + if err != nil { + if errors.Is(err, unix.ENXIO) { + break // no data past off; the tail is a hole + } + return fmt.Errorf("seeking to data at %d: %w", off, err) + } + if dataOff >= size { + break // data starts past the size we were asked to copy + } + holeOff, err := unix.Seek(fd, dataOff, unix.SEEK_HOLE) + if err != nil { + return fmt.Errorf("seeking to hole at %d: %w", dataOff, err) + } + // Refuse to spin: every iteration must move off forward, which a + // filesystem reporting a hole at or before where we started would not. + if holeOff <= off { + return fmt.Errorf("seeking to hole at %d returned non-advancing offset %d", dataOff, holeOff) + } + if holeOff > size { + holeOff = size + } + for pos := dataOff; pos < holeOff; { + if useKernel { + copied, err := kernelCopyRange(fd, int(dst.Fd()), pos, holeOff-pos) + if err == nil { + pos += copied + continue + } + if !errors.Is(err, errKernelCopyUnsupported) { + return fmt.Errorf("copying %d bytes at %d: %w", holeOff-pos, pos, err) + } + // Give up on the kernel path for the rest of this file, but redo + // this chunk below: nothing was copied. + useKernel = false + } + if buf == nil { + buf = make([]byte, 4<<20) + } + n := int64(len(buf)) + if rem := holeOff - pos; rem < n { + n = rem + } + if _, err := src.ReadAt(buf[:n], pos); err != nil { + return fmt.Errorf("reading %d bytes at %d: %w", n, pos, err) + } + if _, err := dst.WriteAt(buf[:n], pos); err != nil { + return fmt.Errorf("writing %d bytes at %d: %w", n, pos, err) + } + pos += n + } + off = holeOff + } + return nil +} diff --git a/cmd/atelet/internal/sparsefile/sparsefile_test.go b/cmd/atelet/internal/sparsefile/sparsefile_test.go new file mode 100644 index 0000000000..fc38fe4945 --- /dev/null +++ b/cmd/atelet/internal/sparsefile/sparsefile_test.go @@ -0,0 +1,317 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sparsefile + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCopyFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + want := []byte("checkpoint pages") + if err := os.WriteFile(src, want, 0o600); err != nil { + t.Fatalf("seeding src: %v", err) + } + + dst := filepath.Join(dir, "dst") + n, err := CopyFile(src, dst) + if err != nil { + t.Fatalf("CopyFile: %v", err) + } + if n != int64(len(want)) { + t.Errorf("copied %d bytes, want %d", n, len(want)) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading dst: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("dst content = %q, want %q", got, want) + } + + if _, err := CopyFile(dir, filepath.Join(dir, "dst2")); err == nil { + t.Error("CopyFile(directory, ...) succeeded, want error") + } +} + +// allocatedBytes reports how much disk a file actually occupies, which is less than its +// logical size when it has holes. +func allocatedBytes(t *testing.T, path string) int64 { + t.Helper() + var st syscall.Stat_t + if err := syscall.Stat(path, &st); err != nil { + t.Fatalf("stat %q: %v", path, err) + } + return st.Blocks * 512 +} + +func TestCopyFilePreservesHoles(t *testing.T) { + const ( + size = 32 << 20 + markerAt = 16 << 20 + ) + dir := t.TempDir() + src := filepath.Join(dir, "memory-ranges") + + // A stand-in for a guest memory image: mostly hole, with data at both the start + // and the middle. + f, err := os.Create(src) + if err != nil { + t.Fatalf("creating src: %v", err) + } + if err := f.Truncate(size); err != nil { + t.Fatalf("sizing src: %v", err) + } + head := bytes.Repeat([]byte{0xAB}, 4<<10) + middle := bytes.Repeat([]byte{0xCD}, 4<<10) + if _, err := f.WriteAt(head, 0); err != nil { + t.Fatalf("writing head: %v", err) + } + if _, err := f.WriteAt(middle, markerAt); err != nil { + t.Fatalf("writing middle: %v", err) + } + if err := errors.Join(f.Sync(), f.Close()); err != nil { + t.Fatalf("flushing src: %v", err) + } + + dst := filepath.Join(dir, "copied") + n, err := CopyFile(src, dst) + if err != nil { + t.Fatalf("CopyFile: %v", err) + } + if n != size { + t.Errorf("copied %d logical bytes, want %d", n, size) + } + + // The copy must be byte-identical, holes included. + want, err := os.ReadFile(src) + if err != nil { + t.Fatalf("reading src: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading dst: %v", err) + } + if !bytes.Equal(want, got) { + t.Fatal("copy differs from source") + } + + srcAlloc, dstAlloc := allocatedBytes(t, src), allocatedBytes(t, dst) + if srcAlloc >= size/2 { + t.Skipf("source did not end up sparse (%d of %d bytes allocated); "+ + "this filesystem cannot report holes", srcAlloc, int64(size)) + } + // A dense copy would allocate the full logical size; a hole-preserving one stays + // near the source's footprint. + if dstAlloc > srcAlloc*4 { + t.Errorf("copy allocated %d bytes for a %d-byte source (logical %d): holes were filled in", + dstAlloc, srcAlloc, int64(size)) + } +} + +func TestCopyFileAllHoles(t *testing.T) { + const size = 8 << 20 + dir := t.TempDir() + src := filepath.Join(dir, "empty") + f, err := os.Create(src) + if err != nil { + t.Fatalf("creating src: %v", err) + } + if err := errors.Join(f.Truncate(size), f.Close()); err != nil { + t.Fatalf("sizing src: %v", err) + } + + dst := filepath.Join(dir, "copied") + if _, err := CopyFile(src, dst); err != nil { + t.Fatalf("CopyFile: %v", err) + } + st, err := os.Stat(dst) + if err != nil { + t.Fatalf("stat dst: %v", err) + } + if st.Size() != size { + t.Errorf("copy is %d bytes, want %d", st.Size(), int64(size)) + } +} + +// TestCopyFilePreservesHolesAcrossFilesystems covers the userspace fallback: +// with source and destination on different filesystems, copy_file_range +// fails with EXDEV and the extents are copied through userspace instead. It +// needs a second real filesystem, so it runs where one is available +// (/dev/shm on Linux) and skips elsewhere — on platforms with no kernel +// copy at all, the plain holes test above already exercises userspace. +func TestCopyFilePreservesHolesAcrossFilesystems(t *testing.T) { + otherFS, err := os.MkdirTemp("/dev/shm", "sparsefile-test-") + if err != nil { + t.Skipf("no second filesystem available for a cross-filesystem copy: %v", err) + } + t.Cleanup(func() { os.RemoveAll(otherFS) }) + + const size = 32 << 20 + dir := t.TempDir() + src := filepath.Join(dir, "memory-ranges") + f, err := os.Create(src) + if err != nil { + t.Fatalf("creating src: %v", err) + } + if err := f.Truncate(size); err != nil { + t.Fatalf("sizing src: %v", err) + } + marker := bytes.Repeat([]byte{0xEF}, 4<<10) + if _, err := f.WriteAt(marker, 8<<20); err != nil { + t.Fatalf("writing marker: %v", err) + } + if err := errors.Join(f.Sync(), f.Close()); err != nil { + t.Fatalf("flushing src: %v", err) + } + // Prove the directories really are on different filesystems; same-mount + // tmpdirs (some CI images) would silently test the kernel path instead. + if err := os.Link(src, filepath.Join(otherFS, "probe")); err == nil { + t.Skip("test dirs share a filesystem; cannot force the userspace fallback") + } + + dst := filepath.Join(otherFS, "copied") + if _, err := CopyFile(src, dst); err != nil { + t.Fatalf("CopyFile: %v", err) + } + + want, err := os.ReadFile(src) + if err != nil { + t.Fatalf("reading src: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading dst: %v", err) + } + if !bytes.Equal(want, got) { + t.Fatal("cross-filesystem copy differs from source") + } + + srcAlloc, dstAlloc := allocatedBytes(t, src), allocatedBytes(t, dst) + if srcAlloc >= size/2 { + t.Skipf("source did not end up sparse (%d of %d bytes allocated)", srcAlloc, int64(size)) + } + if dstAlloc > srcAlloc*4 { + t.Errorf("cross-filesystem copy allocated %d bytes for a %d-byte source: holes were filled in", + dstAlloc, srcAlloc) + } +} + +// TestCopyRejectsNonEmptyDestination pins Copy's freshness contract: holes +// are never written, so an all-hole source copied over existing bytes would +// "succeed" while the stale bytes show through. Copy must refuse instead. +func TestCopyRejectsNonEmptyDestination(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "src") + f, err := os.Create(srcPath) + if err != nil { + t.Fatal(err) + } + if err := errors.Join(f.Truncate(1<<20), f.Close()); err != nil { + t.Fatal(err) + } + src, err := os.Open(srcPath) + if err != nil { + t.Fatal(err) + } + defer src.Close() + + dstPath := filepath.Join(dir, "dst") + if err := os.WriteFile(dstPath, []byte("stale bytes"), 0o600); err != nil { + t.Fatal(err) + } + dst, err := os.OpenFile(dstPath, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer dst.Close() + + if _, err := Copy(src, dst); err == nil { + t.Fatal("Copy accepted a non-empty destination") + } + if got, err := os.ReadFile(dstPath); err != nil || string(got) != "stale bytes" { + t.Errorf("rejected copy modified the destination: %q, %v", got, err) + } +} + +// TestCopyHandles covers the open-handle entry point: sparse source copied +// through caller-owned handles (O_EXCL destination), byte-identical result, +// holes preserved, and a source whose name is gone mid-copy still copies — +// the open handle is the contract. +func TestCopyHandles(t *testing.T) { + const size = 16 << 20 + dir := t.TempDir() + srcPath := filepath.Join(dir, "src") + f, err := os.Create(srcPath) + if err != nil { + t.Fatalf("creating src: %v", err) + } + if err := f.Truncate(size); err != nil { + t.Fatalf("sizing src: %v", err) + } + marker := bytes.Repeat([]byte{0x42}, 4<<10) + if _, err := f.WriteAt(marker, 4<<20); err != nil { + t.Fatalf("writing marker: %v", err) + } + if err := errors.Join(f.Sync(), f.Close()); err != nil { + t.Fatalf("flushing src: %v", err) + } + + src, err := os.Open(srcPath) + if err != nil { + t.Fatal(err) + } + defer src.Close() + // Unlink the source name before copying: Copy exists for callers whose + // source can be evicted mid-copy, so only the handle may matter. + if err := os.Remove(srcPath); err != nil { + t.Fatal(err) + } + + dstPath := filepath.Join(dir, "dst") + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + t.Fatal(err) + } + n, err := Copy(src, dst) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if err := dst.Close(); err != nil { + t.Fatal(err) + } + if n != size { + t.Errorf("copied %d logical bytes, want %d", n, size) + } + + got, err := os.ReadFile(dstPath) + if err != nil { + t.Fatal(err) + } + if int64(len(got)) != size || !bytes.Equal(got[4<<20:4<<20+len(marker)], marker) { + t.Error("handle copy differs from source") + } + for _, b := range got[:4<<20] { + if b != 0 { + t.Fatal("hole region contains data") + } + } +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 40cd6d0eba..4fdb574301 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -21,7 +21,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" "net" "os" @@ -35,6 +34,7 @@ import ( "sync" "github.com/agent-substrate/substrate/cmd/atelet/internal/ategcs" + "github.com/agent-substrate/substrate/cmd/atelet/internal/sparsefile" "github.com/agent-substrate/substrate/internal/actorlog" "github.com/agent-substrate/substrate/internal/ateapiauth" "github.com/agent-substrate/substrate/internal/ateattr" @@ -69,7 +69,6 @@ import ( "go.opentelemetry.io/otel/metric" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "golang.org/x/sync/errgroup" - "golang.org/x/sys/unix" "google.golang.org/api/option" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -1323,7 +1322,7 @@ func (s *AteomHerder) copyLocalCheckpoint(ctx context.Context, snapshotName stri } src := filepath.Join(srcDir, snapshotName, fileName) dst := filepath.Join(dstDir, fileName) - if _, err := copyFile(src, dst); err != nil { + if _, err := sparsefile.CopyFile(src, dst); err != nil { return fmt.Errorf("failed to copy %s to %s: %w", src, dst, err) } } @@ -1331,158 +1330,6 @@ func (s *AteomHerder) copyLocalCheckpoint(ctx context.Context, snapshotName stri return nil } -var createDestFile = func(name string) (io.WriteCloser, error) { return os.Create(name) } - -// sparseDest is the part of *os.File a hole-preserving copy needs. Destinations that -// do not implement it are copied densely instead. -type sparseDest interface { - Truncate(size int64) error - WriteAt(b []byte, off int64) (int, error) -} - -// errSparseUnsupported means the source filesystem cannot report holes, so the caller -// should fall back to a dense copy. -var errSparseUnsupported = errors.New("filesystem cannot report holes") - -// errKernelCopyUnsupported means this platform, kernel or filesystem cannot copy a -// range in the kernel, so the caller should copy through userspace instead. -var errKernelCopyUnsupported = errors.New("kernel range copy unsupported") - -// copyFile copies src to dst, preserving holes where it can, and returns the number of -// logical bytes copied. -// -// Preserving holes matters because the biggest thing copied here is a guest memory -// image, which is mostly unallocated: a plain io.Copy reads holes as zeroes and writes -// them as data, inflating a snapshot to its full logical size. That costs disk on every -// local checkpoint restore, and it destroys the sparseness that later stages rely on to -// tell which parts of guest RAM actually hold anything. -func copyFile(src, dst string) (int64, error) { - sourceFileStat, err := os.Stat(src) - if err != nil { - return 0, err - } - - if !sourceFileStat.Mode().IsRegular() { - return 0, fmt.Errorf("%s is not a regular file", src) - } - - source, err := os.Open(src) - if err != nil { - return 0, err - } - defer source.Close() - - destination, err := createDestFile(dst) - if err != nil { - return 0, err - } - - if sd, ok := destination.(sparseDest); ok { - switch err := copySparse(source, sd, sourceFileStat.Size()); { - case err == nil: - return sourceFileStat.Size(), destination.Close() - case !errors.Is(err, errSparseUnsupported): - return 0, errors.Join(err, destination.Close()) - } - // Unsupported: nothing has been written yet, but probing moved the read - // offset, so rewind before the dense copy below. - if _, err := source.Seek(0, io.SeekStart); err != nil { - return 0, errors.Join(err, destination.Close()) - } - } - - nBytes, err := io.Copy(destination, source) - return nBytes, errors.Join(err, destination.Close()) -} - -// copySparse writes only src's populated extents to dst, located with SEEK_DATA and -// SEEK_HOLE, leaving the rest of dst unallocated. It reports errSparseUnsupported -// before writing anything if the filesystem cannot report holes. -// -// Extents are copied in the kernel where possible. The dense io.Copy this replaces got -// that for free (os.File's ReadFrom uses copy_file_range), so without it a fully -// populated file — a guest that really did touch all its RAM — would copy slower than -// before. -func copySparse(src *os.File, dst sparseDest, size int64) error { - fd := int(src.Fd()) - - // Probe first so an unsupported filesystem falls back with dst untouched. ENXIO - // means the seek ran but found no data at all, i.e. the file is one big hole. - if _, err := unix.Seek(fd, 0, unix.SEEK_DATA); err != nil { - if errors.Is(err, unix.ENXIO) { - return dst.Truncate(size) - } - return errSparseUnsupported - } - if err := dst.Truncate(size); err != nil { - return err - } - - // A destination that exposes its descriptor can be written by the kernel; anything - // else (the test seam substitutes plain writers) goes through userspace. - dstFd := -1 - if f, ok := dst.(interface{ Fd() uintptr }); ok { - dstFd = int(f.Fd()) - } - var buf []byte - - for off := int64(0); off < size; { - dataOff, err := unix.Seek(fd, off, unix.SEEK_DATA) - if err != nil { - if errors.Is(err, unix.ENXIO) { - break // no data past off; the tail is a hole - } - return fmt.Errorf("seeking to data at %d: %w", off, err) - } - if dataOff >= size { - break // data starts past the size we were asked to copy - } - holeOff, err := unix.Seek(fd, dataOff, unix.SEEK_HOLE) - if err != nil { - return fmt.Errorf("seeking to hole at %d: %w", dataOff, err) - } - // Refuse to spin: every iteration must move off forward, which a - // filesystem reporting a hole at or before where we started would not. - if holeOff <= off { - return fmt.Errorf("seeking to hole at %d returned non-advancing offset %d", dataOff, holeOff) - } - if holeOff > size { - holeOff = size - } - for pos := dataOff; pos < holeOff; { - if dstFd >= 0 { - copied, err := kernelCopyRange(fd, dstFd, pos, holeOff-pos) - if err == nil { - pos += copied - continue - } - if !errors.Is(err, errKernelCopyUnsupported) { - return fmt.Errorf("copying %d bytes at %d: %w", holeOff-pos, pos, err) - } - // Give up on the kernel path for the rest of this file, but redo - // this chunk below: nothing was copied. - dstFd = -1 - } - if buf == nil { - buf = make([]byte, 4<<20) - } - n := int64(len(buf)) - if rem := holeOff - pos; rem < n { - n = rem - } - if _, err := src.ReadAt(buf[:n], pos); err != nil { - return fmt.Errorf("reading %d bytes at %d: %w", n, pos, err) - } - if _, err := dst.WriteAt(buf[:n], pos); err != nil { - return fmt.Errorf("writing %d bytes at %d: %w", n, pos, err) - } - pos += n - } - off = holeOff - } - return nil -} - // goldenOnlyFiles returns the golden snapshot files not shadowed by the // actor's own snapshot: on a DATA_ON_GOLDEN restore the actor's files (the // durable-dir data) win name collisions, and the golden snapshot supplies diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 0e4ebb3cbf..5220a8bd29 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -341,63 +341,6 @@ func TestWriteFileAtomic(t *testing.T) { }) } -func TestCopyFile(t *testing.T) { - dir := t.TempDir() - src := filepath.Join(dir, "src") - want := []byte("checkpoint pages") - if err := os.WriteFile(src, want, 0o600); err != nil { - t.Fatalf("seeding src: %v", err) - } - - dst := filepath.Join(dir, "dst") - n, err := copyFile(src, dst) - if err != nil { - t.Fatalf("copyFile: %v", err) - } - if n != int64(len(want)) { - t.Errorf("copied %d bytes, want %d", n, len(want)) - } - got, err := os.ReadFile(dst) - if err != nil { - t.Fatalf("reading dst: %v", err) - } - if !bytes.Equal(got, want) { - t.Errorf("dst content = %q, want %q", got, want) - } - - if _, err := copyFile(dir, filepath.Join(dir, "dst2")); err == nil { - t.Error("copyFile(directory, ...) succeeded, want error") - } -} - -type failingCloseFile struct{ *os.File } - -func (f failingCloseFile) Close() error { - _ = f.File.Close() - return errors.New("deferred flush failed") -} - -func TestCopyFile_CloseError(t *testing.T) { - orig := createDestFile - createDestFile = func(name string) (io.WriteCloser, error) { - f, err := os.Create(name) - if err != nil { - return nil, err - } - return failingCloseFile{f}, nil - } - t.Cleanup(func() { createDestFile = orig }) - - dir := t.TempDir() - src := filepath.Join(dir, "src") - if err := os.WriteFile(src, []byte("checkpoint pages"), 0o600); err != nil { - t.Fatalf("seeding src: %v", err) - } - if _, err := copyFile(src, filepath.Join(dir, "dst")); err == nil { - t.Error("copyFile with failing destination Close = nil, want error") - } -} - // validRunRequest, validCheckpointRequest, and validRestoreRequest build // requests whose every field passes validation; the per-request tests below // break one field per case. @@ -1396,175 +1339,6 @@ func TestBuildAteomWorkloadSpec_ImageVolumeMounts(t *testing.T) { } } -// allocatedBytes reports how much disk a file actually occupies, which is less than its -// size when it has holes. -func allocatedBytes(t *testing.T, path string) int64 { - t.Helper() - var st syscall.Stat_t - if err := syscall.Stat(path, &st); err != nil { - t.Fatalf("stat %q: %v", path, err) - } - return st.Blocks * 512 -} - -// noFdFile hides the descriptor of an *os.File, forcing copySparse down its -// userspace path. -type noFdFile struct { - f *os.File -} - -func (n noFdFile) Write(b []byte) (int, error) { return n.f.Write(b) } -func (n noFdFile) WriteAt(b []byte, off int64) (int, error) { return n.f.WriteAt(b, off) } -func (n noFdFile) Truncate(size int64) error { return n.f.Truncate(size) } -func (n noFdFile) Close() error { return n.f.Close() } - -func TestCopyFilePreservesHoles(t *testing.T) { - const ( - size = 32 << 20 - markerAt = 16 << 20 - ) - dir := t.TempDir() - src := filepath.Join(dir, "memory-ranges") - - // A stand-in for a guest memory image: mostly hole, with data at both the start - // and the middle. - f, err := os.Create(src) - if err != nil { - t.Fatalf("creating src: %v", err) - } - if err := f.Truncate(size); err != nil { - t.Fatalf("sizing src: %v", err) - } - head := bytes.Repeat([]byte{0xAB}, 4<<10) - middle := bytes.Repeat([]byte{0xCD}, 4<<10) - if _, err := f.WriteAt(head, 0); err != nil { - t.Fatalf("writing head: %v", err) - } - if _, err := f.WriteAt(middle, markerAt); err != nil { - t.Fatalf("writing middle: %v", err) - } - if err := errors.Join(f.Sync(), f.Close()); err != nil { - t.Fatalf("flushing src: %v", err) - } - - dst := filepath.Join(dir, "copied") - n, err := copyFile(src, dst) - if err != nil { - t.Fatalf("copyFile: %v", err) - } - if n != size { - t.Errorf("copied %d logical bytes, want %d", n, size) - } - - // The copy must be byte-identical, holes included. - want, err := os.ReadFile(src) - if err != nil { - t.Fatalf("reading src: %v", err) - } - got, err := os.ReadFile(dst) - if err != nil { - t.Fatalf("reading dst: %v", err) - } - if !bytes.Equal(want, got) { - t.Fatal("copy differs from source") - } - - srcAlloc, dstAlloc := allocatedBytes(t, src), allocatedBytes(t, dst) - if srcAlloc >= size/2 { - t.Skipf("source did not end up sparse (%d of %d bytes allocated); "+ - "this filesystem cannot report holes", srcAlloc, int64(size)) - } - // A dense copy would allocate the full logical size; a hole-preserving one stays - // near the source's footprint. - if dstAlloc > srcAlloc*4 { - t.Errorf("copy allocated %d bytes for a %d-byte source (logical %d): holes were filled in", - dstAlloc, srcAlloc, int64(size)) - } -} - -func TestCopyFileAllHoles(t *testing.T) { - const size = 8 << 20 - dir := t.TempDir() - src := filepath.Join(dir, "empty") - f, err := os.Create(src) - if err != nil { - t.Fatalf("creating src: %v", err) - } - if err := errors.Join(f.Truncate(size), f.Close()); err != nil { - t.Fatalf("sizing src: %v", err) - } - - dst := filepath.Join(dir, "copied") - if _, err := copyFile(src, dst); err != nil { - t.Fatalf("copyFile: %v", err) - } - st, err := os.Stat(dst) - if err != nil { - t.Fatalf("stat dst: %v", err) - } - if st.Size() != size { - t.Errorf("copy is %d bytes, want %d", st.Size(), int64(size)) - } -} - -// TestCopyFilePreservesHolesUserspace covers the fallback taken when the destination -// does not expose a descriptor, so copy_file_range is unavailable. -func TestCopyFilePreservesHolesUserspace(t *testing.T) { - orig := createDestFile - createDestFile = func(name string) (io.WriteCloser, error) { - f, err := os.Create(name) - if err != nil { - return nil, err - } - return noFdFile{f: f}, nil - } - t.Cleanup(func() { createDestFile = orig }) - - const size = 32 << 20 - dir := t.TempDir() - src := filepath.Join(dir, "memory-ranges") - f, err := os.Create(src) - if err != nil { - t.Fatalf("creating src: %v", err) - } - if err := f.Truncate(size); err != nil { - t.Fatalf("sizing src: %v", err) - } - marker := bytes.Repeat([]byte{0xEF}, 4<<10) - if _, err := f.WriteAt(marker, 8<<20); err != nil { - t.Fatalf("writing marker: %v", err) - } - if err := errors.Join(f.Sync(), f.Close()); err != nil { - t.Fatalf("flushing src: %v", err) - } - - dst := filepath.Join(dir, "copied") - if _, err := copyFile(src, dst); err != nil { - t.Fatalf("copyFile: %v", err) - } - - want, err := os.ReadFile(src) - if err != nil { - t.Fatalf("reading src: %v", err) - } - got, err := os.ReadFile(dst) - if err != nil { - t.Fatalf("reading dst: %v", err) - } - if !bytes.Equal(want, got) { - t.Fatal("userspace copy differs from source") - } - - srcAlloc, dstAlloc := allocatedBytes(t, src), allocatedBytes(t, dst) - if srcAlloc >= size/2 { - t.Skipf("source did not end up sparse (%d of %d bytes allocated)", srcAlloc, int64(size)) - } - if dstAlloc > srcAlloc*4 { - t.Errorf("userspace copy allocated %d bytes for a %d-byte source: holes were filled in", - dstAlloc, srcAlloc) - } -} - // recordingObjectStorage serves gets from and records puts into one map, so // upload tests can assert exactly which objects landed. type recordingObjectStorage struct {