carlos / platform Public

host: the prepared build checks a file's size before reading it and bounds every read (Task 6 fix round 1, finding 5)

The walk stats first: a file over hostfiles.MaxFileBytes is recorded as
skipped (Prepared.StageSkipped, no object) without a read. Every read
goes through readFileBounded, an io.LimitReader at the limit plus one
byte, so a file that grows past the limit while it is read is recorded
as skipped too and is never held whole in memory.

Tests: a sparse file over the limit is skipped with no read of it; the
bounded read reports a file over a small limit with at most limit+1
bytes read and reads one at the limit whole; StageSkipped names no
object.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fc79061a88b46ec4ffbbb6eca83f7577d9eb1d1b parent 7527782
4 files changed, +136 −1
  • internal/host/movesfiles.go +35 −1
  • internal/host/movesfiles_test.go +62 −0
  • internal/hostfiles/prepared.go +15 −0
  • internal/hostfiles/prepared_test.go +24 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go
index 2e3fb96f0..58acc08f4 100644
--- a/internal/host/movesfiles.go
+++ b/internal/host/movesfiles.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"io/fs"
"os"
"path/filepath"
@@ -250,10 +251,20 @@ func buildPrepared(ctx context.Context, s release.Store, location, generation, o
if err != nil {
return err
}
- body, err := os.ReadFile(path)
+ // The size before any read, and the read bounded at the limit
+ // plus one byte (fix round 1, finding 5): a file over the limit, by
+ // its stat or by growing while it is read, is recorded as skipped
+ // and never held whole in memory.
+ if info.Size() > hostfiles.MaxFileBytes {
+ return p.StageSkipped(filepath.ToSlash(rel), movesFilesTooLarge(info.Size()))
+ }
+ body, tooLarge, err := readFileBounded(path, hostfiles.MaxFileBytes)
if err != nil {
return err
}
+ if tooLarge {
+ return p.StageSkipped(filepath.ToSlash(rel), movesFilesTooLarge(int64(len(body))))
+ }
return p.Stage(ctx, filepath.ToSlash(rel), body, uint32(info.Mode().Perm()))
})
if err != nil {
@@ -282,6 +293,29 @@ func buildPrepared(ctx context.Context, s release.Store, location, generation, o
return preparedManifest{Key: hostfiles.ManifestKey(prefix), SHA256: hex.EncodeToString(sum[:]), Version: version, Op: op, Stamp: stamp}, nil
}
+// movesFilesTooLarge is a skipped entry's reason for a file over the
+// limit; size is what the stat or the bounded read saw.
+func movesFilesTooLarge(size int64) string {
+ return fmt.Sprintf("over the %d-byte limit (%d bytes or more)", hostfiles.MaxFileBytes, size)
+}
+
+// readFileBounded reads path whole when it holds at most limit bytes. It
+// reads at most limit+1 bytes however large the file is or becomes while
+// it is read; tooLarge reports that the file exceeded the limit, and the
+// bytes returned with it are then not the whole file. A test seam.
+var readFileBounded = func(path string, limit int64) ([]byte, bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, false, err
+ }
+ defer f.Close()
+ body, err := io.ReadAll(io.LimitReader(f, limit+1))
+ if err != nil {
+ return nil, false, err
+ }
+ return body, int64(len(body)) > limit, nil
+}
+
// movesFilesChain is the admitted predecessor chain whose manifests are in
// this region's bucket, newest first: the holder's generation, then each
// cold admission's predecessor while it is a named generation in this
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go
index 9d77aa547..b7a08cc75 100644
--- a/internal/host/movesfiles_test.go
+++ b/internal/host/movesfiles_test.go
@@ -321,6 +321,68 @@ func TestPreparedManifestRefusesMissingInputs(t *testing.T) {
}
}
+// The build never reads a file past the limit (fix round 1, finding 5):
+// a file whose size is over hostfiles.MaxFileBytes is recorded as skipped
+// from its stat alone, and every read is bounded at the limit plus one
+// byte, so a file that grows while it is read is recorded as skipped
+// rather than read whole. A sparse file stands in for the oversized one.
+func TestPreparedBuildBoundsEveryRead(t *testing.T) {
+ x := newFilesFixture(t)
+ ctx := context.Background()
+ dir := filepath.Join(x.dir, filesHost)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("A"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ big := filepath.Join(dir, "big.bin")
+ if err := os.WriteFile(big, nil, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(big, hostfiles.MaxFileBytes+1); err != nil {
+ t.Fatal(err)
+ }
+ x.writeEnv("K=1\n")
+ reads := 0
+ orig := readFileBounded
+ readFileBounded = func(path string, limit int64) ([]byte, bool, error) {
+ if filepath.Base(path) == "big.bin" {
+ reads++
+ }
+ return orig(path, limit)
+ }
+ defer func() { readFileBounded = orig }()
+ if _, err := x.f.BuildPrepared(ctx, filesLoc, "g1", "g1", 7, dir, x.envPath(), true, hostfiles.WakeState{V: 1, Host: filesHost, Timers: []hostfiles.WakeTimer{}}); err != nil {
+ t.Fatal(err)
+ }
+ m, _ := x.manifest("g1")
+ if e := m.Entries["big.bin"]; e.Skipped == "" || e.Object != "" {
+ t.Fatalf("the oversized file was not recorded as skipped: %+v", e)
+ }
+ if reads != 0 {
+ t.Fatalf("the oversized file was read %d times", reads)
+ }
+ if e := m.Entries["a.txt"]; e.SHA256 != sha([]byte("A")) {
+ t.Fatalf("a.txt: %+v", e)
+ }
+ // The bounded read itself: a file larger than the limit is reported as
+ // too large with at most limit+1 bytes read; one at the limit is read
+ // whole.
+ small := filepath.Join(x.dir, "small")
+ if err := os.WriteFile(small, []byte("0123456789"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ body, tooLarge, err := orig(small, 4)
+ if err != nil || !tooLarge || len(body) > 5 {
+ t.Fatalf("over the limit: %q %v %v", body, tooLarge, err)
+ }
+ body, tooLarge, err = orig(small, 10)
+ if err != nil || tooLarge || string(body) != "0123456789" {
+ t.Fatalf("at the limit: %q %v %v", body, tooLarge, err)
+ }
+}
+
// A replay of the build (a crash after the objects, before or after the
// manifest) reuses every object it finds by digest and the manifest by
// key: the same binding, one version of each object, no new object.
diff --git a/internal/hostfiles/prepared.go b/internal/hostfiles/prepared.go
index 8a2a78163..8a2ab7723 100644
--- a/internal/hostfiles/prepared.go
+++ b/internal/hostfiles/prepared.go
@@ -146,6 +146,21 @@ func (p *Prepared) Stage(ctx context.Context, path string, body []byte, mode uin
return nil
}
+// StageSkipped records that path exists but was not published, and why:
+// a file the builder did not read because its size is over the limit (the
+// caller decides from the stat, before any read).
+func (p *Prepared) StageSkipped(path, why string) error {
+ clean, err := CleanPath(path)
+ if err != nil {
+ return err
+ }
+ if p.spent {
+ return errors.New("hostfiles: prepared manifest already committed")
+ }
+ p.man.Entries[clean] = Entry{Seq: 0, Skipped: why}
+ return nil
+}
+
// StagePlatform records a platform-managed item (the env file, the wake
// state).
func (p *Prepared) StagePlatform(ctx context.Context, name string, body []byte) error {
diff --git a/internal/hostfiles/prepared_test.go b/internal/hostfiles/prepared_test.go
index 326a7c8d5..dbcd5f833 100644
--- a/internal/hostfiles/prepared_test.go
+++ b/internal/hostfiles/prepared_test.go
@@ -173,6 +173,30 @@ func TestPreparedReplayReusesObjects(t *testing.T) {
}
}
+// StageSkipped records a path the builder did not read (a file over the
+// limit, by its size): an entry with the reason, no object, at sequence 0.
+func TestPreparedStageSkippedNamesNoObject(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ p := prepared(t, s, pfx)
+ if err := p.StageSkipped("big.bin", "too large"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.StageSkipped("../out", "x"); err == nil {
+ t.Fatal("staged a path outside the root")
+ }
+ m, _, _, err := p.Commit(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if e := m.Entries["big.bin"]; e.Skipped != "too large" || e.Object != "" || e.Seq != 0 {
+ t.Fatalf("skipped entry: %+v", e)
+ }
+ keys, err := s.List(ctx, pfx)
+ if err != nil || len(keys) != 1 {
+ t.Fatalf("objects for a skipped entry: %v %v", keys, err)
+ }
+}
+
// NewFromCopy seeds a publisher for a generation that has no manifest yet
// with a predecessor's validated manifest: every entry keeps its
// origin-qualified object, the sequence continues from the copy, and the