moves: bound the files preflight, cap the files tree at 4095 (#236 fix round 1)
Astra's two P2s on 55734c1e, and platform-1e's census ruling.
1. sourceFilesRefusal's reads now have their own deadline,
movesFilesPreflightTimeout (30s: a quarter of hostbox.RequestLimit, far
inside the staging lease). The instances loop passes no deadline. A
stalled listing or download now ends the round with an error and no
report, and releases the staging lease and the pass mutex. It still
reads through cfg.Versioned, as ruled.
2. A live path of exactly 4096 bytes passed the source's archive and
target readiness, then failed the landing after the cut, because the
prepared files manifest caps paths at 4095.
(a) archive.TreePathLen = hostfiles.MaxPathBytes. Build refuses a path
over it with ErrPastTreeCap, which the source reports at S1 as the
new pending row MV-N502 before the cut. Past 4096 bytes is still
MV-N81. checkName refuses it on every reader, so Verify and Extract
refuse such an archive from an older source.
(b) buildPrepared reads the extracted tree through an os.Root over the
files directory, every name relative to it. A path within the cap
no longer goes past PATH_MAX once the data directory is prefixed.
readFileBounded opens through that root.
3. Census ruling moves-overcap-cut-236 (platform-1e): sourceRefusal and
sourceRound take the ruling's lines, and the header states the binding
deny-only condition. A code comment at sourceFilesRefusal says it too.
Fix round 1 re-freezes archive.walk, buildPrepared and readFileBounded
with main's dispositions; those three await the ruling's extension. New
legacy seal 1587c95e. The internal/host and internal/moves/archive
envelopes are re-pinned. main had not moved, so no re-merge was needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+448
−43
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −0internal/host/movesfiles.go+24 −16internal/host/movesfiles_test.go+11 −5internal/host/movesfilescap_test.go+218 −0internal/host/movesphase1.go+44 −2internal/moves/archive/archive.go+14 −0internal/moves/archive/archive_test.go+90 −6internal/moves/archive/limits.go+15 −0internal/provision/plan14_root_reads_test.go+10 −6internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv+15 −7
CI cancelled — superseded by a newer push — run details
CI log
=== gate === cd launchctx && go vet ./... && go test ./... -count=1 ok amadan.net/carlos/platform.git/launchctx 0.011s ? amadan.net/carlos/platform.git/launchctx/controlname [no test files] ? amadan.net/carlos/platform.git/launchctx/wireid [no test files] go vet ./... gofmt -l .: clean go test ./... -count=1 -timeout 30m ok github.com/carlosframework/platform 280.937s ? github.com/carlosframework/platform/cmd/changelog [no test files] ok github.com/carlosframework/platform/cmd/console 2.729s ok github.com/carlosframework/platform/cmd/moveprobe 92.208s ok github.com/carlosframework/platform/cmd/zddprobe 0.709s ok github.com/carlosframework/platform/internal/activator 249.011s ok github.com/carlosframework/platform/internal/agentsock 0.016s ok github.com/carlosframework/platform/internal/alerting 0.003s ok github.com/carlosframework/platform/internal/analytics 0.021s ok github.com/carlosframework/platform/internal/appconfig 2.534s ok github.com/carlosframework/platform/internal/apperr 0.085s ok github.com/carlosframework/platform/internal/applog 0.130s ok github.com/carlosframework/platform/internal/awsinv 0.003s ok github.com/carlosframework/platform/internal/boxchoice 0.004s ok github.com/carlosframework/platform/internal/boxid 0.642s ? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files] ? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files] ok github.com/carlosframework/platform/internal/boxvolumes 0.008s ok github.com/carlosframework/platform/internal/caps 0.011s ok github.com/carlosframework/platform/internal/certwarm 0.003s ok github.com/carlosframework/platform/internal/cgroup 0.680s ok github.com/carlosframework/platform/internal/changelog 0.309s ok github.com/carlosframework/platform/internal/clamd 0.054s ? github.com/carlosframework/platform/internal/cliflag [no test files] ok github.com/carlosframework/platform/internal/console 386.872s ? github.com/carlosframework/platform/internal/controlname [no test files] ? github.com/carlosframework/platform/internal/controlsock [no test files] ok github.com/carlosframework/platform/internal/dbdoor 6.513s ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s ok github.com/carlosframework/platform/internal/dbverify 0.004s ok github.com/carlosframework/platform/internal/dnsrec 0.002s ok github.com/carlosframework/platform/internal/docsite 0.005s ok github.com/carlosframework/platform/internal/domains 2.395s ok github.com/carlosframework/platform/internal/doorkey 0.006s ok github.com/carlosframework/platform/internal/economics 0.004s ok github.com/carlosframework/platform/internal/edge 26.657s ? github.com/carlosframework/platform/internal/edgeproof [no test files] ok github.com/carlosframework/platform/internal/edgesfile 0.008s ok github.com/carlosframework/platform/internal/fleetapi 0.131s ok github.com/carlosframework/platform/internal/gate 1.654s ok github.com/carlosframework/platform/internal/genselect 0.015s ok github.com/carlosframework/platform/internal/geo 0.016s ok github.com/carlosframework/platform/internal/geoip 0.029s ok github.com/carlosframework/platform/internal/host 135.243s ok github.com/carlosframework/platform/internal/hostactors 0.414s ok github.com/carlosframework/platform/internal/hostbox 2.231s ok github.com/carlosframework/platform/internal/hostclaim 0.512s ok github.com/carlosframework/platform/internal/hostevidence 0.008s ok github.com/carlosframework/platform/internal/hostexec 4.922s ok github.com/carlosframework/platform/internal/hostfence 0.035s ok github.com/carlosframework/platform/internal/hostfiles 10.512s ok github.com/carlosframework/platform/internal/hostfloor 0.014s ok github.com/carlosframework/platform/internal/hostguard 0.962s ok github.com/carlosframework/platform/internal/hostops 1.573s ? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files] ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.007s ok github.com/carlosframework/platform/internal/hostquarantine 0.008s ok github.com/carlosframework/platform/internal/hostrelocate 0.422s ok github.com/carlosframework/platform/internal/hostresolve 0.152s ok github.com/carlosframework/platform/internal/hostwrite 0.007s ok github.com/carlosframework/platform/internal/infraview 0.007s ok github.com/carlosframework/platform/internal/instancectl 4.176s ok github.com/carlosframework/platform/internal/instanceexec 2.742s ok github.com/carlosframework/platform/internal/instancefs 0.011s ok github.com/carlosframework/platform/internal/instancekey 0.007s ok github.com/carlosframework/platform/internal/instanceupdates 0.013s ok github.com/carlosframework/platform/internal/layout 1.500s ok github.com/carlosframework/platform/internal/lease 9.790s ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s ok github.com/carlosframework/platform/internal/ledger 0.014s ok github.com/carlosframework/platform/internal/mail 0.166s ok github.com/carlosframework/platform/internal/maintenance 0.083s ok github.com/carlosframework/platform/internal/maintenanceexec 5.524s ok github.com/carlosframework/platform/internal/maintenanceguard 0.065s ok github.com/carlosframework/platform/internal/metrics 0.005s ok github.com/carlosframework/platform/internal/moves 21.065s ok github.com/carlosframework/platform/internal/moves/archive 0.795s ok github.com/carlosframework/platform/internal/objstore 0.177s ok github.com/carlosframework/platform/internal/ops 0.175s ok github.com/carlosframework/platform/internal/park 0.201s ok github.com/carlosframework/platform/internal/privatefile 0.006s ok github.com/carlosframework/platform/internal/provision 16.769s ok github.com/carlosframework/platform/internal/publicrelease 0.006s ? github.com/carlosframework/platform/internal/regionname [no test files] ok github.com/carlosframework/platform/internal/registry 4.898s ok github.com/carlosframework/platform/internal/relay 7.232s ok github.com/carlosframework/platform/internal/release 19.473s ok github.com/carlosframework/platform/internal/rollout 3.693s ok github.com/carlosframework/platform/internal/rolloutwords 0.003s ok github.com/carlosframework/platform/internal/rootaws 0.007s ok github.com/carlosframework/platform/internal/rootparticipation 0.007s ok github.com/carlosframework/platform/internal/rootstate 0.025s ok github.com/carlosframework/platform/internal/runs 0.599s ok github.com/carlosframework/platform/internal/s3compat 0.004s ok github.com/carlosframework/platform/internal/safefile 0.003s ok github.com/carlosframework/platform/internal/schedule 2.515s ok github.com/carlosframework/platform/internal/seeds 0.011s ok github.com/carlosframework/platform/internal/sim/hostlife 9.492s ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.374s ok github.com/carlosframework/platform/internal/status 0.037s ok github.com/carlosframework/platform/internal/steering 0.005s ok github.com/carlosframework/platform/internal/stopevidence 0.214s ok github.com/carlosframework/platform/internal/svcrole 0.002s ok github.com/carlosframework/platform/internal/telemetry 0.005s ok github.com/carlosframework/platform/internal/termfence 0.004s ok github.com/carlosframework/platform/internal/tunnelclient 0.004s ok github.com/carlosframework/platform/internal/unixdial 0.003s ok github.com/carlosframework/platform/internal/updatewords 0.004s ok github.com/carlosframework/platform/internal/usage 0.004s ok github.com/carlosframework/platform/internal/version 0.001s ok github.com/carlosframework/platform/internal/vet 1.565s === cross === --- shard 0 of 5 --- cross-compiling darwin/arm64 amadan: job cancelled — a newer push superseded it --- shard 1 of 5 --- cross-compiling darwin/amd64 amadan: job cancelled — a newer push superseded it --- shard 2 of 5 --- cross-compiling linux/arm64 amadan: job cancelled — a newer push superseded it --- shard 3 of 5 --- cross-compiling windows/amd64 amadan: job cancelled — a newer push superseded it --- shard 4 of 5 --- cross-compiling windows/arm64 amadan: job cancelled — a newer push superseded it
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 7a28f80e9..e8182ebe0 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -89,7 +89,7 @@ var movesCopyIDs = []string{ |
| "MV-N440", "MV-N441", "MV-N442", "MV-N443", |
| "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456", |
| "MV-N500", |
| - "MV-N501", |
| + "MV-N501", "MV-N502", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 5a15e7881..c9fa5b019 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -456,3 +456,9 @@ Paul's ruling of 2026-10-15 on amadan #236 (Astra's P2 on a8cebad). The files re |
| | id | file | text | status | |
| |---|---|---|---| |
| | MV-N501 | internal/host/movesphase1.go | <host> has a files manifest with a path longer than 4095 bytes, so it cannot move. an operator must replace the manifest | pending | |
| + |
| +Fix round 1 (Astra's second P2 on #236). The same cap applies to the files directory itself. The archive format carries a path of up to 4096 bytes (MV-N81), but the target stages every file into a prepared files manifest after the cut, and that refuses a path longer than 4095 bytes, so the landing would fail. The source's archive now refuses a path of exactly 4096 bytes at S1 with MV-N502, before the cut. A longer path still gets MV-N81. MV-N501 names a manifest, not a file, so it does not fit here. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N502 | internal/host/movesphase1.go | <host> has a file whose path is longer than 4095 bytes, so it cannot move. shorten the path or remove the file | pending | |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index fa6c2138e..4cd456dca 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -249,17 +249,24 @@ func buildPrepared(ctx context.Context, s release.Store, location, generation, o |
| if err != nil { |
| return preparedManifest{}, err |
| } |
| - err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { |
| + // The tree is read through an os.Root over dir, every name relative to |
| + // it (#236 fix round 1): a path within the files tree's cap (4095 bytes, |
| + // which the archive the install extracted holds every path to) is past |
| + // PATH_MAX once the data directory is prefixed, so an absolute walk |
| + // fails with ENAMETOOLONG after the cut. The root also keeps every read |
| + // inside dir. |
| + root, err := os.OpenRoot(dir) |
| + if err != nil { |
| + return preparedManifest{}, fmt.Errorf("%w: the files directory %s the install placed: %v", errLandingChanged, dir, err) |
| + } |
| + defer root.Close() |
| + err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error { |
| if err != nil { |
| return err |
| } |
| - if path == dir || d.IsDir() || !d.Type().IsRegular() { |
| + if rel == "." || d.IsDir() || !d.Type().IsRegular() { |
| return nil |
| } |
| - rel, err := filepath.Rel(dir, path) |
| - if err != nil { |
| - return err |
| - } |
| info, err := d.Info() |
| if err != nil { |
| return err |
| @@ -269,16 +276,16 @@ func buildPrepared(ctx context.Context, s release.Store, location, generation, o |
| // 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())) |
| + return p.StageSkipped(rel, movesFilesTooLarge(info.Size())) |
| } |
| - body, tooLarge, err := readFileBounded(path, hostfiles.MaxFileBytes) |
| + body, tooLarge, err := readFileBounded(root, rel, hostfiles.MaxFileBytes) |
| if err != nil { |
| return err |
| } |
| if tooLarge { |
| - return p.StageSkipped(filepath.ToSlash(rel), movesFilesTooLarge(int64(len(body)))) |
| + return p.StageSkipped(rel, movesFilesTooLarge(int64(len(body)))) |
| } |
| - return p.Stage(ctx, filepath.ToSlash(rel), body, uint32(info.Mode().Perm())) |
| + return p.Stage(ctx, rel, body, uint32(info.Mode().Perm())) |
| }) |
| if err != nil { |
| return preparedManifest{}, fmt.Errorf("prepared manifest of %s: %w", dir, err) |
| @@ -312,12 +319,13 @@ 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) |
| +// readFileBounded reads rel, relative to root, 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(root *os.Root, rel string, limit int64) ([]byte, bool, error) { |
| + f, err := root.Open(rel) |
| if err != nil { |
| return nil, false, err |
| } |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index 91ff1f834..b2b3ea3ba 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "path" |
| "path/filepath" |
| "reflect" |
| "sort" |
| @@ -474,11 +475,11 @@ func TestPreparedBuildBoundsEveryRead(t *testing.T) { |
| x.writeEnv("K=1\n") |
| reads := 0 |
| orig := readFileBounded |
| - readFileBounded = func(path string, limit int64) ([]byte, bool, error) { |
| - if filepath.Base(path) == "big.bin" { |
| + readFileBounded = func(root *os.Root, rel string, limit int64) ([]byte, bool, error) { |
| + if path.Base(rel) == "big.bin" { |
| reads++ |
| } |
| - return orig(path, limit) |
| + return orig(root, rel, 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 { |
| @@ -501,11 +502,16 @@ func TestPreparedBuildBoundsEveryRead(t *testing.T) { |
| if err := os.WriteFile(small, []byte("0123456789"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| - body, tooLarge, err := orig(small, 4) |
| + xroot, err := os.OpenRoot(x.dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer xroot.Close() |
| + body, tooLarge, err := orig(xroot, "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) |
| + body, tooLarge, err = orig(xroot, "small", 10) |
| if err != nil || tooLarge || string(body) != "0123456789" { |
| t.Fatalf("at the limit: %q %v %v", body, tooLarge, err) |
| } |
diff --git a/internal/host/movesfilescap_test.go b/internal/host/movesfilescap_test.go| index 61fecacbe..4380028a7 100644 |
| --- a/internal/host/movesfilescap_test.go |
| +++ b/internal/host/movesfilescap_test.go |
| @@ -1,17 +1,28 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io/fs" |
| + "os" |
| + "path" |
| + "path/filepath" |
| "reflect" |
| + "slices" |
| "strings" |
| + "sync" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -167,6 +178,213 @@ func TestPreparingEndsTheRoundWhenTheFilesManifestCannotBeRead(t *testing.T) { |
| } |
| } |
| +// stallingVersioned is the harness's versioned reader with the files |
| +// manifest's download stalled: GetVersion on a manifest key blocks until |
| +// its context ends, as an S3 body that stops arriving does, and answers |
| +// the context's error. |
| +type stallingVersioned struct { |
| + *fakeVersioned |
| + stalled chan struct{} // closed when the first stalled read starts |
| + once sync.Once |
| +} |
| + |
| +func (v *stallingVersioned) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) { |
| + if strings.Contains(key, "/files/g-") && strings.HasSuffix(key, "/manifest.json") { |
| + v.once.Do(func() { close(v.stalled) }) |
| + <-ctx.Done() |
| + return nil, ctx.Err() |
| + } |
| + return v.fakeVersioned.GetVersion(ctx, bucket, key, versionID) |
| +} |
| + |
| +// Fix round 1, P2 1: the preflight's reads are bounded |
| +// (movesFilesPreflightTimeout). A stalled manifest download is cancelled at |
| +// the bound, even under a pass with no deadline of its own; the round ends |
| +// with no report and nothing staged; the staging lease is released and the |
| +// pass mutex is free, so the next pass runs. |
| +func TestPreparingBoundsAStalledFilesManifestRead(t *testing.T) { |
| + const bound = 50 * time.Millisecond |
| + orig := movesFilesPreflightBound |
| + movesFilesPreflightBound = bound |
| + defer func() { movesFilesPreflightBound = orig }() |
| + if movesFilesPreflightTimeout <= bound || movesFilesPreflightTimeout > hostbox.RequestLimit { |
| + t.Fatalf("the production bound %v is not a short one inside the claim's request limit", movesFilesPreflightTimeout) |
| + } |
| + |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + loc := "instances/" + p1Host + "/" + h.inc |
| + putP1Manifest(t, h, loc, "cur", capCases["an ordinary manifest"].manifest(loc, "cur")) |
| + sv := &stallingVersioned{fakeVersioned: h.vs, stalled: make(chan struct{})} |
| + h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return sv, nil } |
| + logs := captureLog(t) |
| + |
| + done := make(chan error, 1) |
| + start := time.Now() |
| + go func() { done <- movesPass(context.Background(), h.s, h.w()) }() |
| + select { |
| + case <-done: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("the pass is still blocked on the stalled read") |
| + } |
| + if elapsed := time.Since(start); elapsed < bound { |
| + t.Fatalf("the pass ended after %v, before the bound %v", elapsed, bound) |
| + } |
| + select { |
| + case <-sv.stalled: |
| + default: |
| + t.Fatal("the manifest download was never asked for") |
| + } |
| + if sr := h.sourceReport(t); sr != nil { |
| + t.Fatalf("a report was written: %+v", sr) |
| + } |
| + if calls := h.f.stagingCalls(); len(calls) != 0 { |
| + t.Fatalf("staged: %+v", calls) |
| + } |
| + if !strings.Contains(logs(), context.DeadlineExceeded.Error()) { |
| + t.Fatalf("no log of the cancelled read:\n%s", logs()) |
| + } |
| + h.lease.mu.Lock() |
| + acquired, released := h.lease.acquired, h.lease.released |
| + h.lease.mu.Unlock() |
| + if acquired == 0 || acquired != released { |
| + t.Fatalf("staging lease acquired %d, released %d", acquired, released) |
| + } |
| + if !h.w().pass.TryLock() { |
| + t.Fatal("the pass mutex is still held") |
| + } |
| + h.w().pass.Unlock() |
| + |
| + // The download recovers: the next pass reports and stages. |
| + h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil } |
| + h.pass(t) |
| + if sr := h.sourceReport(t); sr == nil || !sr.S1.OK || len(h.f.stagingCalls()) == 0 { |
| + t.Fatalf("the next pass: %+v, %d staging calls", sr, len(h.f.stagingCalls())) |
| + } |
| +} |
| + |
| +// Fix round 1, P2 2: the files directory itself is held to the tree's cap. |
| +// A file whose path inside it is 4096 bytes (the archive format's own |
| +// bound, which used to pass) is refused at S1 with MV-N502 before anything |
| +// is staged; one of 4095 bytes passes and its archive is staged. The |
| +// files directory is under the data directory, so both absolute paths are |
| +// past PATH_MAX: the archive reads through an os.Root. |
| +func TestPreparingRefusesAFilePathPastTheTreeCap(t *testing.T) { |
| + for _, n := range []int{hostfiles.MaxPathBytes + 1, hostfiles.MaxPathBytes} { |
| + t.Run(fmt.Sprint(n), func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + root, err := os.OpenRoot(filepath.Join(h.cfg.DataDir, p1Host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer root.Close() |
| + leaf := capPath(n) |
| + if err := root.MkdirAll(path.Dir(leaf), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := root.WriteFile(leaf, []byte("deep"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.pass(t) |
| + sr := h.sourceReport(t) |
| + if sr == nil { |
| + t.Fatal("no source report") |
| + } |
| + if n > hostfiles.MaxPathBytes { |
| + if sr.S1.OK || sr.S1.Reason != s1FilePastCap(p1Host) { |
| + t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilePastCap(p1Host)) |
| + } |
| + if calls := h.f.stagingCalls(); slices.ContainsFunc(calls, func(c stagingCall) bool { return strings.HasPrefix(c.Key, moves.FilesPrefix(p1Host, p1ID)) }) { |
| + t.Fatalf("an archive was staged: %+v", calls) |
| + } |
| + return |
| + } |
| + if !sr.S1.OK || sr.Archive == nil { |
| + t.Fatalf("a path at the cap: S1 %+v, archive %+v", sr.S1, sr.Archive) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Fix round 1, P2 2(b): a path at the tree's cap goes end to end. The |
| +// source's archive carries it, the target's Extract places it, and the |
| +// prepared files manifest build stages it (hostfiles.Prepared.Stage), with |
| +// the extracted tree's absolute paths past PATH_MAX: a 4095-byte file, a |
| +// deep tree of files near it, and an ordinary one. The build reads through |
| +// an os.Root over the files directory, every name relative to it. |
| +func TestATreeCapPathReachesThePreparedManifest(t *testing.T) { |
| + ctx := context.Background() |
| + x := newFilesFixture(t) |
| + src := filepath.Join(t.TempDir(), "h") |
| + if err := os.Mkdir(src, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + root, err := os.OpenRoot(src) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer root.Close() |
| + files := map[string]string{"a.txt": "A"} |
| + files[capPath(hostfiles.MaxPathBytes)] = "at the cap" |
| + for _, n := range []int{hostfiles.MaxPathBytes - 1, 4000, 3900} { |
| + files[capPath(n)+"-"+fmt.Sprint(n)] = fmt.Sprint(n) // under one deep tree |
| + } |
| + for rel, body := range files { |
| + if len(rel) > hostfiles.MaxPathBytes { |
| + rel = rel[:hostfiles.MaxPathBytes] |
| + } |
| + if err := root.MkdirAll(path.Dir(rel), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := root.WriteFile(rel, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var tarz bytes.Buffer |
| + if _, _, _, err := archive.Build(src, "", &tarz); err != nil { |
| + t.Fatalf("the source's archive: %v", err) |
| + } |
| + dst := filepath.Join(x.dir, filesHost) |
| + if len(dst)+1+hostfiles.MaxPathBytes <= 4096 { |
| + t.Fatalf("%s does not put the tree past PATH_MAX", dst) |
| + } |
| + if err := archive.Extract(bytes.NewReader(tarz.Bytes()), dst, x.envPath()); err != nil { |
| + t.Fatalf("the target's extract: %v", err) |
| + } |
| + if _, err := x.f.BuildPrepared(ctx, filesLoc, "g1", "g1", 7, dst, x.envPath(), false, hostfiles.WakeState{V: 1, Host: filesHost, Timers: []hostfiles.WakeTimer{}}); err != nil { |
| + t.Fatalf("the prepared manifest: %v", err) |
| + } |
| + m, found := x.manifest("g1") |
| + if !found { |
| + t.Fatal("no prepared manifest") |
| + } |
| + staged := 0 |
| + err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return err |
| + } |
| + body, err := root.ReadFile(rel) |
| + if err != nil { |
| + return err |
| + } |
| + if e, ok := m.Entries[rel]; !ok || e.SHA256 != sha(body) || e.Skipped != "" { |
| + t.Errorf("%d-byte path: entry %+v, %v", len(rel), e, ok) |
| + } |
| + staged++ |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if staged != len(files) || len(m.Entries) != len(files) { |
| + t.Fatalf("%d files, %d walked, %d entries", len(files), staged, len(m.Entries)) |
| + } |
| + if err := movesFilesRestorable(m); err != nil { |
| + t.Fatalf("the prepared manifest is not restorable: %v", err) |
| + } |
| +} |
| + |
| // The cut's requested round runs the same source round (G13): a manifest |
| // past the cap that appeared after readiness sends the cut back to ready |
| // before anything holds, fences or stops, with MV-N501 in S1, and the move |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 5e104a11f..33f1f8c3c 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -85,8 +85,17 @@ func s1FilesPastCap(host string) string { |
| return host + " has a files manifest with a path longer than 4095 bytes, so it cannot move. an operator must replace the manifest" |
| } |
| -// The row says 4095, so the sentence does; this fails to compile if |
| -// hostfiles.MaxPathBytes is ever changed without the row. |
| +// s1FilePastCap is MV-N502 (#236 fix round 1): the files directory itself |
| +// holds a path longer than the files tree's cap (archive.ErrPastTreeCap, |
| +// one byte under the archive format's MV-N81 bound). The archive could |
| +// carry it, but the target's prepared files manifest could not, so the |
| +// landing would fail after the cut; the source refuses here instead. |
| +func s1FilePastCap(host string) string { |
| + return host + " has a file whose path is longer than 4095 bytes, so it cannot move. shorten the path or remove the file" |
| +} |
| + |
| +// The rows say 4095, so the sentences do; this fails to compile if |
| +// hostfiles.MaxPathBytes is ever changed without the rows. |
| const _ = uint(hostfiles.MaxPathBytes-4095) + uint(4095-hostfiles.MaxPathBytes) |
| // s1TooManyEntries and s1LongPath are MV-N79 and MV-N81, the archive's own |
| @@ -581,6 +590,22 @@ func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec pr |
| return w.sourceFilesRefusal(ctx, rec, holder) |
| } |
| +// movesFilesPreflightTimeout bounds sourceFilesRefusal's reads together: |
| +// creating the versioned reader, listing the manifest key's versions for |
| +// each generation of the chain, and downloading the selected manifest. A |
| +// files manifest is one JSON object, small next to what a round copies, so |
| +// a healthy read takes well under a second; 30 seconds absorbs a slow |
| +// bucket, is a quarter of the claim's own request limit |
| +// (hostbox.RequestLimit), and is far inside the 45-minute staging lease the |
| +// round holds (moves.StagingLeaseTTL). Past it the round ends with an |
| +// error and no report, and the next pass tries again. |
| +const movesFilesPreflightTimeout = 30 * time.Second |
| + |
| +// movesFilesPreflightBound is the bound in force: movesFilesPreflightTimeout, |
| +// a variable only so a test can watch a stalled read end without waiting |
| +// the full 30 seconds. |
| +var movesFilesPreflightBound = movesFilesPreflightTimeout |
| + |
| // sourceFilesRefusal is the cut's files preflight (#236; Paul's ruling of |
| // 2026-10-15): the files manifest a recovery of this host would restore |
| // must pass the readers' path cap, or the move never reaches its cut. A |
| @@ -600,6 +625,16 @@ func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec pr |
| // nothing published before the cap reaches it. The rule is the |
| // establishment's own (movesFilesRestorable). A host with no files |
| // manifest has nothing to check here. A read that fails ends the round. |
| +// |
| +// It is deny-only (census ruling moves-overcap-cut-236, platform-1e, |
| +// 2026-10-15; binding): it can only refuse a move, never allow anything. |
| +// Passing it grants nothing, and it never skips or short-circuits the |
| +// establishment's own check of the same manifest, which reads through the |
| +// claim's identity-bound store (hostbox.RequestStore) and applies |
| +// movesFilesRestorable and ValidateManifest again. Its reads go through |
| +// cfg.Versioned, the moves worker's own reader, and are bounded by |
| +// movesFilesPreflightTimeout. A change that lets it authorize anything |
| +// needs a new census ruling. |
| func (w *movesWorker) sourceFilesRefusal(ctx context.Context, rec provision.Record, holder *hostclaim.Holder) (string, error) { |
| cfg := w.cfg |
| if holder == nil || holder.Location == "" { |
| @@ -608,6 +643,11 @@ func (w *movesWorker) sourceFilesRefusal(ctx context.Context, rec provision.Reco |
| if cfg.Versioned == nil { |
| return "", errors.New("no versioned reader to check the files manifest with") |
| } |
| + // The round runs under the staging lease and the pass mutex every moves |
| + // pass shares, and the instances loop calls it with no deadline: a |
| + // stalled listing or download must end the round, not hold both. |
| + ctx, cancel := context.WithTimeout(ctx, movesFilesPreflightBound) |
| + defer cancel() |
| vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion) |
| if err != nil { |
| return "", fmt.Errorf("the data bucket, to check the files manifest: %w", err) |
| @@ -871,6 +911,8 @@ func archiveFailure(host string, err error) (why string, retry bool, ferr error) |
| return s1SpecialFile(host, pe.Path), false, nil // M-C14, hard links too |
| case errors.Is(err, archive.ErrOverCap): |
| return s1OverCap(host), false, nil // M-C15 |
| + case errors.Is(err, archive.ErrPastTreeCap): |
| + return s1FilePastCap(host), false, nil // MV-N502 |
| case errors.Is(err, archive.ErrTooManyEntries): |
| return s1TooManyEntries, false, nil // MV-N79 |
| case errors.Is(err, archive.ErrBadPath) && hasPath: |
diff --git a/internal/moves/archive/archive.go b/internal/moves/archive/archive.go| index 648743084..c0ce62a72 100644 |
| --- a/internal/moves/archive/archive.go |
| +++ b/internal/moves/archive/archive.go |
| @@ -42,6 +42,9 @@ var ( |
| ErrBadPath = errors.New("archive: refused path") |
| ErrTooLarge = errors.New("archive: the decoded archive is over its size limit") |
| ErrNotEmpty = errors.New("archive: the destination files directory is not empty") |
| + // ErrPastTreeCap is Build's refusal of a path longer than TreePathLen |
| + // (hostfiles' cap) but within MaxPathLen; the source says MV-N502. |
| + ErrPastTreeCap = errors.New("archive: a path is past the files tree's length cap") |
| ) |
| // PathError is a Build refusal that names a file: Err is the sentinel |
| @@ -83,6 +86,14 @@ func changedError(path string) error { |
| // MaxPathLen is ever changed without the row. |
| const _ = uint(MaxPathLen-4096) + uint(4096-MaxPathLen) |
| +// treePathError is a path past TreePathLen but within MaxPathLen: the |
| +// archive format could carry it, the target's files manifest could not. |
| +// The operator's sentence is the caller's (MV-N502, from the sentinel); |
| +// this text reaches the log only. |
| +func treePathError(path string) error { |
| + return &PathError{Err: ErrPastTreeCap, Path: path, msg: fmt.Sprintf("archive: refused path: %s is longer than the files tree's %d-byte cap", path, TreePathLen)} |
| +} |
| + |
| func longPathError(path string) error { |
| return &PathError{Err: ErrBadPath, Path: path, msg: fmt.Sprintf("archive: refused path: %s is longer than 4096 bytes", path)} |
| } |
| @@ -167,6 +178,9 @@ func walk(root *os.Root, envPath string) ([]item, error) { |
| if len(p) > MaxPathLen { |
| return longPathError(p) |
| } |
| + if len(p) > TreePathLen { |
| + return treePathError(p) |
| + } |
| return add(item{name: FilesPrefix + p, rel: p, root: root, info: info}) |
| }) |
| if err != nil { |
diff --git a/internal/moves/archive/archive_test.go b/internal/moves/archive/archive_test.go| index 4cf281c17..9cf54e7d7 100644 |
| --- a/internal/moves/archive/archive_test.go |
| +++ b/internal/moves/archive/archive_test.go |
| @@ -267,6 +267,7 @@ func refusedStreams() map[string]refusedStreamCase { |
| "nested late dir": {[]rawEntry{dirEntry("files/a/"), reg("files/a/b/c", "x"), dirEntry("files/a/b/")}, ErrBadPath}, |
| "env as dir": {[]rawEntry{dirEntry("env/")}, ErrBadPath}, |
| "too long": {[]rawEntry{reg("files/"+strings.Repeat("a", MaxPathLen+1), "x")}, ErrBadPath}, |
| + "past tree cap": {[]rawEntry{reg("files/"+strings.Repeat("a", TreePathLen+1), "x")}, ErrBadPath}, |
| "symlink": {[]rawEntry{{hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}}, ErrSpecialFile}, |
| "hard link": {[]rawEntry{reg("files/a", "x"), {hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeLink, Linkname: "files/a"}}}, ErrSpecialFile}, |
| "char device": {[]rawEntry{{hdr: tar.Header{Name: "files/d", Typeflag: tar.TypeChar, Devmajor: 1, Devminor: 3}}}, ErrSpecialFile}, |
| @@ -468,9 +469,10 @@ func TestThousandsOfSmallFilesArchive(t *testing.T) { |
| // path Build admits, so every archive Build writes is one Extract accepts. |
| func TestTheLongestPathFitsTheEntryOverhead(t *testing.T) { |
| dir := filepath.Join(t.TempDir(), "h") |
| - // Components of 200 bytes nested to a relative path of MaxPathLen-ish. |
| + // Components of 200 bytes nested to a relative path of TreePathLen-ish: |
| + // the longest Build admits is the files tree's cap (#236 fix round 1). |
| rel := "" |
| - for len(rel)+201 <= MaxPathLen-10 { |
| + for len(rel)+201 <= TreePathLen-10 { |
| if rel != "" { |
| rel += "/" |
| } |
| @@ -479,8 +481,8 @@ func TestTheLongestPathFitsTheEntryOverhead(t *testing.T) { |
| if err := os.Mkdir(dir, 0o755); err != nil { |
| t.Fatal(err) |
| } |
| - leaf := rel + "/" + strings.Repeat("f", MaxPathLen-len(rel)-1) |
| - if len(leaf) != MaxPathLen { |
| + leaf := rel + "/" + strings.Repeat("f", TreePathLen-len(rel)-1) |
| + if len(leaf) != TreePathLen { |
| t.Fatalf("leaf %d", len(leaf)) |
| } |
| // Created through a Root (openat), as Build reads it: the absolute |
| @@ -503,15 +505,97 @@ func TestTheLongestPathFitsTheEntryOverhead(t *testing.T) { |
| if per := (b.tarBytes - 2*512 - b.content) / int64(b.count); per > entryOverhead { |
| t.Fatalf("per-entry overhead %d over the bound %d", per, entryOverhead) |
| } |
| - // One byte longer is refused by Build, naming the path. |
| + // One byte longer (MaxPathLen) is past the tree's cap: refused by Build |
| + // with ErrPastTreeCap, naming the path. |
| if err := root.Rename(leaf, leaf+"x"); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := build(dir, "", io.Discard); !errors.Is(err, ErrBadPath) { |
| + var pe *PathError |
| + if _, err := build(dir, "", io.Discard); !errors.Is(err, ErrPastTreeCap) || errors.Is(err, ErrBadPath) || !errors.As(err, &pe) || pe.Path != leaf+"x" { |
| + t.Fatalf("a path of MaxPathLen: %v", err) |
| + } |
| + // Past MaxPathLen it is the format's own refusal (MV-N81), as before. |
| + if err := root.Rename(leaf+"x", leaf+"xy"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := build(dir, "", io.Discard); !errors.Is(err, ErrBadPath) || !errors.As(err, &pe) || pe.Path != leaf+"xy" { |
| t.Fatalf("over-long path: %v", err) |
| } |
| } |
| +// #236 fix round 1: a path of exactly the files tree's cap (TreePathLen, |
| +// hostfiles.MaxPathBytes) goes through Build, Seal, Verify and Extract, into |
| +// a destination whose absolute path is past PATH_MAX once the path is |
| +// added; one byte longer never leaves the source, and a stream that carries |
| +// it (an older source's) is refused by Verify and Extract (the "past tree |
| +// cap" hostile stream). |
| +func TestATreeCapPathRoundTrips(t *testing.T) { |
| + // TreePathLen is hostfiles.MaxPathBytes by definition; MaxPathLen is |
| + // one byte more, which the next line fails to compile without. |
| + const _ = uint(MaxPathLen-1-TreePathLen) + uint(TreePathLen-(MaxPathLen-1)) |
| + dir := filepath.Join(t.TempDir(), "h") |
| + if err := os.Mkdir(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + leaf := treeCapPath(TreePathLen) |
| + root, err := os.OpenRoot(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer root.Close() |
| + if err := root.MkdirAll(filepath.Dir(leaf), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := root.WriteFile(leaf, []byte("deep"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + var tarz bytes.Buffer |
| + plain, entries, n, err := Build(dir, "", &tarz) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, priv, b := keys(t) |
| + var sealed bytes.Buffer |
| + key, sealedSHA, err := Seal(bytes.NewReader(tarz.Bytes()), pub, b, &sealed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m := Manifest{V: ManifestVersion, Op: "mv_x", Incarnation: "i", Plain: plain, Sealed: sealedSHA, Bytes: n, Entries: entries, SealedDataKey: key, Chunk: ChunkSize} |
| + if err := Verify(bytes.NewReader(sealed.Bytes()), m, priv, b); err != nil { |
| + t.Fatalf("Verify of a tree-cap path: %v", err) |
| + } |
| + _, files, env := extractTarget(t) |
| + if len(files)+1+len(leaf) <= 4096 { |
| + t.Fatalf("the destination %s does not put the path past PATH_MAX", files) |
| + } |
| + if err := Extract(bytes.NewReader(tarz.Bytes()), files, env); err != nil { |
| + t.Fatalf("Extract of a tree-cap path: %v", err) |
| + } |
| + out, err := os.OpenRoot(files) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer out.Close() |
| + if got, err := out.ReadFile(leaf); err != nil || string(got) != "deep" { |
| + t.Fatalf("extracted %q, %v", got, err) |
| + } |
| +} |
| + |
| +// treeCapPath is a relative path of exactly n bytes, 200-byte directory |
| +// components and a leaf. |
| +func treeCapPath(n int) string { |
| + var b strings.Builder |
| + for b.Len()+201 <= n-10 { |
| + if b.Len() > 0 { |
| + b.WriteByte('/') |
| + } |
| + b.WriteString(strings.Repeat("d", 200)) |
| + } |
| + b.WriteByte('/') |
| + b.WriteString(strings.Repeat("f", n-b.Len())) |
| + return b.String() |
| +} |
| + |
| // Build, Verify and Extract share one entry-count limit, and Extract and |
| // Verify bound the decoded stream, so a zstd bomb that passed the sealing |
| // is still bounded on the target. |
diff --git a/internal/moves/archive/limits.go b/internal/moves/archive/limits.go| index d61e0b29a..44f6bf90c 100644 |
| --- a/internal/moves/archive/limits.go |
| +++ b/internal/moves/archive/limits.go |
| @@ -8,6 +8,7 @@ import ( |
| "path/filepath" |
| "strings" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/klauspost/compress/zstd" |
| ) |
| @@ -26,6 +27,17 @@ const ( |
| MaxPathLen = 4096 |
| ) |
| +// TreePathLen is the files tree's own cap, hostfiles' (MaxPathBytes, 4095 |
| +// bytes): the most a path inside the files directory may hold and still be |
| +// staged into the prepared files manifest the target builds after the cut |
| +// (hostfiles.Prepared.Stage refuses anything longer). It is one byte under |
| +// MaxPathLen, the archive format's bound that MV-N81 states, so Build |
| +// refuses a path of exactly MaxPathLen with ErrPastTreeCap (a refusal at |
| +// S1, before the cut), and every reader refuses one past TreePathLen |
| +// (checkName), so an archive an older source built with such a path never |
| +// passes the target's verification (#236 fix round 1). |
| +const TreePathLen = hostfiles.MaxPathBytes |
| + |
| // entryOverhead is the most tar bytes one entry costs beyond its content: |
| // a 512-byte header, a PAX header (512 bytes plus the path record, at most |
| // len("files/")+MaxPathLen+1 bytes and a length prefix, rounded up to 4608) |
| @@ -89,6 +101,9 @@ func checkName(name string, dir bool) (rel string, isEnv bool, err error) { |
| if !ok || rel == "" { |
| return "", false, bad("is outside files/ and env") |
| } |
| + if len(rel) > TreePathLen { |
| + return "", false, bad("is past the files tree's length cap") |
| + } |
| if rel == MarkerName || strings.HasPrefix(rel, MarkerName+"/") { |
| return "", false, bad("is the protocol marker or inside it") |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 5f85dcb94..2a2c9b982 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -744,15 +744,19 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| t.Fatal(errs) |
| } |
| - // Census overlay moves-overcap-cut-236 (amadan #236, moves' part of |
| - // Paul's ruling of 2026-10-15), loaded last: the moves source round's |
| - // files manifest preflight. sourceRefusal and sourceRound are re-frozen |
| - // with main's class, task and basis and new digests, so the seal moves; |
| - // the internal/host envelope is re-pinned. Owed a census ruling. |
| + // Census overlay moves-overcap-cut-236 (#236, moves' part of Paul's |
| + // ruling of 2026-10-15), loaded last: the moves source round's files |
| + // manifest preflight, under census ruling moves-overcap-cut-236 |
| + // (platform-1e, 2026-10-15). sourceRefusal and sourceRound stay |
| + // accepted-legacy with the ruling's lines, on the binding condition that |
| + // the preflight is deny-only (the overlay's header). Fix round 1 re-freezes |
| + // archive.walk, buildPrepared and readFileBounded for the files tree's |
| + // cap, with main's dispositions. The seal moves; the internal/host and |
| + // internal/moves/archive envelopes are re-pinned. |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - seal = "c169ea03f9a41ef3b761619fd61ce78eff59b40eaf5f71ad1765bcc13c2662e4" |
| + seal = "1587c95efa94ab9de43b0635d91f1949e2d3b92aa09d90eaf2ed98daaeaff443" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv b/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv| index 5dd88dda6..a9a92f3c1 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv |
| @@ -1,7 +1,15 @@ |
| -# Plan 14 census overlay moves-overcap-cut-236 (amadan #236, moves' part of Paul's ruling of 2026-10-15; loaded after hostfiles-cap-236): the moves source round refuses, at S1, a source whose files manifest a recovery could not restore because an entry path is past the readers' cap (hostfiles.RestorableWithinCap). sourceRound passes the claim holder it already read under the staging lease to sourceRefusal, and sourceRefusal ends in a call to the new sourceFilesRefusal, which reads the holder's files manifest from this box's own data bucket by exact version through cfg.Versioned (the agent's moves worker, as carlos; no root process). |
| -# Both units keep main's class, task and basis unchanged; only their digests (and sourceRefusal's site offsets, moved by its new parameter and comment) change, so each is re-frozen here and the legacy seal moves. The new units (sourceFilesRefusal, movesFilesSelect, movesManifestVersion, movesFilesRestorable) carry no recognized root read. No registry, /data, release or claim read was added. This re-freeze is owed a census ruling. |
| -@legacy da46b0739f2d4ffa154ae94449a2345a6a90e122c5872f42a19d46f4446c0bfd internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| -internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| -@legacy 88e76ac8c57eab258dc8885283a919c0e90ee7e44f280f6be9828e34aafb6339 internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| -internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| -@coverage internal/host 3ba30e265e90918651dec3bee22803cbc618afb5d5184ca9e220d4482474e320 |
| +# Plan 14 census overlay moves-overcap-cut-236 (#236, moves' part of Paul's ruling of 2026-10-15; loaded after hostfiles-cap-236): the moves source round refuses, at S1, a source whose files manifest a recovery could not restore because an entry path is past the readers' cap (hostfiles.RestorableWithinCap), and (fix round 1) a source whose files directory holds a path past that cap. |
| +# Census ruling moves-overcap-cut-236 (platform-1e, 2026-10-15), #236: movesWorker.sourceRefusal and movesWorker.sourceRound stay accepted-legacy with main's dispositions. sourceRound passes the claim holder it read under the staging lease to sourceRefusal, which ends in sourceFilesRefusal: a read of the holder's files manifest from this box's own data bucket by exact version through cfg.Versioned, bounded by movesFilesPreflightTimeout. CONDITION (binding): that check is deny-only. It never allows anything, and never skips or short-circuits the establishment's own check on the claim's identity-bound store. If it ever authorizes something, it needs a new ruling. |
| +# Fix round 1, #236: archive.walk (Build refuses a path past the files tree's cap, archive.TreePathLen = hostfiles.MaxPathBytes), buildPrepared (the prepared files manifest build reads the extracted tree through an os.Root over the files directory, names relative to it, so a path within the cap is not past PATH_MAX; the new site is that OpenRoot) and var readFileBounded (it opens through that root) are re-frozen with main's dispositions. They are outside the ruling above, which was given for 55734c1e; they await platform-1e's extension of it. |
| +@legacy 98a854a936ae2bf5be2f8c07f401caadb37731407d67b73fabf2015826c8bfc1 internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route;census-ruling-moves-overcap-cut-236 whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| +internal/host/movesphase1.go movesWorker.sourceRefusal legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route;census-ruling-moves-overcap-cut-236 whole:Route@364,whole:Route@476 690fda4d0209035cffc5b2d066a93e04e7a9a584f55e1fd629395187f1c56484 |
| +@legacy 973f36144cc7c6bcda570a12c6d8bb2b35a69392db98413a93c0633fe15e4ace internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| +internal/host/movesphase1.go movesWorker.sourceRound legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Exec@1981,file:Exec@2099,file:Read@1427 aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7 |
| +@legacy 355287791db74820a3f396ae8a69167eac4607d9f0c6e6c38ad5ff1542d3d4e4 internal/moves/archive/archive.go walk legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Lstat@1021,file:Lstat@600,file:WalkDir@242 f5c5a82f6a728e88fffe234242826b31957cd2df947d2cb209f55bb0fcc89f6d |
| +internal/moves/archive/archive.go walk legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Lstat@1021,file:Lstat@600,file:WalkDir@242 f5c5a82f6a728e88fffe234242826b31957cd2df947d2cb209f55bb0fcc89f6d |
| +@legacy ed83d4011f9dfde45aef1266f1e133bd62ee4f873a592f6e5bdfd962e0a7c9d8 internal/host/movesfiles.go buildPrepared legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Lstat@613,file:OpenRoot@1801,file:ReadFile@808,file:WalkDir@1987 a9f0a09605b993c7fda63b20ce5e60d20cf739873f0f4fbccc50715f58811d9a |
| +internal/host/movesfiles.go buildPrepared legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Lstat@613,file:OpenRoot@1801,file:ReadFile@808,file:WalkDir@1987 a9f0a09605b993c7fda63b20ce5e60d20cf739873f0f4fbccc50715f58811d9a |
| +@legacy 26ae0e0538ecc6e4d98a3748f4de411cd4dc5a648e546371590c5c14d29812a3 internal/host/movesfiles.go var readFileBounded legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Open@102,file:ReadAll@193 8b17a7a48cda6274a0b3cb7cede31bd2531659738549933e256fd745c4282c74 |
| +internal/host/movesfiles.go var readFileBounded legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Open@102,file:ReadAll@193 8b17a7a48cda6274a0b3cb7cede31bd2531659738549933e256fd745c4282c74 |
| +@coverage internal/host f2909ce821abb2ef291e0bc0e572046886c051fbf251acc17f947602802e51e1 |
| +@coverage internal/moves/archive f6da67e45388b94d16330b350778fe896411c6fae910aca035718b179d68152d |