release, instancectl: hash a staged build once per file identity
A staged build was hashed whole on every wake, by the agent (VerifyStaged) and again by root (instance control's staged-exe check): 24 ms for a 9 MB binary on dev, about a quarter of a second for a 100 MB bundle. release.SHA256Cached keeps each digest under the opened file's identity: device, inode, size, mtime and ctime to the nanosecond. A write moves ctime and a reinstall by rename is a new inode, so any change is hashed again. As git does for its index, only a file unchanged for two seconds is cached, so a rewrite within one coarse clock tick cannot keep an identity. Linux only; elsewhere every call hashes, as before. The identity is read again after the hash, and a file that changed while it was read is not cached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+271
−6
internal/instancectl/stagedexe.go+7 −5internal/release/digestcache.go+103 −0internal/release/digestcache_linux.go+22 −0internal/release/digestcache_other.go+12 −0internal/release/digestcache_test.go+126 −0internal/release/staged.go+1 −1
diff --git a/internal/instancectl/stagedexe.go b/internal/instancectl/stagedexe.go| index f5753917f..2416e884c 100644 |
| --- a/internal/instancectl/stagedexe.go |
| +++ b/internal/instancectl/stagedexe.go |
| @@ -2,10 +2,8 @@ package instancectl |
| import ( |
| "crypto/sha256" |
| - "encoding/hex" |
| "errors" |
| "fmt" |
| - "io" |
| "net/http" |
| "os" |
| "path/filepath" |
| @@ -67,11 +65,15 @@ func (s *Server) validateStagedExe(host string, x StagedExe) error { |
| return fmt.Errorf("%w: %v", ErrStagedExe, err) |
| } |
| defer f.Close() |
| - h := sha256.New() |
| - if _, err := io.Copy(h, f); err != nil { |
| + // Hashed once per identity of the opened file (release.SHA256Cached): |
| + // root alone writes the version store, a write moves the file's ctime |
| + // and a reinstall is a new inode, so an unchanged identity is the bytes |
| + // already checked. |
| + got, err := release.SHA256Cached(f) |
| + if err != nil { |
| return fmt.Errorf("%w: %v", ErrStagedExe, err) |
| } |
| - if got := hex.EncodeToString(h.Sum(nil)); got != strings.ToLower(x.Digest) { |
| + if got != strings.ToLower(x.Digest) { |
| return fmt.Errorf("%w: %s has digest %s, the attempt verified %s", ErrStagedExe, want, got, x.Digest) |
| } |
| return nil |
diff --git a/internal/release/digestcache.go b/internal/release/digestcache.go| new file mode 100644 |
| index 000000000..327cb7b36 |
| --- /dev/null |
| +++ b/internal/release/digestcache.go |
| @@ -0,0 +1,103 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "io" |
| + "os" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// A staged build was hashed whole on every wake, by the agent |
| +// (VerifyStaged) and again by root (instance control's staged-exe check): |
| +// 24 ms for a 9 MB binary on a dev box, so a 100 MB bundle paid a quarter |
| +// of a second a wake. The digest is now kept per file identity: device, |
| +// inode, size, and the modification and change times to the nanosecond, |
| +// read from the descriptor being hashed. A write to the file moves its |
| +// ctime, which no unprivileged caller can set back, and a replacement by |
| +// rename (how the version store installs a build) is a new inode, so any |
| +// change is a new identity and is hashed again. Where the platform gives no |
| +// such identity (fileIdentity is linux-only), every call hashes, as before. |
| +// |
| +// File times are coarse (a clock tick), so a file changed moments before |
| +// it is hashed could be rewritten within the same tick and keep its |
| +// identity. As git does for its index, a digest is cached only for a file |
| +// whose last change was at least digestSettle before the hash: any later |
| +// write then lands at a visibly later time. |
| +var ( |
| + digestMu sync.Mutex |
| + digestCache = map[string]string{} // identity -> sha256 hex |
| + // digestHashHook, when set (tests only), runs each time a file is |
| + // actually hashed. |
| + digestHashHook func() |
| + // digestNow is the clock the settle rule reads (tests move it). |
| + digestNow = time.Now |
| +) |
| + |
| +// digestSettle is how long a file must have been unchanged for its digest |
| +// to be cached. |
| +const digestSettle = 2 * time.Second |
| + |
| +// digestCacheMax bounds the cache: identities of replaced builds are never |
| +// asked again, so past the bound it starts over rather than grow. |
| +const digestCacheMax = 4096 |
| + |
| +func resetDigestCache() { |
| + digestMu.Lock() |
| + defer digestMu.Unlock() |
| + digestCache = map[string]string{} |
| +} |
| + |
| +// SHA256Cached is the sha256 of the open file f, from the cache when f's |
| +// identity was hashed before. It reads from f's current offset, as |
| +// io.Copy would, so callers pass a freshly opened file. |
| +func SHA256Cached(f *os.File) (string, error) { |
| + fi, err := f.Stat() |
| + if err != nil { |
| + return "", err |
| + } |
| + id, changed, ok := fileIdentity(fi) |
| + settled := ok && digestNow().Sub(changed) >= digestSettle |
| + if ok { |
| + digestMu.Lock() |
| + sum, hit := digestCache[id] |
| + digestMu.Unlock() |
| + if hit { |
| + return sum, nil |
| + } |
| + } |
| + if digestHashHook != nil { |
| + digestHashHook() |
| + } |
| + h := sha256.New() |
| + if _, err := io.Copy(h, f); err != nil { |
| + return "", err |
| + } |
| + sum := hex.EncodeToString(h.Sum(nil)) |
| + if settled { |
| + // The identity is read again after the hash: a file that changed |
| + // while it was read is not cached under either identity. |
| + if fi2, err := f.Stat(); err == nil { |
| + if id2, _, ok2 := fileIdentity(fi2); ok2 && id2 == id { |
| + digestMu.Lock() |
| + if len(digestCache) >= digestCacheMax { |
| + digestCache = map[string]string{} |
| + } |
| + digestCache[id] = sum |
| + digestMu.Unlock() |
| + } |
| + } |
| + } |
| + return sum, nil |
| +} |
| + |
| +// FileSHA256Cached is SHA256Cached on the file at path. |
| +func FileSHA256Cached(path string) (string, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer f.Close() |
| + return SHA256Cached(f) |
| +} |
diff --git a/internal/release/digestcache_linux.go b/internal/release/digestcache_linux.go| new file mode 100644 |
| index 000000000..d3bbb8362 |
| --- /dev/null |
| +++ b/internal/release/digestcache_linux.go |
| @@ -0,0 +1,22 @@ |
| +package release |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "syscall" |
| + "time" |
| +) |
| + |
| +// fileIdentity is f's device, inode, size, mtime and ctime (nanoseconds), |
| +// and the later of its mtime and ctime: when it last changed. |
| +func fileIdentity(fi os.FileInfo) (string, time.Time, bool) { |
| + st, ok := fi.Sys().(*syscall.Stat_t) |
| + if !ok || !fi.Mode().IsRegular() { |
| + return "", time.Time{}, false |
| + } |
| + m, c := time.Unix(st.Mtim.Sec, st.Mtim.Nsec), time.Unix(st.Ctim.Sec, st.Ctim.Nsec) |
| + if m.After(c) { |
| + c = m |
| + } |
| + return fmt.Sprintf("%d:%d:%d:%d.%d:%d.%d", st.Dev, st.Ino, st.Size, st.Mtim.Sec, st.Mtim.Nsec, st.Ctim.Sec, st.Ctim.Nsec), c, true |
| +} |
diff --git a/internal/release/digestcache_other.go b/internal/release/digestcache_other.go| new file mode 100644 |
| index 000000000..4daca3830 |
| --- /dev/null |
| +++ b/internal/release/digestcache_other.go |
| @@ -0,0 +1,12 @@ |
| +//go:build !linux |
| + |
| +package release |
| + |
| +import ( |
| + "os" |
| + "time" |
| +) |
| + |
| +// fileIdentity has no portable inode and change time here: every call |
| +// hashes (digestcache.go). |
| +func fileIdentity(os.FileInfo) (string, time.Time, bool) { return "", time.Time{}, false } |
diff --git a/internal/release/digestcache_test.go b/internal/release/digestcache_test.go| new file mode 100644 |
| index 000000000..58742b448 |
| --- /dev/null |
| +++ b/internal/release/digestcache_test.go |
| @@ -0,0 +1,126 @@ |
| +//go:build linux |
| + |
| +package release |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func sumOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| + |
| +// settledClock moves the cache's clock a minute ahead, so the files a test |
| +// just wrote count as settled. |
| +func settledClock(t *testing.T) { |
| + t.Helper() |
| + digestNow = func() time.Time { return time.Now().Add(time.Minute) } |
| + t.Cleanup(func() { digestNow = time.Now }) |
| +} |
| + |
| +// A file changed moments before it is hashed is not cached: a rewrite in |
| +// the same clock tick could keep its identity. |
| +func TestFileSHA256CachedDoesNotCacheAFreshFile(t *testing.T) { |
| + resetDigestCache() |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + hashed := 0 |
| + digestHashHook = func() { hashed++ } |
| + t.Cleanup(func() { digestHashHook = nil }) |
| + for range 2 { |
| + if _, err := FileSHA256Cached(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if hashed != 2 { |
| + t.Fatalf("hashed %d times, want every time for a file not yet settled", hashed) |
| + } |
| +} |
| + |
| +// A staged build is hashed once per file identity, not once per wake: the |
| +// second check of an unchanged file reads none of it. |
| +func TestFileSHA256CachedHashesAnUnchangedFileOnce(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + hashed := 0 |
| + digestHashHook = func() { hashed++ } |
| + t.Cleanup(func() { digestHashHook = nil }) |
| + for range 3 { |
| + got, err := FileSHA256Cached(path) |
| + if err != nil || got != sumOf([]byte("build one")) { |
| + t.Fatalf("FileSHA256Cached = %q, %v", got, err) |
| + } |
| + } |
| + if hashed != 1 { |
| + t.Fatalf("hashed %d times, want once", hashed) |
| + } |
| +} |
| + |
| +// Any change to the file is a new identity: rewritten in place (its mtime |
| +// and ctime move, whatever mtime is set back to) or replaced by a rename (a |
| +// new inode). Each is hashed again, so a changed copy is always caught. |
| +func TestFileSHA256CachedSeesEveryChange(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := FileSHA256Cached(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + // In place, same size, mtime put back: ctime still moves. |
| + st, _ := os.Stat(path) |
| + time.Sleep(10 * time.Millisecond) |
| + if err := os.WriteFile(path, []byte("build two"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(path, st.ModTime(), st.ModTime()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := FileSHA256Cached(path); err != nil || got != sumOf([]byte("build two")) { |
| + t.Fatalf("after an in-place rewrite = %q, %v", got, err) |
| + } |
| + // Replaced by rename. |
| + tmp := filepath.Join(dir, "app.new") |
| + if err := os.WriteFile(tmp, []byte("build three"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := FileSHA256Cached(path); err != nil || got != sumOf([]byte("build three")) { |
| + t.Fatalf("after a rename = %q, %v", got, err) |
| + } |
| +} |
| + |
| +// VerifyStaged goes through the cache and still refuses a changed copy. |
| +func TestVerifyStagedStillRefusesAChangedCopy(t *testing.T) { |
| + resetDigestCache() |
| + settledClock(t) |
| + path := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(path, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + id := StagedIdentity{Version: "v1", StagedPath: path, Digest: sumOf([]byte("build one"))} |
| + if err := VerifyStaged(id); err != nil { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + if err := os.WriteFile(path, []byte("tampered!"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := VerifyStaged(id); err == nil { |
| + t.Fatal("a changed copy verified") |
| + } |
| +} |
diff --git a/internal/release/staged.go b/internal/release/staged.go| index 8487a0743..df1e43e28 100644 |
| --- a/internal/release/staged.go |
| +++ b/internal/release/staged.go |
| @@ -180,7 +180,7 @@ func VerifyStaged(id StagedIdentity) error { |
| if id.StagedPath == "" || id.Digest == "" { |
| return fmt.Errorf("%w: no retained path and digest for %q", ErrStagedCopyChanged, id.Version) |
| } |
| - sum, err := FileSHA256(id.StagedPath) |
| + sum, err := FileSHA256Cached(id.StagedPath) |
| if err != nil { |
| return fmt.Errorf("%w: %s: %v", ErrStagedCopyChanged, id.StagedPath, err) |
| } |