Merge origin/main 70e57449 into files seed + WIP checkpoint 32 (not for merge)
Census overlay re-pinned after main's chain; retained unverifiable parks no longer block later retirement or wakes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+1133
−81
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+14 −0docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md+10 −2internal/host/claimfilesseed_reboot_rootrunner_linux_test.go+49 −14internal/host/claimfilesseed_renumber_rootrunner_linux_test.go+7 −0internal/host/claimservingpark_records.go+13 −5internal/host/claimservingquarantine_linux_test.go+15 −9internal/host/claimservingretained_linux_test.go+117 −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_legacy_identity_test.go+3 −3internal/provision/plan14_root_reads_test.go+19 −1internal/provision/plan9_b8_census_test.go+3 −1internal/provision/testdata/plan14-root-reads-moves-overcap-cut-236.tsv+15 −0internal/provision/testdata/plan14-root-reads-seed.tsv+4 −4
CI passed — 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 284.932s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.714s
ok github.com/carlosframework/platform/cmd/moveprobe 92.296s
ok github.com/carlosframework/platform/cmd/zddprobe 0.710s
ok github.com/carlosframework/platform/internal/activator 250.207s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
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.478s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.139s
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.893s
? 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.002s
ok github.com/carlosframework/platform/internal/cgroup 0.716s
ok github.com/carlosframework/platform/internal/changelog 0.305s
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 388.752s
? 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 5.847s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
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.322s
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.539s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.130s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.021s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/host 159.864s
ok github.com/carlosframework/platform/internal/hostactors 0.310s
ok github.com/carlosframework/platform/internal/hostbox 2.453s
ok github.com/carlosframework/platform/internal/hostclaim 0.494s
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.029s
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 1.007s
ok github.com/carlosframework/platform/internal/hostops 1.555s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.013s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.447s
ok github.com/carlosframework/platform/internal/hostresolve 0.141s
ok github.com/carlosframework/platform/internal/hostwrite 0.013s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 4.170s
ok github.com/carlosframework/platform/internal/instanceexec 2.726s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.525s
ok github.com/carlosframework/platform/internal/lease 9.808s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.018s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.163s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.509s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.070s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.003s
ok github.com/carlosframework/platform/internal/moves/archive 0.788s
ok github.com/carlosframework/platform/internal/objstore 0.177s
ok github.com/carlosframework/platform/internal/ops 0.178s
ok github.com/carlosframework/platform/internal/park 0.191s
ok github.com/carlosframework/platform/internal/privatefile 0.008s
ok github.com/carlosframework/platform/internal/provision 17.221s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.927s
ok github.com/carlosframework/platform/internal/relay 7.236s
ok github.com/carlosframework/platform/internal/release 19.572s
ok github.com/carlosframework/platform/internal/rollout 3.820s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
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.579s
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.504s
ok github.com/carlosframework/platform/internal/seeds 0.012s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.625s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.653s
ok github.com/carlosframework/platform/internal/status 0.037s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.181s
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.003s
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.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
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 47055010 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.022s
=== RUN TestT4StorageProbeMinio
2026/10/16 00:36:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEDB709D4FEF87, 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.020s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 5793d69b2..2b6af98a8 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/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md| index 8ca66882b..f095dfd5b 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md |
| @@ -548,8 +548,16 @@ reached the stopped position). The target box logs both: |
| `eu-west-2`, box `carlos-dev-uk`, data bucket |
| `carlos-dev-uk-640012953448` (platform-infrastructure#42). Before Run 1: |
| -- [ ] Bootstrap `carlos-dev-uk`. |
| -- [ ] Copy the SSM parameters a dev box reads at boot to `eu-west-2`. |
| +- [x] Bootstrap `carlos-dev-uk`. Done 2026-10-15: box `dennis-stenciling` |
| + on v0.27.3, with `CARLOS_SWAP=overlap` and `CARLOS_REGION=uk`. |
| +- [x] SSM parameters: nothing to copy. No box reads SSM at boot or at |
| + runtime. Never copy `/carlos/release-key` to a region whose host role can |
| + read `/carlos/*`. In `eu-west-2` the `carlos-dev-uk` role can. |
| +- [x] `carlos-dev-uk` needs the dev console's `CARLOS_SECRETS_KEY` before |
| + Run 1 (T2 and config sync). Done 2026-10-15: `dennis-stenciling` got the |
| + dev key through SSM `eu-west-2` `/carlos/secrets-key`. Its |
| + `secrets_key_fp` is `sha256:bb067582…f5fe`, the same as sophie's. The key |
| + is backed up in 1Password as "CARLOS_SECRETS_KEY dev". |
| - [ ] `CARLOS_REGION_HA=true` on `carlos-dev-ie` and `carlos-dev-uk`, and |
| `CARLOS_AWS_ACCOUNT` on the dev console. |
| - [ ] Keep `carlos-dev-ie-b` ineligible: no `CARLOS_REGION_HA` in its |
diff --git a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go| index 37c8a6460..5035026bc 100644 |
| --- a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go |
| @@ -3,6 +3,7 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ed25519" |
| "encoding/json" |
| @@ -105,20 +106,7 @@ func saveFilesSeedReboot(t *testing.T, binary string, hosts []*seedLiveHost) { |
| t.Fatal(err) |
| } |
| if r.Authorization.Successor.Host == host && r.Parked && !r.Closed { |
| - if strings.Contains(t.Name(), "Renumber") { |
| - // Loop devices have no independent volume serial. Expire the |
| - // retained park before reboot; FS-R7 forbids its deletion after |
| - // renumber. The ordinary EBS pair retains its parks across boot. |
| - f.f.now = r.ReapAfter.Add(time.Second) |
| - if err := f.p.ledger.withNamespace(t.Context(), func() error { return f.p.ledger.reapLocked(t.Context(), r.Authorization.ID) }); err != nil { |
| - t.Fatal("expire loop park before reboot", err) |
| - } |
| - r, err = f.p.ledger.readHistorical(r.Authorization.ID) |
| - if err != nil || !r.Closed { |
| - t.Fatal("expired loop park", err) |
| - } |
| - t.Log("loop park expired before reboot", host) |
| - } |
| + |
| sh.Records = append(sh.Records, r) |
| } |
| } |
| @@ -210,6 +198,24 @@ func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) { |
| } |
| } |
| } |
| + retained := map[string][]byte{} |
| + if strings.Contains(t.Name(), "Renumber") { |
| + for _, host := range saved.Hosts { |
| + for _, r := range host.Records { |
| + for _, file := range r.Source.Files { |
| + if !file.Present { |
| + continue |
| + } |
| + path := filepath.Join("/data/instances/.serving-parked", r.Authorization.ID, file.Name) |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + retained[path] = body |
| + } |
| + } |
| + } |
| + } |
| var hosts []*seedLiveHost |
| for _, savedHost := range saved.Hosts { |
| h := reopenSeedLiveHost(t, savedHost, witness, state) |
| @@ -245,6 +251,35 @@ func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) { |
| h.probe(t) |
| h.stop() |
| } |
| + for path, want := range retained { |
| + got, err := os.ReadFile(path) |
| + if err != nil || !bytes.Equal(got, want) { |
| + t.Fatalf("retained park changed after two wakes: %s %v", path, err) |
| + } |
| + } |
| + if len(retained) != 0 { |
| + ids, more, err := ledger.recoveryIDs(t.Context(), "", servingParkRecoveryLimit) |
| + if err != nil || more { |
| + t.Fatal("retained census", err) |
| + } |
| + count := 0 |
| + for _, host := range saved.Hosts { |
| + for _, old := range host.Records { |
| + found := false |
| + for _, id := range ids { |
| + if id == old.Authorization.ID { |
| + found = true |
| + } |
| + } |
| + r, err := ledger.readHistorical(old.Authorization.ID) |
| + if !found || err != nil || r.Closed || !r.ReapAfter.Equal(old.ReapAfter) { |
| + t.Fatal("lost retained record", r, err) |
| + } |
| + count++ |
| + } |
| + } |
| + t.Logf("%d unverifiable parks retained after two live wakes per host; %d records counted", count, len(ids)) |
| + } |
| } |
| func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiveWitness, state rootstate.Store) *seedLiveHost { |
diff --git a/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go b/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go| index d6a54c6bd..3249a63e3 100644 |
| --- a/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go |
| +++ b/internal/host/claimfilesseed_renumber_rootrunner_linux_test.go |
| @@ -102,3 +102,10 @@ func TestRootServingHandoverDirectoryProof(t *testing.T) { |
| servingRootRunner(t) |
| TestServingHandoverRebootDirectoryProof(t) |
| } |
| + |
| +func TestRootServingParkRetainedDoesNotBlockRetirement(t *testing.T) { |
| + servingRootRunner(t) |
| + TestServingParkRetainedDoesNotBlockRetirement(t) |
| + TestServingParkRetainedReplacementWriteFailures(t) |
| + TestServingParkQuarantineRetainedParkAllowsRetirement(t) |
| +} |
diff --git a/internal/host/claimservingpark_records.go b/internal/host/claimservingpark_records.go| index 929b52ea1..7a92c13e4 100644 |
| --- a/internal/host/claimservingpark_records.go |
| +++ b/internal/host/claimservingpark_records.go |
| @@ -8,6 +8,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "io" |
| + "log" |
| "os" |
| "reflect" |
| "time" |
| @@ -315,7 +316,7 @@ func (l *servingParkLedger) slot(a servingParkAuthorization) (servingParkSlot, e |
| return s, hostbox.ErrChanged |
| } |
| if saved.ID != "" { |
| - r, err := l.read(saved.ID) |
| + r, err := l.readHistorical(saved.ID) |
| if err != nil || r.Disposition != servingParkRetain || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| return s, hostbox.ErrChanged |
| } |
| @@ -327,7 +328,7 @@ func (l *servingParkLedger) slot(a servingParkAuthorization) (servingParkSlot, e |
| } |
| } |
| if saved.Last != "" { |
| - r, err := l.read(saved.Last) |
| + r, err := l.readHistorical(saved.Last) |
| if err != nil || r.Authorization.Successor.Box != s.Box || r.Authorization.Successor.Host != s.Host { |
| return s, hostbox.ErrChanged |
| } |
| @@ -423,7 +424,7 @@ func (l *servingParkLedger) parkWithEvidenceLocked(ctx context.Context, a servin |
| // intent from a restored source in the same incarnation. The outer root |
| // guard independently requires the current incarnation and successor. |
| if slot.Last != "" { |
| - last, e := l.read(slot.Last) |
| + last, e := l.readHistorical(slot.Last) |
| if e != nil { |
| return zero, e |
| } |
| @@ -666,7 +667,7 @@ func (l *servingParkLedger) replaceLocked(ctx context.Context, id string) error |
| return err |
| } |
| defer unlock() |
| - old, err := l.read(id) |
| + old, err := l.readHistorical(id) |
| if err != nil || !old.Complete { |
| return hostbox.ErrChanged |
| } |
| @@ -677,7 +678,14 @@ func (l *servingParkLedger) replaceLocked(ctx context.Context, id string) error |
| if old.Deleting { |
| cause = old.Cause |
| } |
| - return l.deleteLocked(ctx, &old, cause) |
| + current, err := l.read(id) |
| + if err == nil { |
| + err = l.deleteLocked(ctx, ¤t, cause) |
| + } |
| + if err != nil { |
| + log.Printf("Host %q: serving database retirement %q retained: %s.", old.Authorization.Successor.Host, id, err) |
| + } |
| + return ctx.Err() |
| } |
| func sameServingRootStops(a, b []hostguard.FenceReceipt) bool { |
diff --git a/internal/host/claimservingquarantine_linux_test.go b/internal/host/claimservingquarantine_linux_test.go| index 7cab7c29d..a0524cb6e 100644 |
| --- a/internal/host/claimservingquarantine_linux_test.go |
| +++ b/internal/host/claimservingquarantine_linux_test.go |
| @@ -546,7 +546,7 @@ func TestServingParkQuarantineRefusesUnsafeAuthority(t *testing.T) { |
| } |
| } |
| -func TestServingParkQuarantinePreservesBlockedSlot(t *testing.T) { |
| +func TestServingParkQuarantineRetainedParkAllowsRetirement(t *testing.T) { |
| f := newServingParkDiskFixture(t) |
| f.put("", "old served database") |
| a, stops := f.evidence(10, true, "missing") |
| @@ -576,20 +576,26 @@ func TestServingParkQuarantinePreservesBlockedSlot(t *testing.T) { |
| quarantinePreserved(t, f, q) |
| f.put("", "new served database") |
| next, receipts := f.evidence(11, false, "started") |
| - if _, err := f.park(next, receipts); err == nil { |
| - t.Fatal("unresolved slot replaced") |
| + parked, err := f.park(next, receipts) |
| + if err != nil { |
| + t.Fatal("retained quarantine blocked retirement", err) |
| } |
| - slot, err := f.ledger.slot(a) |
| - if err != nil || slot.ID != a.ID { |
| - t.Fatal("blocked slot changed", err) |
| + f.complete(next) |
| + f.retained(next, "new served database", parked.ReapAfter) |
| + old, err := f.ledger.readHistorical(a.ID) |
| + if err != nil || old.Closed || !old.ReapAfter.Equal(r.ReapAfter) { |
| + t.Fatal("lost retained quarantine record", old, err) |
| } |
| body, err := os.ReadFile(filepath.Join(path+"-saved", a.ID, a.Successor.Host+".db")) |
| if err != nil || string(body) != "old served database" { |
| t.Fatal("old park changed", err) |
| } |
| - body, err = os.ReadFile(provision.NestedDBPath(f.data, f.base.Host)) |
| - if err != nil || string(body) != "new served database" { |
| - t.Fatal("blocked replacement changed live source", err) |
| + body, err = os.ReadFile(filepath.Join(path+"-saved", a.ID, "unknown")) |
| + if err != nil || string(body) != "retain" { |
| + t.Fatal("unknown retained child changed", err) |
| + } |
| + if _, err = os.Stat(provision.NestedDBPath(f.data, f.base.Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("new retirement left its source", err) |
| } |
| f.base.Host, f.target.Host = "other.example", "other.example" |
| if err := os.MkdirAll(filepath.Dir(provision.NestedDBPath(f.data, f.base.Host)), 0700); err != nil { |
diff --git a/internal/host/claimservingretained_linux_test.go b/internal/host/claimservingretained_linux_test.go| new file mode 100644 |
| index 000000000..ccdc0d332 |
| --- /dev/null |
| +++ b/internal/host/claimservingretained_linux_test.go |
| @@ -0,0 +1,117 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "log" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| +) |
| + |
| +func TestServingParkRetainedDoesNotBlockRetirement(t *testing.T) { |
| + for _, attack := range []string{"unreadable-volume", "changed-volume", "hardlink", "unreadable-binding"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, old := servingRebootRecordFixture(t, "complete") |
| + servingRenumberHistory(t, f, &old) |
| + if attack == "unreadable-volume" { |
| + f.fs.volume = func(uint64) string { return "" } |
| + } |
| + if attack == "changed-volume" { |
| + f.fs.volume = func(uint64) string { return "vol11111111111111111" } |
| + } |
| + root := &servingParkRoot{ledger: f.ledger} |
| + if err := root.commitServingReboot(t.Context(), old.Authorization.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "hardlink" { |
| + if err := os.Link(f.path(old.Authorization, ""), f.path(old.Authorization, "")+".extra"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if attack == "unreadable-binding" { |
| + if err := f.disk.Write("current-retirement-"+old.Authorization.ID+".json", []byte("invalid")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var report bytes.Buffer |
| + output := log.Writer() |
| + log.SetOutput(&report) |
| + defer log.SetOutput(output) |
| + for n := uint64(2); n <= 3; n++ { |
| + body := fmt.Sprintf("tenant writes %d", n) |
| + f.put("", body) |
| + next, stops := f.evidence(n, false, "started") |
| + parked, err := f.park(next, stops) |
| + if err != nil { |
| + t.Fatalf("retained park blocked retirement %d: %v", n, err) |
| + } |
| + f.complete(next) |
| + f.retained(next, body, parked.ReapAfter) |
| + } |
| + saved, err := f.ledger.readHistorical(old.Authorization.ID) |
| + if err != nil || saved.Closed || !saved.ReapAfter.Equal(old.ReapAfter) { |
| + t.Fatal("lost retained record", saved, err) |
| + } |
| + for suffix, want := range map[string]string{"": "last database", "-wal": "last WAL", "-shm": "last SHM"} { |
| + got, err := os.ReadFile(f.path(old.Authorization, suffix)) |
| + if err != nil || string(got) != want { |
| + t.Fatalf("retained %s: %q %v", suffix, got, err) |
| + } |
| + } |
| + ids, more, err := f.ledger.recoveryIDs(t.Context(), "", servingParkRecoveryLimit) |
| + if err != nil || more || len(ids) != 3 { |
| + t.Fatal("retained record missing from census", ids, more, err) |
| + } |
| + if !bytes.Contains(report.Bytes(), []byte(old.Authorization.ID)) { |
| + t.Fatal("retained park not reported", report.String()) |
| + } |
| + if err := f.ledger.maintainParks(t.Context(), func() error { return instancectl.ErrDiskFull }); err == nil { |
| + t.Fatal("pressure granted deletion of retained park") |
| + } |
| + if _, err := os.Stat(f.path(old.Authorization, "")); err != nil { |
| + t.Fatal("pressure lost retained park", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkRetainedReplacementWriteFailures(t *testing.T) { |
| + for _, before := range []bool{false, true} { |
| + for boundary := 1; boundary <= 18; boundary++ { |
| + t.Run(fmt.Sprintf("before-%t-write-%d", before, boundary), func(t *testing.T) { |
| + f, old := servingRebootRecordFixture(t, "complete") |
| + servingRenumberHistory(t, f, &old) |
| + f.fs.volume = func(uint64) string { return "" } |
| + root := &servingParkRoot{ledger: f.ledger} |
| + if err := root.commitServingReboot(t.Context(), old.Authorization.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + next, stops := f.evidence(2, false, "started") |
| + faults := &servingParkFaultStore{Store: f.disk, failAt: boundary, before: before} |
| + f.ledger.state, f.fs.state = faults, faults |
| + if _, err := f.park(next, stops); err == nil { |
| + _ = f.ledger.withNamespace(t.Context(), func() error { return f.ledger.completeLocked(t.Context(), next.ID, next.Successor) }) |
| + } |
| + f.ledger.state, f.fs.state = f.disk, f.disk |
| + parked, err := f.park(next, stops) |
| + if err != nil { |
| + t.Fatal("retirement retry", err) |
| + } |
| + f.complete(next) |
| + f.retained(next, "successor database", parked.ReapAfter) |
| + got, err := os.ReadFile(f.path(old.Authorization, "")) |
| + if err != nil || string(got) != "last database" { |
| + t.Fatalf("retained bytes after retry: %q %v", got, err) |
| + } |
| + saved, err := f.ledger.readHistorical(old.Authorization.ID) |
| + if err != nil || saved.Closed || !saved.ReapAfter.Equal(old.ReapAfter) { |
| + t.Fatal("retained record after retry", saved, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index 58ff91b25..faf0ac08a 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -250,17 +250,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 |
| @@ -270,16 +282,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) |
| @@ -313,12 +325,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 |
| } |
| @@ -406,7 +421,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 |
| } |
| @@ -430,30 +452,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 |
| @@ -533,6 +583,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_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index 578aa44f4..f0e4d3cbf 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -603,12 +603,12 @@ func TestPlan14SeededInstancesOverlayPreservesLegacySeal(t *testing.T) { |
| func TestPlan14SeedOverlayPreservesLegacySeal(t *testing.T) { |
| root := censusRoot(t) |
| inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| - for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv"} { |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv", "plan14-root-reads-moves-overcap-cut-236.tsv"} { |
| if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| t.Fatal(err) |
| } |
| } |
| - const seal = "10e024eccd584f40b64e959a105c551302585e3e14e55104fc250dae5960898a" |
| + const seal = "fc38db001ebd8825709a8ca9c41cc0e8f165c1b1967e5967347dc9bd3556afec" |
| if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| t.Fatal(errs) |
| } |
| @@ -619,7 +619,7 @@ func TestPlan14SeedOverlayPreservesLegacySeal(t *testing.T) { |
| if len(inv.Legacy) != before+7 { |
| t.Fatal("FS-R6, FS-R10 and FS-R11 preserve exactly seven additional legacy fingerprints") |
| } |
| - if errs := checkRootReadLegacy(inv, "586fc489beaebe0f6fbce9f5199d0e80379f2bd00e3e3e716a273d6fe2e67d36"); len(errs) != 0 { |
| + if errs := checkRootReadLegacy(inv, "624e96c0f0fd397d09575e807f7347f86cbcb109b4b713eb8369f0b64622e483"); len(errs) != 0 { |
| t.Fatal(errs) |
| } |
| for _, key := range []string{"internal/activator/servingretry.go:ServingRetries.Wrap", "internal/activator/servingretry.go:ServingRetries.settle", "internal/park/local.go:Local.RetireSuperseded"} { |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 6892f03ae..6c82ddccb 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -744,11 +744,29 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| 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" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-seed.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| // FS-R6 preserves three legacy readers; FS-R10 re-digests the test-only S3 server. Other proposals remain unapproved. |
| - seal = "586fc489beaebe0f6fbce9f5199d0e80379f2bd00e3e3e716a273d6fe2e67d36" |
| + seal = "624e96c0f0fd397d09575e807f7347f86cbcb109b4b713eb8369f0b64622e483" |
| 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 8e9c8eafb..ba5efc8dd 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -782,7 +782,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 |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index 57ceceeb6..a3dc77643 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -186,12 +186,12 @@ internal/host/claimservingpark_records.go servingParkLedger.completeLocked autho |
| internal/host/claimservingpark_records.go servingParkLedger.continueLocked authority C,J4 rev3=PROPOSED-seed; namespace/host-protected slot and record locks; pending original retirement, absent source and retained parked identities before durable bounded continuation 87f297a924f7f153624304233218d8b5548a016fc7dbf0f7cb8dedb789324009 |
| internal/host/claimservingpark_records.go servingParkLedger.deleteLocked authority C,J4 rev3=PROPOSED-seed; complete retained record or proved discard disposition and persisted cause/progress before filesystem deletion 1545950c564d51d9c29b0d76daca1ddec5a159481698b11accd4dd7411157b37 |
| internal/host/claimservingpark_records.go servingParkLedger.parkLocked authority C,J4 rev3=PROPOSED-seed; generation or test-fixture legacy entry delegates to immutable evidence-aware ledger; no independent source authority 1f16bea822e6c63df15c8259c8972a4084cdf364769f10396f396424e0fe848c |
| -internal/host/claimservingpark_records.go servingParkLedger.parkWithEvidenceLocked authority C,J4 rev3=PROPOSED-seed; root-authenticated source, prior slot and immutable intent; a missing same-incarnation retirement must be newer than the last successor; preserve park ownership and replay refusal after collection; old-format replay preserves original consumption and source evidence without inferred handles file:Prepare@3772,file:Read@1774 c0cdd64d93a729abd8cf2a48606579516824c35181f977631de4a1a395a0b8c7 |
| +internal/host/claimservingpark_records.go servingParkLedger.parkWithEvidenceLocked authority C,J4 rev3=PROPOSED-seed; run32; root; last immutable retirement supplies only successor ordering; current source capture, stop evidence, pending intent, slot writes and rename authorization unchanged file:Prepare@3782,file:Read@1784 429fccd009db1aea9bc42dd7a5489d1bedd2a2eabab08bd3cbdab056b49761a6 |
| internal/host/claimservingpark_records.go servingParkLedger.read authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations f029f0fb8d31bdccaf86a5e336ffc1643eb2487fc7adb87852a9d21ad287e732 |
| internal/host/claimservingpark_records.go servingParkLedger.readHistorical authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations file:Read@259 7af371e0f68db2dbf035c4dd75c9f8312ea3733d1bbfbdd4139088b2e1805bb3 |
| -internal/host/claimservingpark_records.go servingParkLedger.replaceLocked authority C,J4 rev3=PROPOSED-seed; completed incumbent record under its record lock; resumes its recorded deletion cause before slot replacement 3e67316e959c9a901c32a2a61fda4e6ea5e92fe51bdb7fdb5f5c53d9d5960aad |
| +internal/host/claimservingpark_records.go servingParkLedger.replaceLocked authority C,J4 rev3=PROPOSED-seed; run32; root; validates completed incumbent from immutable private record; rebinds and applies existing identity/volume checks before deletion; failed deletion retains its record and artifacts, reports the existing approved warning, and permits the new slot after cancellation check; no deletion authority added 39322abbd7ed543d0aa3a82619a4d6a2507a1deb83cd1fe1dd00e33737465e23 |
| internal/host/claimservingpark_records.go servingParkLedger.save authority C,J4 rev3=PROPOSED-seed; root-private retirement bytes and separate mount projection; forward reads and inverse progress writes validate both representations 187f2c360d38e10a9f22b20f4c02a021c66b9308cad404e536ef5d0f0a860674 |
| -internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4 rev3=PROPOSED-seed; private box/host slot and pending retirement identities across incarnation changes; last-retirement identity supports discarded/artifact-free completion replay file:Read@179 239b806b2e2bfdd568f5d6b749d299f0bc7b118d675e872429432591a89b7b68 |
| +internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4 rev3=PROPOSED-seed; run32; root; immutable private ID and Last records validate host, box and retained disposition without requiring a usable boot binding; pending retirement still requires current binding; these reads authorize slot identity only, never filesystem mutation file:Read@179 aa8be9edd8e4515abf4e8f567420e7f7f642d0cc24e729c450542853e7446144 |
| internal/host/claimservingpark_records.go servingParkLedger.withNamespace authority C,J4 rev3=PROPOSED-seed; private rootstate namespace lock and cancellation before invoking root authority; composed with root host authority through the typed journal service b7cfae44fdb7de9ef8130f5581710ae3a1a526bb059b4f616650172490f5c8ac |
| internal/host/claimservingpark_records.go servingParkLedger.write authority C,J4 rev3=PROPOSED-seed; serialized private record sent only to the supplied rootstate store 0635d2c27871e67079119a0f2e3034e817b765d0fe3a47e7d9bdc66fb2779571 |
| internal/host/claimservingpark_records.go servingParkRecord.activeAuthorization authority C,J4 rev3=PROPOSED-seed; last persisted continuation authorization or immutable original authorization; no source or deadline changes fe168fe52317e45ce70c3e97a9bc2c52d2409328ab4844a8d2a34fc4babba36d |
| @@ -325,7 +325,7 @@ system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, |
| system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins ee25513ed72f9b8338d95682c4c76b4ced26ec1f060528fc5804da9afbfbfd83 |
| @coverage . bf3d70b6a2c5d769a3c580c9d5511231499b788d9c08a542895de8381e5a39c9 |
| @coverage internal/activator 9bc3f0f89cc3902c34917f1c6b2f86a778cec0bed499e5ea783fac9d93c95a15 |
| -@coverage internal/host 0b8d526b4e8b1504dafba96903efca8946b2d0bdb8c3ce35bf3b94b9131736e3 |
| +@coverage internal/host 6753f47be8c2d263dd1fd116fb112a39a6184fe8400b26046a74f9462c15e1af |
| @coverage internal/hostbox 86337ee9d1f7a4bdd15e331cac712c55d39da4cc26a7c013573a7a9535fe3975 |
| @coverage internal/hostfiles a38dbcd9789ba4acd237e88c834f2b2bfca9847b0d43c32b0116dc8996d7a142 |
| @coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6 |