moves-overcap-cut
Squashed from moves-overcap-cut into main.
12 files changed,
+914
−43
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+14 −0internal/host/movesfiles.go+85 −28internal/host/movesfiles_test.go+11 −5internal/host/movesfilescap_test.go+539 −0internal/host/movesphase1.go+106 −3internal/moves/archive/archive.go+17 −0internal/moves/archive/archive_test.go+90 −6internal/moves/archive/limits.go+15 −0internal/provision/plan14_root_reads_test.go+18 −0internal/provision/plan9_b8_census_test.go+3 −1internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv+15 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 301.872s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.775s
ok github.com/carlosframework/platform/cmd/moveprobe 93.347s
ok github.com/carlosframework/platform/cmd/zddprobe 0.704s
ok github.com/carlosframework/platform/internal/activator 249.399s
ok github.com/carlosframework/platform/internal/agentsock 0.021s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.037s
ok github.com/carlosframework/platform/internal/appconfig 2.479s
ok github.com/carlosframework/platform/internal/apperr 0.086s
ok github.com/carlosframework/platform/internal/applog 0.158s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.640s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.015s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.701s
ok github.com/carlosframework/platform/internal/changelog 0.323s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 392.186s
? 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.159s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.008s
ok github.com/carlosframework/platform/internal/domains 2.397s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.879s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.011s
ok github.com/carlosframework/platform/internal/fleetapi 0.147s
ok github.com/carlosframework/platform/internal/gate 1.663s
ok github.com/carlosframework/platform/internal/genselect 0.036s
ok github.com/carlosframework/platform/internal/geo 0.011s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/host 136.817s
ok github.com/carlosframework/platform/internal/hostactors 0.387s
ok github.com/carlosframework/platform/internal/hostbox 2.281s
ok github.com/carlosframework/platform/internal/hostclaim 0.526s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 4.959s
ok github.com/carlosframework/platform/internal/hostfence 0.036s
ok github.com/carlosframework/platform/internal/hostfiles 10.516s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 1.011s
ok github.com/carlosframework/platform/internal/hostops 1.603s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.479s
ok github.com/carlosframework/platform/internal/hostresolve 0.144s
ok github.com/carlosframework/platform/internal/hostwrite 0.013s
ok github.com/carlosframework/platform/internal/infraview 0.013s
ok github.com/carlosframework/platform/internal/instancectl 4.182s
ok github.com/carlosframework/platform/internal/instanceexec 2.710s
ok github.com/carlosframework/platform/internal/instancefs 0.013s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.023s
ok github.com/carlosframework/platform/internal/layout 1.549s
ok github.com/carlosframework/platform/internal/lease 9.816s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.021s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.178s
ok github.com/carlosframework/platform/internal/maintenance 0.097s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.549s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.067s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.259s
ok github.com/carlosframework/platform/internal/moves/archive 0.762s
ok github.com/carlosframework/platform/internal/objstore 0.193s
ok github.com/carlosframework/platform/internal/ops 0.175s
ok github.com/carlosframework/platform/internal/park 0.219s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 14.937s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.904s
ok github.com/carlosframework/platform/internal/relay 7.221s
ok github.com/carlosframework/platform/internal/release 19.409s
ok github.com/carlosframework/platform/internal/rollout 3.682s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.009s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
ok github.com/carlosframework/platform/internal/runs 0.562s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.499s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.299s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.799s
ok github.com/carlosframework/platform/internal/status 0.043s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.310s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.549s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46334114 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/15 18:35:21 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC7BE1AECC9B2, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index c4d349fce..e8182ebe0 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -89,6 +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-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 e44720f29..f01e150c8 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -448,3 +448,17 @@ Paul's copy review of 2026-10-12 (region HA stage B, final review minor 9). The |
| | MV-N500 | internal/console/apiinstancemove.go | the instance's last relocation is still finishing. try again in a few minutes | approved | |
| The five rows added after Plan 12 (MV-N450, MV-N451, MV-N454, MV-N455 and MV-N456) were approved by Paul on 2026-10-13 with no edits. |
| + |
| +## Files manifest past the path cap |
| + |
| +Paul's ruling of 2026-10-15 on amadan #236 (Astra's P2 on a8cebad). The files readers refuse, terminally, a manifest with an entry path longer than 4095 bytes (`hostfiles.ErrTooDeep`), and a manifest written before that cap can still hold one. A recovery after the cut that selected it could not restore the source, so the source's round refuses the move before any staging write, stop or cut, and writes MV-N501 into S1, where `carlos move status` shows it. MV-N501 is the first number past MV-N500. |
| + |
| +| 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 | approved | |
| + |
| +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 | approved | |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index 683d50f21..da9b68cd0 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -249,17 +249,29 @@ 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. |
| + // |
| + // Census ruling moves-overcap-cut-236 (platform-1e, 2026-10-15; |
| + // binding): no read here falls back to a path-based open. A root that |
| + // cannot be opened, or a name it cannot open (readFileBounded), refuses |
| + // the build; nothing retries by absolute path. |
| + 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 +281,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 +324,15 @@ 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) { |
| + // Through the root only (census ruling moves-overcap-cut-236, binding): |
| + // an open that fails is the error, never a retry by path. |
| + f, err := root.Open(rel) |
| if err != nil { |
| return nil, false, err |
| } |
| @@ -405,7 +420,14 @@ func movesFilesChainFrom(h *hostclaim.Holder, start, home, region string) []stri |
| // names it: "" when the key has no current object. A delete marker or an |
| // unversioned current object is an error. |
| func (f *movesFiles) manifestVersion(ctx context.Context, key string) (string, error) { |
| - vs, err := f.Data.ListVersions(ctx, f.Bucket, key) |
| + return movesManifestVersion(ctx, f.Data, f.Bucket, key) |
| +} |
| + |
| +// movesManifestVersion is manifestVersion over any versioned reader of the |
| +// data bucket: the cut's preflight reads with the moves worker's own |
| +// (sourceFilesRefusal), the establishment with the claim's. |
| +func movesManifestVersion(ctx context.Context, data lease.VersionedStore, bucket, key string) (string, error) { |
| + vs, err := data.ListVersions(ctx, bucket, key) |
| if err != nil { |
| return "", err |
| } |
| @@ -429,30 +451,58 @@ func (f *movesFiles) manifestVersion(ctx context.Context, key string) (string, e |
| // one in its place). found is false when no generation of the chain has a |
| // manifest. |
| func (f *movesFiles) newestManifest(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location string, chain []string) (hostfiles.Manifest, []string, bool, error) { |
| + m, key, version, found, err := movesFilesSelect(ctx, f.Data, f.Bucket, location, chain) |
| + if err != nil || !found { |
| + return hostfiles.Manifest{}, nil, false, err |
| + } |
| + allowed, err := f.movesFilesAllowed(ctx, o, in, location, in.NewGeneration, m) |
| + if err != nil { |
| + return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| + } |
| + return m, allowed, true, nil |
| +} |
| + |
| +// movesFilesSelect is newestManifest's selection, apart from its |
| +// validation: the first generation of chain whose manifest key has a |
| +// current version, read at exactly that version, with the key and the |
| +// version. found is false when no generation of chain has a manifest. The |
| +// establishment (newestManifest, the src_gen bridge included) and the |
| +// cut's preflight (sourceFilesRefusal) both select through it, so the cut |
| +// checks the manifest a recovery would restore, not a guess at it (#236). |
| +func movesFilesSelect(ctx context.Context, data lease.VersionedStore, bucket, location string, chain []string) (m hostfiles.Manifest, key, version string, found bool, err error) { |
| for _, g := range chain { |
| prefix := movesFilesPrefix(location, g) |
| key := hostfiles.ManifestKey(prefix) |
| - version, err := f.manifestVersion(ctx, key) |
| + version, err := movesManifestVersion(ctx, data, bucket, key) |
| if err != nil { |
| - return hostfiles.Manifest{}, nil, false, err |
| + return hostfiles.Manifest{}, "", "", false, err |
| } |
| if version == "" { |
| continue |
| } |
| - m, found, err := hostfiles.Load(ctx, versionStore{data: f.Data, bucket: f.Bucket, key: key, version: version}, prefix) |
| + m, found, err := hostfiles.Load(ctx, versionStore{data: data, bucket: bucket, key: key, version: version}, prefix) |
| if err != nil { |
| - return hostfiles.Manifest{}, nil, false, err |
| + return hostfiles.Manifest{}, "", "", false, err |
| } |
| if !found { |
| - return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s is not readable", key, version) |
| - } |
| - allowed, err := f.movesFilesAllowed(ctx, o, in, location, in.NewGeneration, m) |
| - if err != nil { |
| - return hostfiles.Manifest{}, nil, false, fmt.Errorf("host: the files manifest %s@%s: %w", key, version, err) |
| + return hostfiles.Manifest{}, "", "", false, fmt.Errorf("host: the files manifest %s@%s is not readable", key, version) |
| } |
| - return m, allowed, true, nil |
| + return m, key, version, true, nil |
| } |
| - return hostfiles.Manifest{}, nil, false, nil |
| + return hostfiles.Manifest{}, "", "", false, nil |
| +} |
| + |
| +// movesFilesRestorable is the reader's path cap as moves applies it |
| +// (hostfiles.RestorableWithinCap, the rule checkManifest applies to every |
| +// entry, live, tombstoned or skipped): a manifest that fails it is |
| +// hostfiles.ErrTooDeep, and no establishment restores it. The recovery's |
| +// establishment (movesFilesAllowed) and the cut's preflight |
| +// (sourceFilesRefusal) both call it, so a move whose recovery could not |
| +// restore the source's files never starts its cut (#236; Paul's ruling of |
| +// 2026-10-15). If region HA relaxes the readers' rule, RestorableWithinCap |
| +// follows it, and both sides here with it. |
| +func movesFilesRestorable(m hostfiles.Manifest) error { |
| + return hostfiles.RestorableWithinCap(m) |
| } |
| // movesOriginProof proves that generations of the holder's incarnation |
| @@ -532,6 +582,13 @@ func (f *movesFiles) proveOrigins(ctx context.Context, o hostclaim.Observed, in |
| // before; only unreferenced hints and unreferenced chain generations, which |
| // no entry depends on, are no longer asked about. |
| func (f *movesFiles) movesFilesAllowed(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, location, admitted string, m hostfiles.Manifest) ([]string, error) { |
| + // The path cap first, by the rule the cut's preflight applies |
| + // (movesFilesRestorable): ValidateManifest below refuses the same |
| + // manifest with the same error, so this only refuses it before any |
| + // origin proof, and keeps the two sides on one function. |
| + if err := movesFilesRestorable(m); err != nil { |
| + return nil, err |
| + } |
| named, err := movesFilesOrigins(location, "", m) |
| if err != nil { |
| return nil, 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| new file mode 100644 |
| index 000000000..4380028a7 |
| --- /dev/null |
| +++ b/internal/host/movesfilescap_test.go |
| @@ -0,0 +1,539 @@ |
| +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" |
| +) |
| + |
| +// The cut's files preflight (#236; Paul's ruling of 2026-10-15): a source |
| +// whose files manifest a recovery could not restore, because an entry path |
| +// is past the readers' cap, is refused at S1 before anything is staged, |
| +// stopped or cut. |
| + |
| +// capPath is a clean relative path of exactly n bytes: 200-byte segments |
| +// joined by "/", as hostfiles' own tests build them. |
| +func capPath(n int) string { |
| + var b strings.Builder |
| + for b.Len() < n { |
| + if b.Len() > 0 { |
| + b.WriteByte('/') |
| + } |
| + b.WriteString(strings.Repeat("x", min(200, n-b.Len()))) |
| + } |
| + return b.String() |
| +} |
| + |
| +// capCase is one manifest shape: its one entry's kind (live, tombstone or |
| +// skipped) at a path of the given length. |
| +type capCase struct { |
| + kind string |
| + n int |
| +} |
| + |
| +// capCases are the shapes every test here runs: an ordinary manifest, a |
| +// live entry at the cap, and each kind of entry one byte past it. |
| +var capCases = map[string]capCase{ |
| + "an ordinary manifest": {"live", len("a.txt")}, |
| + "a live entry at the cap": {"live", hostfiles.MaxPathBytes}, |
| + "a live entry past it": {"live", hostfiles.MaxPathBytes + 1}, |
| + "a tombstone past it": {"tombstone", hostfiles.MaxPathBytes + 1}, |
| + "a skipped entry past it": {"skipped", hostfiles.MaxPathBytes + 1}, |
| +} |
| + |
| +func (c capCase) path() string { |
| + if c.n == len("a.txt") { |
| + return "a.txt" |
| + } |
| + return capPath(c.n) |
| +} |
| + |
| +// manifest is the case's manifest for generation gen at location loc: a |
| +// live entry names an object under gen's files prefix, as the publisher |
| +// writes it. |
| +func (c capCase) manifest(loc, gen string) hostfiles.Manifest { |
| + var e hostfiles.Entry |
| + switch c.kind { |
| + case "live": |
| + sum := sha([]byte("A")) |
| + e = hostfiles.Entry{Object: movesFilesPrefix(loc, gen) + fmt.Sprintf("objects/%016d-%s", 1, sum), SHA256: sum, Size: 1, Mode: 0o600, Seq: 1} |
| + case "tombstone": |
| + e = hostfiles.Entry{Seq: 1, Deleted: true} |
| + case "skipped": |
| + e = hostfiles.Entry{Seq: 1, Skipped: "too-large"} |
| + } |
| + return hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 1, Entries: map[string]hostfiles.Entry{c.path(): e}, Platform: map[string]hostfiles.Entry{}} |
| +} |
| + |
| +// readerRefuses is what the files readers do with m today: ValidateManifest |
| +// (checkManifest, the establishment's reader) refuses it as ErrTooDeep. The |
| +// tests hold the cut to this verdict rather than to a fixed answer per |
| +// kind: today the readers cap every entry, tombstones and skipped entries |
| +// included, so the cut refuses all three past the cap. If region HA |
| +// relaxes the readers for tombstones and skipped entries (its part of |
| +// #236), RestorableWithinCap follows, and the cut with it. |
| +func readerRefuses(m hostfiles.Manifest, loc, gen string) bool { |
| + return errors.Is(hostfiles.ValidateManifest(m, []string{movesFilesPrefix(loc, gen)}), hostfiles.ErrTooDeep) |
| +} |
| + |
| +// putP1Manifest stores m as the current version of gen's files manifest in |
| +// the Phase 1 harness's data bucket, written directly (a manifest from |
| +// before the cap), never through the staging mirror. |
| +func putP1Manifest(t *testing.T, h *phase1Harness, loc, gen string, m hostfiles.Manifest) { |
| + t.Helper() |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := hostfiles.ManifestKey(movesFilesPrefix(loc, gen)) |
| + h.f.store("data-ie", key, fakeMoveObject{lease.MirrorObject{ETag: "manifest-" + gen, Size: int64(len(raw))}, string(raw)}) |
| + h.f.setVersion("data-ie", key, "ver-manifest-"+gen) |
| +} |
| + |
| +// A source whose files manifest has an entry past the cap is refused at S1 |
| +// with MV-N501, and nothing is staged: no staging request, no touch of the |
| +// target in the claim, the move still preparing. A manifest the readers |
| +// accept passes, and the round stages as before. |
| +func TestPreparingRefusesAFilesManifestPastTheCap(t *testing.T) { |
| + for name, c := range capCases { |
| + t.Run(name, func(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + loc := "instances/" + p1Host + "/" + h.inc |
| + m := c.manifest(loc, "cur") |
| + putP1Manifest(t, h, loc, "cur", m) |
| + touched := h.claim(t).Touched |
| + h.pass(t) |
| + sr := h.sourceReport(t) |
| + if sr == nil { |
| + t.Fatal("no source report") |
| + } |
| + refuses := readerRefuses(m, loc, "cur") |
| + if c.kind == "live" && refuses != (c.n > hostfiles.MaxPathBytes) { |
| + t.Fatalf("the reader's verdict on a live entry of %d bytes is %v", c.n, refuses) |
| + } |
| + if !refuses { |
| + if !sr.S1.OK || len(h.f.stagingCalls()) == 0 { |
| + t.Fatalf("a manifest the readers accept: S1 %+v, %d staging calls", sr.S1, len(h.f.stagingCalls())) |
| + } |
| + return |
| + } |
| + if sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| + t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilesPastCap(p1Host)) |
| + } |
| + if calls := h.f.stagingCalls(); len(calls) != 0 { |
| + t.Fatalf("staged despite the refusal: %+v", calls) |
| + } |
| + if got := h.claim(t).Touched; !reflect.DeepEqual(got, touched) { |
| + t.Fatalf("the claim was touched: %+v, was %+v", got, touched) |
| + } |
| + if st := h.read(t, p1Host).Move.State; st != provision.MovePreparing { |
| + t.Fatalf("the move is %s", st) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A files manifest that cannot be read ends the round as the other read |
| +// errors do: no source report, nothing staged, the reason in the log. |
| +func TestPreparingEndsTheRoundWhenTheFilesManifestCannotBeRead(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + h.vs.deny = func(op, _, key string) error { |
| + if op == "list-versions" && strings.Contains(key, "/files/g-") { |
| + return errors.New("injected 403") |
| + } |
| + return nil |
| + } |
| + logs := captureLog(t) |
| + h.pass(t) |
| + 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(), "read the files manifest") { |
| + t.Fatalf("no log of the read:\n%s", logs()) |
| + } |
| +} |
| + |
| +// 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 |
| +// is not pinned again while it stands. Once an operator replaces the |
| +// manifest, a passing round pins it again and the cut admits. |
| +func TestCutRefusesAFilesManifestPastTheCap(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.ev.stopped = false // stop at stopping, to look at the admission |
| + past := capCases["a live entry past it"] |
| + putP1Manifest(t, h.phase1Harness, cutRoot, "cur", past.manifest(cutRoot, "cur")) |
| + h.cut(t) |
| + h.pass(t) |
| + m := h.move(t) |
| + if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil { |
| + t.Fatalf("not back to ready: %+v", m) |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 { |
| + t.Fatalf("something stopped: %d evidence calls, holds %v, %d fences", h.ev.calls, h.holds, h.fences) |
| + } |
| + if sr := h.sourceReport(t); sr == nil || sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) { |
| + t.Fatalf("S1 = %+v, want %q", sr, s1FilesPastCap(p1Host)) |
| + } |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil { |
| + t.Fatalf("pinned again while the manifest stands: %+v", m) |
| + } |
| + |
| + // The operator replaces the manifest. |
| + putP1Manifest(t, h.phase1Harness, cutRoot, "cur", capCases["an ordinary manifest"].manifest(cutRoot, "cur")) |
| + // A refused report carries no archive, so the source reports once, the |
| + // target checks that report, and the next round pins. |
| + h.pass(t) |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil { |
| + t.Fatalf("not pinned again after the replacement: %+v", m) |
| + } |
| + h.cut(t) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping { |
| + t.Fatalf("the cut did not admit after the replacement: %+v", m) |
| + } |
| +} |
| + |
| +// The cut's preflight and the recovery's establishment agree on every |
| +// manifest shape, so they cannot drift: the source's round before the flip |
| +// (sourceFilesRefusal over the holder at src) refuses exactly the |
| +// manifests a recovery's Establish refuses as ErrTooDeep, through the |
| +// src_gen bridge of a clean return and through a revert's own lineage, and |
| +// both agree with the readers (readerRefuses). Both call |
| +// movesFilesRestorable; a manifest the cut passes, the recovery |
| +// establishes from. |
| +func TestCutPreflightAndRecoveryAgreeOnTheCap(t *testing.T) { |
| + ctx := context.Background() |
| + recoveries := map[string]func(o *hostclaim.Observed){ |
| + "a clean return": func(*hostclaim.Observed) {}, |
| + "a revert": func(o *hostclaim.Observed) { |
| + o.Claim.Op.Kind = hostclaim.KindReverting |
| + delete(o.Claim.Op.Intent, "rollback") |
| + o.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "src", Region: "uk", Predecessor: "@root", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}, |
| + {Generation: "r1", Region: "uk", Predecessor: "src", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}} |
| + }, |
| + } |
| + for name, c := range capCases { |
| + for rname, shape := range recoveries { |
| + t.Run(name+", "+rname, func(t *testing.T) { |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + m := c.manifest(filesLoc, "src") |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "src")), raw) |
| + |
| + // The cut's side: the source's round before the flip, its |
| + // holder at src. |
| + w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| + Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| + pre := servingObserved("src", "src:uk:@root:uk") |
| + why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder) |
| + if err != nil { |
| + t.Fatalf("the preflight: %v", err) |
| + } |
| + if why != "" && why != s1FilesPastCap(filesHost) { |
| + t.Fatalf("the preflight's reason %q", why) |
| + } |
| + |
| + // The recovery's side: the first recovery admission after |
| + // the cut, whose src_gen is the holder's generation then. |
| + o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1") |
| + shape(&o) |
| + o = x.observe(o, "src", "r1") |
| + est := x.f.Establish(ctx, o, launchInputs("r1"), x) |
| + if est != nil && !errors.Is(est, hostfiles.ErrTooDeep) { |
| + t.Fatalf("the establishment failed for another reason: %v", est) |
| + } |
| + |
| + cut, recovery, reader := why != "", est != nil, readerRefuses(m, filesLoc, "src") |
| + if cut != recovery || cut != reader { |
| + t.Fatalf("the cut refuses %v, the recovery refuses %v (%v), the readers refuse %v", cut, recovery, est, reader) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// The establishment applies the cap before any origin proof: a manifest |
| +// past it is ErrTooDeep with no Proven call, by the same function the |
| +// cut's preflight calls. |
| +func TestEstablishRefusesPastTheCapBeforeTheProof(t *testing.T) { |
| + ctx := context.Background() |
| + x := newFilesFixture(t) |
| + x.writeEnv("K=1\n") |
| + m := capCases["a live entry past it"].manifest(filesLoc, "src") |
| + if err := movesFilesRestorable(m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| + t.Fatalf("movesFilesRestorable: %v", err) |
| + } |
| + proof := &countingProof{proven: map[string]bool{"src": true, "r1": true}} |
| + x.f.Proof = proof |
| + if _, err := x.f.movesFilesAllowed(ctx, servingObserved("src"), launchInputs("r1").Admission, filesLoc, "r1", m); !errors.Is(err, hostfiles.ErrTooDeep) { |
| + t.Fatalf("movesFilesAllowed: %v", err) |
| + } |
| + if len(proof.calls) != 0 { |
| + t.Fatalf("the proof was asked %v", proof.calls) |
| + } |
| +} |
| + |
| +// The preflight reads by exact version through the data bucket's versioned |
| +// reader; a holder with no location, or a chain with no manifest, has |
| +// nothing to check. |
| +func TestSourceFilesRefusalNeedsAManifestToRefuse(t *testing.T) { |
| + ctx := context.Background() |
| + x := newFilesFixture(t) |
| + w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2", |
| + Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}} |
| + pre := servingObserved("src", "src:uk:@root:uk") |
| + if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); why != "" || err != nil { |
| + t.Fatalf("no manifest: %q, %v", why, err) |
| + } |
| + noLoc := *pre.Claim.Holder |
| + noLoc.Location = "" |
| + if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, &noLoc); why != "" || err != nil { |
| + t.Fatalf("no location: %q, %v", why, err) |
| + } |
| + w.cfg.Versioned = nil |
| + if _, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); err == nil { |
| + t.Fatal("no versioned reader passed") |
| + } |
| +} |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 36a6b2a2b..33f1f8c3c 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -17,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "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" |
| @@ -77,6 +78,26 @@ func s1OverCap(host string) string { |
| return host + " keeps more than 256 MiB of files beside its database. Keep large files in object storage." |
| } |
| +// s1FilesPastCap is MV-N501 (#236): the files manifest a recovery of the |
| +// host would restore holds an entry path past the readers' cap, so no |
| +// recovery after the cut could restore it (sourceFilesRefusal). |
| +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" |
| +} |
| + |
| +// 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 |
| // rows, written here from data (the sentinel, the path the error carries) |
| // rather than taken from an error's text. s1ArchiveFailed is every archive |
| @@ -309,7 +330,7 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| // The box-step refusals (§7.1: every box step re-checks), and the |
| // one-box rule the door applied (D15, ruling G13): a region that has |
| // gained a second box since is refused here, every round. |
| - if why, err := w.sourceRefusal(ctx, s, rec); err != nil { |
| + if why, err := w.sourceRefusal(ctx, s, rec, obs.Claim.Holder); err != nil { |
| return nil, nil, err |
| } else if why != "" { |
| return report(&srcRound{s1: &moves.Check{Reason: why}}) |
| @@ -532,8 +553,9 @@ func (w *movesWorker) refreshCrossRate(ctx context.Context, gm *guardedMirror, s |
| // sourceRefusal is the box-step re-check (§6.2, §7.1, F14): the reason this |
| // source cannot move, or "". A registry or bucket read that fails is an |
| -// error and ends the round. |
| -func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec provision.Record) (string, error) { |
| +// error and ends the round. holder is the claim's, as the round read it |
| +// under the staging lease. |
| +func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec provision.Record, holder *hostclaim.Holder) (string, error) { |
| cfg := w.cfg |
| if cfg.Route == nil { |
| return "", errors.New("no registry to re-check the source against") |
| @@ -565,6 +587,85 @@ func (w *movesWorker) sourceRefusal(ctx context.Context, s release.Store, rec pr |
| if cfg.SecretsPub == nil || cfg.SecretsKey == "" { |
| return s1NoSecretsKey, nil |
| } |
| + 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 |
| +// manifest written before the cap (a8cebad) can hold a path past it, and |
| +// every reader refuses that manifest terminally: a recovery that selected |
| +// it would fail its establishment and leave the source offline after the |
| +// cut. It runs in every preparing round and in the cut's requested round |
| +// (G13), before any staging write, stop or cut. |
| +// |
| +// The manifest is the one the establishment selects (movesFilesSelect): |
| +// the newest manifest of the holder's admitted chain in this region. That |
| +// is the one a revert restores (its holder never leaves the source), and |
| +// the one a clean return's src_gen bridge restores, since the flip |
| +// journals the holder's generation as src_gen and every later write of a |
| +// manifest is held to the cap by Commit. A dirty return restores from a |
| +// prepared manifest its install builds through the capped writers, so |
| +// 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 == "" { |
| + return "", nil // no location, no files manifest (a legacy holder) |
| + } |
| + 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) |
| + } |
| + m, key, version, found, err := movesFilesSelect(ctx, vs, cfg.OwnBucket, holder.Location, movesFilesChain(holder, cfg.ParkHomeRegion, cfg.parkRegion())) |
| + if err != nil { |
| + return "", fmt.Errorf("read the files manifest: %w", err) |
| + } |
| + if !found { |
| + return "", nil |
| + } |
| + if err := movesFilesRestorable(m); err != nil { |
| + if !errors.Is(err, hostfiles.ErrTooDeep) { |
| + return "", fmt.Errorf("the files manifest %s@%s: %w", key, version, err) |
| + } |
| + log.Printf("moves: %s: the files manifest %s@%s cannot be restored (%v); an operator must replace it", rec.Host, key, version, err) |
| + return s1FilesPastCap(rec.Host), nil |
| + } |
| return "", nil |
| } |
| @@ -810,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..1c3287872 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)} |
| } |
| @@ -157,6 +168,9 @@ func walk(root *os.Root, envPath string) ([]item, error) { |
| } |
| return nil |
| } |
| + // Through the root only (census ruling moves-overcap-cut-236, |
| + // binding): a read that fails refuses the walk, never a retry by |
| + // path. |
| info, err := root.Lstat(p) |
| if err != nil { |
| return err |
| @@ -167,6 +181,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 2618e1b5a..4efa7d2f4 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -741,6 +741,24 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + // 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). The ruling's |
| + // extension (fix round 1) covers archive.walk, buildPrepared and |
| + // readFileBounded for the files tree's cap, on the binding condition that |
| + // no read falls back to a path-based open when a root open fails. 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 = "fc38db001ebd8825709a8ca9c41cc0e8f165c1b1967e5967347dc9bd3556afec" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 05b5aba92..a646ddca9 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -772,7 +772,9 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesphase1.go", "writeMoveLedger", "nonrecord", "sel:Put", "597a70ac06ceb4e9eb52bb9f61b28a4937e703fa4a0916f64bd95d9594f0556e"}, |
| // settings-4: added the build-state lane (mirrorBuildState: holder location's |
| // build-state.json to the move lane's state/ prefix in the data bucket); no record key. |
| - {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "9f87605eaeb58cb034c2147a2f54e02b542d1cf8930b6fe3a1b78868cc8e5345"}, |
| + // moves-overcap-cut (#236): sourceRefusal is passed the claim holder for the |
| + // files manifest preflight; the write sites are unchanged. |
| + {"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "aacb3d17ad302c969e05ccfc0e091c4c6a2b0d6cc3791fa5f717ee7ad905a9e7"}, |
| {"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"}, |
| {"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"}, |
| {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"}, |
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| new file mode 100644 |
| index 000000000..1ff0c6c27 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv |
| @@ -0,0 +1,15 @@ |
| +# 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. |
| +# Extension of census ruling moves-overcap-cut-236 (platform-1e, 2026-10-15), #236 fix round 1: 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) stay accepted-legacy with main's dispositions and the ruling's suffix. CONDITION (binding): no read falls back to a path-based open when a root open fails; it refuses instead. |
| +@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 826af0218f3ea5b1cf04fa64f3546303ab5a7c261f1f5462264f994134c9b998 internal/moves/archive/archive.go walk legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Lstat@1168,file:Lstat@747,file:WalkDir@242 6ee298c79079c2bb5a1dfb15f0af4c0ca41b9ef7b4cd349894deec47d21d5e54 |
| +internal/moves/archive/archive.go walk legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Lstat@1168,file:Lstat@747,file:WalkDir@242 6ee298c79079c2bb5a1dfb15f0af4c0ca41b9ef7b4cd349894deec47d21d5e54 |
| +@legacy 825ac4c6b6df65127e2334d47521cc5d302246f376dad2ae26a6bc6d599d17b0 internal/host/movesfiles.go buildPrepared legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Lstat@613,file:OpenRoot@2066,file:ReadFile@808,file:WalkDir@2252 16939eca2043b765376bcf44653902d46f51af192ff36d8ef0fbb2b5b098b59e |
| +internal/host/movesfiles.go buildPrepared legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Lstat@613,file:OpenRoot@2066,file:ReadFile@808,file:WalkDir@2252 16939eca2043b765376bcf44653902d46f51af192ff36d8ef0fbb2b5b098b59e |
| +@legacy 68bb7ee7e82af29cc8438c3fc3f575f11c86a71e53806af37bef1a5c76082832 internal/host/movesfiles.go var readFileBounded legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Open@236,file:ReadAll@327 9a6274fadde8c8905c83791f982e195eb894209d2dfed4ba7ce2929ee0f66cc9 |
| +internal/host/movesfiles.go var readFileBounded legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-moves-overcap-cut-236 file:Open@236,file:ReadAll@327 9a6274fadde8c8905c83791f982e195eb894209d2dfed4ba7ce2929ee0f66cc9 |
| +@coverage internal/host 590dd6508e927969a8e54ea4ae074abd1772e106c1a2cedcc37b5c2f5ae3e962 |
| +@coverage internal/moves/archive 4d329d65e065ce06cd6108529a9d58bac7aad481289d8b4e18c23ddcd2ed8cac |