WIP claim Plan 14 Part 3 checkpoint 2 (not for merge)
P3-R1..R3 and most of R4 fixed; F2 syscall-cut crash coverage; G2 phase engine, root census bootstrap with signed Seeds; J1-J3 matrices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
48 files changed,
+2573
−146
internal/console/boxselect.go+2 −1internal/console/boxselect_test.go+31 −3internal/console/plan14_root_floor_test.go+2 −0internal/host/claimjournal_migration.go+8 −1internal/host/guardexec.go+1 −1internal/host/guardexec_edge_root_linux_test.go+72 −0internal/host/plan14_root_dispatch_test.go+56 −0internal/host/plan14_root_migration_test.go+220 −0internal/host/plan14_root_payload_families_linux_test.go+18 −0internal/host/rootinstallation_test.go+1 −0internal/host/rootmigration.go+232 −0internal/host/rootmigration_bootstrap_root_linux_test.go+161 −0internal/host/rootmigration_census.go+223 −0internal/host/rootmigration_census_linux_test.go+95 −0internal/host/rootmigration_production_linux.go+236 −0internal/host/rootmigration_production_other.go+13 −0internal/host/rootmigration_root_linux_test.go+170 −0internal/host/rootmovesfloor.go+62 −5internal/host/rootmovesfloor_authorization.go+55 −0internal/host/rootmovesfloor_boot.go+3 −6internal/host/rootmovesfloor_boot_root_linux_test.go+2 −5internal/host/rootmovesfloor_boot_test.go+2 −2internal/host/rootmovesfloor_crash_root_linux_test.go+90 −0internal/host/rootmovesfloor_recovery_root_linux_test.go+130 −0internal/host/rootmovesfloor_refresh.go+13 −2internal/host/rootmovesfloor_refresh_test.go+4 −4internal/host/rootmovesfloor_root_linux_test.go+16 −5internal/host/rootmovesfloor_source.go+13 −23internal/host/rootmovesfloor_test.go+88 −4internal/host/systemapply.go+23 −9internal/host/units/carlos-system-recover.service+1 −1internal/host/units/carlos-system-update.service+1 −1internal/hostbox/migration_census_linux.go+104 −0internal/hostbox/migration_census_other.go+9 −0internal/hostguard/guard.go+3 −0internal/hostguard/record.go+1 −0internal/hostguard/root.go+13 −3internal/hostguard/rootmigration.go+85 −0internal/hostguard/rootmigration_inventory.go+40 −0internal/hostguard/rootmigration_test.go+52 −0internal/hostguard/rootview.go+14 −10internal/provision/plan14_p3_census_test.go+1 −0internal/provision/testdata/plan14-root-reads-p3.tsv+100 −53internal/rootstate/fault_rootrunner_linux.go+27 −0internal/rootstate/state_linux.go+5 −1internal/safefile/fault_rootrunner_linux.go+12 −0internal/safefile/file_linux.go+10 −6internal/safefile/plan14_sync_linux_test.go+53 −0
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 294.573s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.731s
ok github.com/carlosframework/platform/cmd/moveprobe 93.175s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 249.687s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.477s
ok github.com/carlosframework/platform/internal/apperr 0.070s
ok github.com/carlosframework/platform/internal/applog 0.119s
ok github.com/carlosframework/platform/internal/awsinv 0.006s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.594s
? 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.007s
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.700s
ok github.com/carlosframework/platform/internal/changelog 0.310s
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 394.149s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.196s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.342s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.003s
ok github.com/carlosframework/platform/internal/edge 26.919s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.156s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.182s
ok github.com/carlosframework/platform/internal/host 143.936s
ok github.com/carlosframework/platform/internal/hostactors 0.365s
ok github.com/carlosframework/platform/internal/hostbox 2.172s
ok github.com/carlosframework/platform/internal/hostclaim 0.496s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 4.896s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 10.492s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 1.018s
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.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.423s
ok github.com/carlosframework/platform/internal/hostresolve 0.130s
ok github.com/carlosframework/platform/internal/hostwrite 0.001s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 4.171s
ok github.com/carlosframework/platform/internal/instanceexec 2.712s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.509s
ok github.com/carlosframework/platform/internal/lease 9.806s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.020s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.162s
ok github.com/carlosframework/platform/internal/maintenance 0.075s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.508s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.133s
ok github.com/carlosframework/platform/internal/moves/archive 0.776s
ok github.com/carlosframework/platform/internal/objstore 0.166s
ok github.com/carlosframework/platform/internal/ops 0.167s
ok github.com/carlosframework/platform/internal/park 0.206s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 17.490s
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 5.335s
ok github.com/carlosframework/platform/internal/relay 7.234s
ok github.com/carlosframework/platform/internal/release 22.047s
ok github.com/carlosframework/platform/internal/rollout 3.768s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.583s
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.509s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.544s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.065s
ok github.com/carlosframework/platform/internal/status 0.037s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.196s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.601s
=== 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: 47186082 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.04s)
--- 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.02s)
--- 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.01s)
--- 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.01s)
--- 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.043s
=== RUN TestT4StorageProbeMinio
2026/10/15 15:04:40 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEBC3EF2ABE921, 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.033s
minio contract: PASS
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go| index 82bd519a2..5a16b6ff0 100644 |
| --- a/internal/console/boxselect.go |
| +++ b/internal/console/boxselect.go |
| @@ -102,7 +102,8 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string |
| } |
| if floor != nil { |
| prevOK := f.PrevRelease == "none" || f.PrevAllowedFor == floor.MinRelease |
| - if f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0 || f.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| + rootFloorRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0) && (f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0) |
| + if rootFloorRefused || f.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| sel.FloorOK = false |
| } |
| } |
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go| index 21680f0f8..f91ec18b5 100644 |
| --- a/internal/console/boxselect_test.go |
| +++ b/internal/console/boxselect_test.go |
| @@ -104,7 +104,6 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes |
| "hibernation_configured": true, "serves_shared_pool": true, "accepts_inbound": true, |
| "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{}, |
| "moves_protocol": moves.ProtocolVersion, "prev_release": "none", |
| - "moves_floor_state": "accepted", "moves_floor_generation": 1, |
| } |
| if b.Protocol != 0 { |
| m["moves_protocol"] = b.Protocol |
| @@ -114,8 +113,6 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes |
| } |
| if floor != nil && !b.FloorUnarmed { |
| m["moves_floor_min_release"] = floor.MinRelease |
| - m["moves_floor_state"] = "accepted" |
| - m["moves_floor_generation"] = 1 |
| } |
| if b.PrevRelease != "" { |
| m["prev_release"] = b.PrevRelease |
| @@ -302,3 +299,34 @@ func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +func TestPlan14MoveSelectionMixedRootFloors(t *testing.T) { |
| + for _, state := range []string{"", "quarantined", "accepted"} { |
| + t.Run(state, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.rel.Put(t.Context(), moves.FloorKey, []byte(`{"min":1,"min_release":"v0.23.0"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + key := edgesFilePrefix + "uk-box.json" |
| + raw, tag, err := fx.rel.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var body map[string]any |
| + if err := json.Unmarshal(raw, &body); err != nil { |
| + t.Fatal(err) |
| + } |
| + body["moves_floor_generation"] = 1 |
| + body["moves_floor_state"] = state |
| + raw, _ = json.Marshal(body) |
| + if _, err := fx.rel.Put(t.Context(), key, raw, tag); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := fx.srv.selectMoveBoxes(t.Context(), "acme.go.tito.io", "uk", "ie", "bab/titogo") |
| + if err != nil || got.Source != "ie-box" || got.Target != "uk-box" || got.FloorOK != (state == "accepted") { |
| + t.Fatal(got, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/plan14_root_floor_test.go b/internal/console/plan14_root_floor_test.go| index 7e2e22024..0d8bfce93 100644 |
| --- a/internal/console/plan14_root_floor_test.go |
| +++ b/internal/console/plan14_root_floor_test.go |
| @@ -24,6 +24,8 @@ func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) { |
| if err := json.Unmarshal(raw, &m); err != nil { |
| t.Fatal(err) |
| } |
| + m["moves_floor_state"] = "accepted" |
| + m["moves_floor_generation"] = 1 |
| switch state { |
| case "missing": |
| delete(m, "moves_floor_state") |
diff --git a/internal/host/claimjournal_migration.go b/internal/host/claimjournal_migration.go| index 98f16b82f..117e1df11 100644 |
| --- a/internal/host/claimjournal_migration.go |
| +++ b/internal/host/claimjournal_migration.go |
| @@ -56,7 +56,7 @@ func RunSystemJournalMigrate(args []string) { |
| provider = ProductionClaimOwnerProvider{} |
| } |
| r := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: dataDir(), OptDir: release.OptDir(), UnitsDir: "/etc/systemd/system"} |
| - if err = migrateClaimJournals(ctx, claimStore(ctx), r, provider); err != nil { |
| + if err = migrateClaimJournalsWith(ctx, claimStore(ctx), r, provider, true); err != nil { |
| log.Fatal(err) |
| } |
| } |
| @@ -64,6 +64,10 @@ func RunSystemJournalMigrate(args []string) { |
| // migrateClaimJournals opens only what the import needs: the verified actor, |
| // root's journal and the retirement directory for the legacy stop proofs. |
| func migrateClaimJournals(ctx context.Context, store release.Store, r ClaimRuntimeResources, provider ClaimOwnerProvider) (resultErr error) { |
| + return migrateClaimJournalsWith(ctx, store, r, provider, false) |
| +} |
| + |
| +func migrateClaimJournalsWith(ctx context.Context, store release.Store, r ClaimRuntimeResources, provider ClaimOwnerProvider, census bool) (resultErr error) { |
| if store == nil || provider == nil || r.Mode != "instance-control" || !filepath.IsAbs(r.DataDir) { |
| return hostwrite.ErrUnavailable |
| } |
| @@ -91,6 +95,9 @@ func migrateClaimJournals(ctx context.Context, store release.Store, r ClaimRunti |
| if err != nil { |
| return err |
| } |
| + if census { |
| + return migrateRootClaimAuthority(ctx, store, r, owners, actor, journals, launches) |
| + } |
| return journals.MigrateLegacy(ctx, filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, quiesceLegacyClaimJournals, legacyJournalProof(store, owners.RetirementAuthority.Directory), recoverLegacyClaimEdgeMask) |
| } |
diff --git a/internal/host/guardexec.go b/internal/host/guardexec.go| index ddc2268ef..1f9f6dd64 100644 |
| --- a/internal/host/guardexec.go |
| +++ b/internal/host/guardexec.go |
| @@ -85,7 +85,7 @@ func RunSystemGuardExec(args []string) { |
| default: |
| log.Fatal(hostfloor.ErrFloor) |
| } |
| - if component == "router" { |
| + if component == "router" && len(args) > 1 && args[0] == "system" { |
| id, required, e := rootMovesFloorIdentity() |
| if e != nil { |
| log.Fatal(e) |
diff --git a/internal/host/guardexec_edge_root_linux_test.go b/internal/host/guardexec_edge_root_linux_test.go| new file mode 100644 |
| index 000000000..d59638c8a |
| --- /dev/null |
| +++ b/internal/host/guardexec_edge_root_linux_test.go |
| @@ -0,0 +1,72 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +func TestPlan14GuardExecEdgeRootRunner(t *testing.T) { |
| + if os.Getenv("PLAN14_P3_EDGE_CHILD") == "1" { |
| + RunSystemGuardExec([]string{"edge"}) |
| + t.Fatal("exec returned") |
| + } |
| + if err := os.Mkdir("/var/lib/carlos-claims", 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll("/var/lib/carlos-claims") }) |
| + dir := generationRootRunnerDir(t) |
| + self, err := os.ReadFile(os.Args[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + helper := filepath.Join(dir, "runner.test") |
| + if err := os.WriteFile(helper, self, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, _, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_OPT_DIR", dir) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy") |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + guard := filepath.Join(dir, "system", "guard") |
| + if err := os.MkdirAll(guard, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "deploy", Mode: "unpublished"}) |
| + if err := os.WriteFile(filepath.Join(guard, "policy.json"), raw, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + entry, err := os.ReadFile("/usr/bin/echo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(systemBinaryPath(dir), entry, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, floor := range []bool{false, true} { |
| + if floor { |
| + if err := os.MkdirAll(filepath.Join(guard, "moves", "private"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cmd := exec.CommandContext(t.Context(), helper, "-test.run=^TestPlan14GuardExecEdgeRootRunner$") |
| + cmd.Env = append(os.Environ(), "PLAN14_P3_EDGE_CHILD=1") |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: 65534, Gid: 65534}} |
| + out, err := cmd.CombinedOutput() |
| + if err != nil || !strings.Contains(string(out), "agent") { |
| + t.Fatalf("floor=%v: %v %s", floor, err, out) |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_root_dispatch_test.go b/internal/host/plan14_root_dispatch_test.go| new file mode 100644 |
| index 000000000..12c8f34a3 |
| --- /dev/null |
| +++ b/internal/host/plan14_root_dispatch_test.go |
| @@ -0,0 +1,56 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// These are the protected attribution/readiness fields consumed by the |
| +// production adoption pass. E3 settings and E4 consent join this matrix when |
| +// their owning track lands; this test claims no whole-registry conversion. |
| +func testPlan14RootProtectedDispatch(t *testing.T) { |
| + cases := map[string]func(*registry.Route){ |
| + "canonical": func(*registry.Route) {}, |
| + "account": func(r *registry.Route) { r.Account = "bap" }, |
| + "app": func(r *registry.Route) { r.App = "other" }, |
| + "unit": func(r *registry.Route) { r.Unit = "other.service" }, |
| + "socket": func(r *registry.Route) { r.Socket = "/run/other.sock" }, |
| + "addr": func(r *registry.Route) { r.Addr = "127.0.0.1:1" }, |
| + "addrs": func(r *registry.Route) { r.Addrs = "127.0.0.1:1" }, |
| + "health": func(r *registry.Route) { r.Health = "null" }, |
| + "kind": func(r *registry.Route) { r.Kind = "static" }, |
| + "backing": func(r *registry.Route) { r.Backing = "exec" }, |
| + "channel": func(r *registry.Route) { r.Channel = "forged" }, |
| + "removed": nil, |
| + } |
| + for name, mutate := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + e, rt, u, c, pub := plan14AdoptionFixture(t) |
| + h := seedHostRootHistory(t, rt) |
| + if mutate == nil { |
| + if err := e.root.reg.Delete(rt.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + mutate(&rt) |
| + if err := e.root.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| + err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, release.BakeDir(e.bake), release.WithRootHistory(boundRootHistory(h, c.resolve, e.root.obs)), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| + if name == "canonical" { |
| + if err != nil || len(u.effects) != 1 { |
| + t.Fatal("canonical dispatch", err, u.effects) |
| + } |
| + } else if len(u.effects) != 0 { |
| + t.Fatal("registry mutation dispatched", name, err, u.effects) |
| + } |
| + }) |
| + } |
| + t.Run("observation-and-settle", TestPlan14ProofObservationRejectsForgedRegistry) |
| + t.Run("held-publication", TestPlan14HeldRevalidatesCanonicalBindingUnderGuard) |
| +} |
diff --git a/internal/host/plan14_root_migration_test.go b/internal/host/plan14_root_migration_test.go| new file mode 100644 |
| index 000000000..c2045b0ed |
| --- /dev/null |
| +++ b/internal/host/plan14_root_migration_test.go |
| @@ -0,0 +1,220 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func migrationFixture(t *testing.T) (rootMigration, *rootMigrationSnapshot, *int) { |
| + t.Helper() |
| + state := &rollbackDisk{dir: t.TempDir()} |
| + exec := &rollbackDisk{dir: t.TempDir()} |
| + writes := 0 |
| + snapshot := &rootMigrationSnapshot{Complete: true, DirectoryRevision: 7, Exclusion: "signed collecting reservation", Report: hostops.CensusReport{Box: "box", Enrollment: "en", BootID: "boot", Reservation: "offline", DirectoryRevision: 7, CapturedAt: time.Now(), Complete: true, Installations: []hostops.CensusInstallation{}, Seeds: &hostops.CensusSeeds{Journaled: []string{}, Gated: []string{}}}, Items: []rootMigrationItem{}} |
| + m := rootMigration{state: state, execution: exec, identity: rootMigrationIdentity{Deployment: "deploy", Box: "box", Enrollment: "en", Generation: 1}, quiesce: func(context.Context) (func(), error) { return func() {}, nil }, observe: func(context.Context) (rootMigrationSnapshot, error) { return *snapshot, nil }, deny: func(context.Context, rootMigrationItem) error { writes++; return nil }, recover: func(context.Context, rootMigrationItem) error { writes++; return nil }, ready: func(context.Context, rootMigrationSnapshot) error { return nil }, publish: func(context.Context, hostops.CensusReport) error { writes++; return nil }} |
| + return m, snapshot, &writes |
| +} |
| + |
| +func TestPlan14RootMigrationRefusesBeforeEffects(t *testing.T) { |
| + for _, kind := range []string{"preparing", "ready", "cutting", "aborting", "reverting", "landing", "committed", "rolling-back", "stuck", "future", "admitted-before-launch", "probing", "probed", "root-journal", "reservation", "door", "late-move", "incomplete", "seed-journal", "seed-gate", "seed-unknown"} { |
| + t.Run(kind, func(t *testing.T) { |
| + m, s, effects := migrationFixture(t) |
| + switch kind { |
| + case "root-journal": |
| + s.MoveJournals = 1 |
| + case "reservation": |
| + s.MoveReservations = 1 |
| + case "door": |
| + s.Exclusion = "" |
| + case "late-move": |
| + n := 0 |
| + m.observe = func(context.Context) (rootMigrationSnapshot, error) { |
| + n++ |
| + if n == 2 { |
| + s.Claims = []hostclaim.Claim{{Op: hostclaim.Op{ID: "late"}}} |
| + } |
| + return *s, nil |
| + } |
| + case "incomplete": |
| + s.Complete = false |
| + case "seed-journal": |
| + s.Report.Seeds.Journaled = []string{"host"} |
| + case "seed-gate": |
| + s.Report.Seeds.Gated = []string{"host"} |
| + case "seed-unknown": |
| + s.Report.Seeds = nil |
| + default: |
| + s.Records = []provision.Record{{Move: &provision.MoveState{State: kind, LandingPhase: kind}}} |
| + } |
| + before, _ := json.Marshal(s) |
| + if m.run(t.Context()) == nil { |
| + t.Fatal("migration accepted active work") |
| + } |
| + after, _ := json.Marshal(s) |
| + if kind != "late-move" && string(before) != string(after) { |
| + t.Fatal("migration changed input") |
| + } |
| + names, _ := m.state.Names() |
| + for _, n := range names { |
| + if n != "migration.lock" { |
| + t.Fatal("write before preflight", n) |
| + } |
| + } |
| + if *effects != 0 { |
| + t.Fatal("effects before preflight", *effects) |
| + } |
| + s.MoveJournals = 0 |
| + s.MoveReservations = 0 |
| + s.Exclusion = "signed collecting reservation" |
| + s.Complete = true |
| + s.Records = nil |
| + s.Claims = nil |
| + s.Report.Seeds = &hostops.CensusSeeds{} |
| + m.observe = func(context.Context) (rootMigrationSnapshot, error) { return *s, nil } |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal("normal move close did not unblock", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootMigrationCrashResume(t *testing.T) { |
| + for _, after := range []bool{false, true} { |
| + for cut := 1; cut <= 7; cut++ { |
| + t.Run(string(rune('a'+cut))+map[bool]string{true: "-after", false: "-before"}[after], func(t *testing.T) { |
| + m, s, _ := migrationFixture(t) |
| + s.Items = []rootMigrationItem{{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "apps/bab/app/instances/a.example.json", Box: "box", Enrollment: "en", Migration: 1}, Session: 9, ClaimETag: "claim"}} |
| + n := 0 |
| + original := m.state |
| + m.state = &floorFaultStore{Store: m.state, n: &n, fail: cut, after: after} |
| + err := m.run(t.Context()) |
| + if err == nil { |
| + t.Fatal("missing crash", cut, n) |
| + } |
| + m.state = original |
| + // DataDir is deliberately absent on replay; all retained work is root state. |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + c, err := (rootparticipation.Ledger{State: m.execution}).Census() |
| + if err != nil || c.Migration != 1 { |
| + t.Fatal(c, err) |
| + } |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal("idempotence", err) |
| + } |
| + m.identity.Enrollment = "other" |
| + if m.run(t.Context()) == nil { |
| + t.Fatal("foreign enrollment reused migration") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootMigrationRetainsSessionDeny(t *testing.T) { |
| + state := &rollbackDisk{dir: t.TempDir()} |
| + root := hostguard.NewRootAuthority(state) |
| + in := hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1} |
| + if err := root.RetainMigrationDeny(t.Context(), in, 9); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := root.RetainMigrationDeny(t.Context(), in, 8); err != nil { |
| + t.Fatal(err) |
| + } |
| + root = hostguard.NewRootAuthority(state) |
| + view, err := root.View(t.Context(), in.Host, hostguard.Captured{Incarnation: "inc", Stamp: 1}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(view.MigrationDenies) != 1 || view.MigrationDenies[0].Session != 9 { |
| + t.Fatal("lost deny", view) |
| + } |
| +} |
| + |
| +var _ rootstate.Store = (*rollbackDisk)(nil) |
| +var _ = errors.Is |
| +var _ = os.ErrNotExist |
| + |
| +func TestPlan14RootMigrationPublishesOnlyDurableCompletion(t *testing.T) { |
| + m, _, _ := migrationFixture(t) |
| + m.publish = func(ctx context.Context, _ hostops.CensusReport) error { |
| + raw, err := m.state.Read("migration.json") |
| + if err != nil { |
| + return err |
| + } |
| + var r rootMigrationRecord |
| + if json.Unmarshal(raw, &r) != nil || r.Phase != "complete" { |
| + t.Fatal("report preceded local completion") |
| + } |
| + if _, err := (rootparticipation.Ledger{State: m.execution}).Census(); err != nil { |
| + t.Fatal("report preceded census", err) |
| + } |
| + return nil |
| + } |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14RootMigrationPreparedBlocksGrants(t *testing.T) { |
| + m, _, _ := migrationFixture(t) |
| + n := 0 |
| + m.state = &floorFaultStore{Store: m.state, n: &n, fail: 1, after: true} |
| + if err := m.run(t.Context()); err == nil { |
| + t.Fatal("prepare interruption was not reached") |
| + } |
| + raw, err := m.execution.Read("census.json") |
| + if err != nil { |
| + t.Fatal("visible prepared without root admission barrier", err) |
| + } |
| + var c rootparticipation.Census |
| + if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { |
| + t.Fatal("prepared permits root grants", c) |
| + } |
| +} |
| + |
| +func TestPlan14RootMigrationCensusCrashResume(t *testing.T) { |
| + for _, after := range []bool{false, true} { |
| + for _, cut := range []int{1, 2} { |
| + m, _, published := migrationFixture(t) |
| + execution := m.execution |
| + n := 0 |
| + m.execution = &floorFaultStore{Store: execution, n: &n, fail: cut, after: after} |
| + if err := m.run(t.Context()); err == nil { |
| + t.Fatal("census fault not reached", cut, after) |
| + } |
| + if *published != 0 { |
| + t.Fatal("published despite failed census sync") |
| + } |
| + raw, err := execution.Read("census.json") |
| + if err == nil { |
| + var c rootparticipation.Census |
| + if rootparticipation.Decode(raw, &c) != nil || c.Complete || c.Pending != "migration" { |
| + t.Fatal("failed census granted", cut, after, c) |
| + } |
| + } else if cut != 1 || after || !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + m.execution = execution |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal("census retry", cut, after, err) |
| + } |
| + c, err := (rootparticipation.Ledger{State: execution}).Census() |
| + if err != nil || !c.Complete || *published != 1 { |
| + t.Fatal("retry incomplete", c, err, *published) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/plan14_root_payload_families_linux_test.go b/internal/host/plan14_root_payload_families_linux_test.go| new file mode 100644 |
| index 000000000..6240bb9c8 |
| --- /dev/null |
| +++ b/internal/host/plan14_root_payload_families_linux_test.go |
| @@ -0,0 +1,18 @@ |
| +package host |
| + |
| +import "testing" |
| + |
| +func TestPlan14RootPayloadReadFamilies(t *testing.T) { |
| + t.Run("generation-payload", TestPlan14GenerationPayloadCompleteness) |
| + t.Run("installation-requirements", TestPlan14InventoryRequirementsRegistration) |
| + t.Run("kernel-descriptors", TestPlan14GenerationKernelOwnDescriptors) |
| + t.Run("kernel-deleted-files", TestPlan14GenerationKernelReferences) |
| + t.Run("retained-file-inodes", TestPlan14GenerationKernelRetainedFiles) |
| + t.Run("inventory-process-recovery", TestPlan14InventoryProcessCrash) |
| +} |
| + |
| +func TestPlan14RegistryCannotSelectRollbackOrUnpin(t *testing.T) { |
| + t.Run("pending-current-exact-retirement", TestPlan14GenerationRetirementReleasesExactPin) |
| + t.Run("retirement-root-binding", TestPlan14GenerationRetirementRootBinding) |
| + t.Run("root-history-denials-readiness", TestPlan14RootHistoryProductionPass) |
| +} |
diff --git a/internal/host/rootinstallation_test.go b/internal/host/rootinstallation_test.go| index 3e65052dd..24ada2326 100644 |
| --- a/internal/host/rootinstallation_test.go |
| +++ b/internal/host/rootinstallation_test.go |
| @@ -14,6 +14,7 @@ import ( |
| ) |
| func TestPlan14RootDispatchRejectsRegistryMutation(t *testing.T) { |
| + t.Run("protected-passes", testPlan14RootProtectedDispatch) |
| for _, attack := range []string{"none", "account", "app", "unit", "backing", "delete", "record owner", "claim owner", "missing installation"} { |
| t.Run(attack, func(t *testing.T) { |
| store := release.NewDirStore(t.TempDir()) |
diff --git a/internal/host/rootmigration.go b/internal/host/rootmigration.go| new file mode 100644 |
| index 000000000..783f9b4b5 |
| --- /dev/null |
| +++ b/internal/host/rootmigration.go |
| @@ -0,0 +1,232 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rootMigrationIdentity struct { |
| + Deployment, Box, Enrollment string |
| + Generation uint64 |
| +} |
| +type rootMigrationItem struct { |
| + Installation hostguard.RootInstallation |
| + Session uint64 |
| + ClaimETag string |
| +} |
| +type rootMigrationSnapshot struct { |
| + Complete bool |
| + DirectoryRevision uint64 |
| + Exclusion string |
| + Records []provision.Record |
| + Claims []hostclaim.Claim |
| + Items []rootMigrationItem |
| + MoveJournals, MoveReservations int |
| + Report hostops.CensusReport |
| +} |
| +type rootMigrationRecord struct { |
| + Schema int |
| + Identity rootMigrationIdentity |
| + Phase string |
| + Snapshot rootMigrationSnapshot |
| +} |
| +type rootMigration struct { |
| + prepareRecovery func(context.Context) error |
| + state, execution rootstate.Store |
| + identity rootMigrationIdentity |
| + quiesce func(context.Context) (func(), error) |
| + observe func(context.Context) (rootMigrationSnapshot, error) |
| + deny, recover func(context.Context, rootMigrationItem) error |
| + ready func(context.Context, rootMigrationSnapshot) error |
| + publish func(context.Context, hostops.CensusReport) error |
| +} |
| + |
| +func (s rootMigrationSnapshot) preflight(id rootMigrationIdentity) error { |
| + if s.Exclusion == "" || s.DirectoryRevision == 0 || s.MoveJournals != 0 || s.MoveReservations != 0 || s.Items == nil || !s.Report.Complete || s.Report.Box != id.Box || s.Report.Enrollment != id.Enrollment || s.Report.DirectoryRevision != s.DirectoryRevision || s.Report.Reservation == "" || s.Report.BootID == "" || s.Report.CapturedAt.IsZero() || s.Report.Installations == nil || s.Report.Seeds == nil || len(s.Report.Seeds.Journaled) > 0 || len(s.Report.Seeds.Gated) > 0 { |
| + return errRootMigrationBusy |
| + } |
| + for _, rec := range s.Records { |
| + if rec.Seed.Unfinished() { |
| + return errRootMigrationBusy |
| + } |
| + } |
| + seen := map[string]bool{} |
| + for _, item := range s.Items { |
| + in := item.Installation |
| + if in.Host == "" || seen[in.Host] || in.Incarnation == "" || in.Stamp == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box != id.Box || in.Enrollment != id.Enrollment || in.Migration != id.Generation || item.Session == 0 || item.ClaimETag == "" { |
| + return errRootMigrationBusy |
| + } |
| + seen[in.Host] = true |
| + } |
| + return rootMigrationPreflight(s.Complete, s.Records, s.Claims) |
| +} |
| +func (s rootMigrationSnapshot) fingerprint() string { |
| + // Wall time is report metadata, not census membership or exclusion identity. |
| + s.Report.CapturedAt = time.Time{} |
| + s.Report.BootID = "" |
| + s.Items = append([]rootMigrationItem(nil), s.Items...) |
| + for i := range s.Items { |
| + s.Items[i].Installation.InventoryRevision = 0 |
| + s.Items[i].Installation.Artifacts = hostguard.InstallationArtifacts{} |
| + } |
| + return generationInventoryDigest(s) |
| +} |
| +func (m rootMigration) write(name string, v any) error { |
| + raw, err := json.Marshal(v) |
| + if err != nil { |
| + return err |
| + } |
| + return m.state.Write(name, raw) |
| +} |
| +func (m rootMigration) census(complete bool) error { |
| + c := rootparticipation.Census{Schema: 1, Box: m.identity.Box, Enrollment: m.identity.Enrollment, Migration: m.identity.Generation, Revision: 1, LaunchRevision: 1, Complete: complete} |
| + if !complete { |
| + c.Pending = "migration" |
| + } |
| + raw, _ := json.Marshal(c) |
| + if err := m.execution.Write("census.json", raw); err != nil { |
| + if complete { |
| + c.Complete = false |
| + c.Pending = "migration" |
| + raw, _ = json.Marshal(c) |
| + return errors.Join(err, m.execution.Write("census.json", raw)) |
| + } |
| + return err |
| + } |
| + return nil |
| +} |
| +func (m rootMigration) run(ctx context.Context) error { |
| + if m.state == nil || m.execution == nil || m.identity.Deployment == "" || m.identity.Box == "" || m.identity.Enrollment == "" || m.identity.Generation == 0 || m.quiesce == nil || m.observe == nil || m.deny == nil || m.recover == nil || m.ready == nil || m.publish == nil { |
| + return errRootMigrationBusy |
| + } |
| + unlock, err := m.state.Lock(ctx, "migration.lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + var saved rootMigrationRecord |
| + raw, err := m.state.Read("migration.json") |
| + exists := err == nil |
| + if exists { |
| + if rootparticipation.Decode(raw, &saved) != nil || saved.Schema != 1 || saved.Identity != m.identity || saved.Snapshot.preflight(m.identity) != nil { |
| + return errRootMigrationBusy |
| + } |
| + switch saved.Phase { |
| + case "prepared", "fenced", "recovered", "complete": |
| + default: |
| + return errRootMigrationBusy |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + release, err := m.quiesce(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + defer release() |
| + if exists && saved.Phase == "complete" { |
| + if err := m.ready(ctx, saved.Snapshot); err != nil { |
| + return err |
| + } |
| + if err := m.write("migration.json", saved); err != nil { |
| + return err |
| + } |
| + if err := m.census(true); err != nil { |
| + return err |
| + } |
| + return m.publish(ctx, saved.Snapshot.Report) |
| + } |
| + observe := func() (rootMigrationSnapshot, error) { |
| + s, e := m.observe(ctx) |
| + if e == nil { |
| + e = s.preflight(m.identity) |
| + } |
| + return s, e |
| + } |
| + first, err := observe() |
| + if err != nil { |
| + return err |
| + } |
| + fresh, err := observe() |
| + if err != nil { |
| + return err |
| + } |
| + if first.fingerprint() != fresh.fingerprint() { |
| + return errRootMigrationBusy |
| + } |
| + if exists && saved.Snapshot.fingerprint() != fresh.fingerprint() { |
| + return errRootMigrationBusy |
| + } |
| + if !exists { |
| + saved = rootMigrationRecord{Schema: 1, Identity: m.identity, Snapshot: first} |
| + } |
| + if err := m.census(false); err != nil { |
| + return err |
| + } |
| + saved.Phase = "prepared" |
| + if err := m.write("migration.json", saved); err != nil { |
| + return err |
| + } |
| + for _, item := range saved.Snapshot.Items { |
| + if err := m.deny(ctx, item); err != nil { |
| + return err |
| + } |
| + if err := m.write("deny-"+item.Installation.Host+".json", item); err != nil { |
| + return err |
| + } |
| + } |
| + saved.Phase = "fenced" |
| + if err := m.write("migration.json", saved); err != nil { |
| + return err |
| + } |
| + if m.prepareRecovery != nil { |
| + if err := m.prepareRecovery(ctx); err != nil { |
| + return err |
| + } |
| + } |
| + for _, item := range saved.Snapshot.Items { |
| + // Reverify even a visible receipt on retry: no record can manufacture stop |
| + // evidence, and the recovery adapter preserves the original signed time. |
| + if err := m.recover(ctx, item); err != nil { |
| + return err |
| + } |
| + if err := m.write("receipt-"+item.Installation.Host+".json", item); err != nil { |
| + return err |
| + } |
| + } |
| + saved.Phase = "recovered" |
| + if err := m.write("migration.json", saved); err != nil { |
| + return err |
| + } |
| + fresh, err = observe() |
| + if err != nil { |
| + return err |
| + } |
| + if saved.Snapshot.fingerprint() != fresh.fingerprint() { |
| + return errRootMigrationBusy |
| + } |
| + if err := m.ready(ctx, saved.Snapshot); err != nil { |
| + return err |
| + } |
| + if err := m.write("report.json", saved.Snapshot.Report); err != nil { |
| + return err |
| + } |
| + saved.Phase = "complete" |
| + if err := m.write("migration.json", saved); err != nil { |
| + return err |
| + } |
| + if err := m.census(true); err != nil { |
| + return err |
| + } |
| + return m.publish(ctx, saved.Snapshot.Report) |
| +} |
diff --git a/internal/host/rootmigration_bootstrap_root_linux_test.go b/internal/host/rootmigration_bootstrap_root_linux_test.go| new file mode 100644 |
| index 000000000..23f13c1fe |
| --- /dev/null |
| +++ b/internal/host/rootmigration_bootstrap_root_linux_test.go |
| @@ -0,0 +1,161 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) { |
| + if os.Getenv("PLAN14_P3_BOOTSTRAP_CHILD") == "" { |
| + unit := "/usr/lib/systemd/system/carlos-edge.service" |
| + for _, p := range []string{unit, "/etc/systemd/system/carlos-edge.service", "/run/systemd/system/carlos-edge.service"} { |
| + if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| + t.Fatal("existing unit", p, err) |
| + } |
| + } |
| + if err := os.WriteFile(unit, []byte("[Service]\nType=oneshot\nExecStart=/usr/bin/true\n[Install]\nWantedBy=multi-user.target\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + exec.Command("systemctl", "unmask", "--runtime", "carlos-edge.service").Run() |
| + os.Remove(unit) |
| + exec.Command("systemctl", "daemon-reload").Run() |
| + }) |
| + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| + t.Fatal(err, string(out)) |
| + } |
| + if out, err := exec.Command("systemctl", "mask", "--runtime", "carlos-edge.service").CombinedOutput(); err != nil { |
| + t.Fatal(err, string(out)) |
| + } |
| + |
| + oldOwner := claimQuiesceOwner |
| + claimQuiesceOwner = func() (int, int, error) { return 65534, 65534, nil } |
| + defer func() { claimQuiesceOwner = oldOwner }() |
| + release, err := quiesceLegacyClaimJournals(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer release() |
| + // The existing generation census supports a controlled all-root namespace; |
| + // E owns the wider running-tenant/foreign-namespace production integration. |
| + cmd := exec.CommandContext(t.Context(), "unshare", "--mount", "--pid", "--fork", "--mount-proc", os.Args[0], "-test.run=^TestPlan14RootMigrationBootstrapRootRunner$", "-test.v") |
| + cmd.Env = append(os.Environ(), "PLAN14_P3_BOOTSTRAP_CHILD=1") |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatal(err, string(out)) |
| + } else { |
| + t.Log(string(out)) |
| + } |
| + return |
| + } |
| + rootMigrationQuiesce = func(context.Context) (func(), error) { return func() {}, nil } |
| + for _, path := range []string{"/var/lib/carlos-claims", "/var/lib/carlos-release"} { |
| + if err := os.Mkdir(path, 0700); err != nil { |
| + t.Fatal("existing authority", path, err) |
| + } |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll(path); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + } |
| + scratch := generationRootRunnerDir(t) |
| + t.Setenv("TMPDIR", scratch) |
| + store, r, owners, _ := fix4JournalFixture(t) |
| + // Use the production private authority, replacing only the older fixture's opener. |
| + openClaimRootAuthority = func() (rootstate.Store, error) { |
| + return rootstate.Open("/var/lib/carlos-claims/authority/fences", true, false) |
| + } |
| + actor, err := owners.Identity.Verify(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for path := r.OptDir; path != scratch; path = filepath.Dir(path) { |
| + if err := os.Chmod(path, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Mkdir(filepath.Join(r.OptDir, "versions"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_OPT_DIR", r.OptDir) |
| + t.Setenv("CARLOS_DATA_DIR", r.DataDir) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", actor.ControlBucket) |
| + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey))) |
| + plan := hostops.BackfillPlan{V: 1, ID: "migration", Phase: "collecting", DirectoryRevision: 1, CreatedAt: time.Now(), Hosts: map[string]hostops.BackfillHost{}} |
| + raw, err := release.SignRootEnvelope(release.RootSigner(key), plan) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(t.Context(), hostops.BackfillKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + id := rootFloorIdentity{Deployment: actor.ControlBucket, Box: actor.Box, Enrollment: actor.Enrollment} |
| + floors, err := openRootMovesFloors(r.OptDir, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer floors.close() |
| + floor := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| + if err := floors.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &floor, Source: "enrolled root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, floors)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rootFloorRunnerBinary(t, r.OptDir, systemBinaryPath(r.OptDir), "v2.0.0", key) |
| + rootFloorRunnerBinary(t, r.OptDir, filepath.Join(systemGuardDir(r.OptDir), "carlos"), "v2.0.0", key) |
| + journal, err := hostbox.OpenLaunchJournal("/var/lib/carlos-claims/journals.db") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer journal.Close() |
| + service, err := hostbox.NewJournalService(journal) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + oldOwner := claimQuiesceOwner |
| + claimQuiesceOwner = func() (int, int, error) { return 65534, 65534, nil } |
| + defer func() { claimQuiesceOwner = oldOwner }() |
| + if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err != nil { |
| + t.Fatal("production bootstrap", err) |
| + } |
| + state, err := rootstate.Open(rootparticipation.Namespace, false, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer state.Close() |
| + c, err := (rootparticipation.Ledger{State: state}).Census() |
| + if err != nil || !c.Complete || c.Box != actor.Box || c.NeverEnrolled { |
| + t.Fatal("invalid root census", c, err) |
| + } |
| + keys, err := store.List(t.Context(), "control/host-census/") |
| + if err != nil || len(keys) != 1 { |
| + t.Fatal(keys, err) |
| + } |
| + body, _, err := store.Get(t.Context(), keys[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var envelope hostops.CensusEnvelope |
| + if json.Unmarshal(body, &envelope) != nil || envelope.Report.Seeds == nil { |
| + t.Fatal("no Seeds in actual census") |
| + } |
| + if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err != nil { |
| + t.Fatal("completed migration replay", err) |
| + } |
| +} |
diff --git a/internal/host/rootmigration_census.go b/internal/host/rootmigration_census.go| new file mode 100644 |
| index 000000000..135e98823 |
| --- /dev/null |
| +++ b/internal/host/rootmigration_census.go |
| @@ -0,0 +1,223 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type rootMigrationCollector struct { |
| + suspects func(context.Context) ([]string, error) |
| + retirements func(context.Context) ([]hostbox.RetirementJournal, error) |
| + store release.Store |
| + directory hostactors.Directory |
| + identity rootMigrationIdentity |
| + seeds hostops.SeedStateReader |
| + installations func(context.Context) ([]hostguard.FenceView, error) |
| + launches func(context.Context) ([]hostbox.LaunchInventory, error) |
| + reservations func(context.Context) ([]release.GenerationReservation, error) |
| + boot func() (string, error) |
| +} |
| + |
| +func (c rootMigrationCollector) collect(ctx context.Context) (rootMigrationSnapshot, error) { |
| + s := rootMigrationSnapshot{Items: []rootMigrationItem{}} |
| + if c.store == nil || c.seeds == nil || c.installations == nil || c.launches == nil || c.reservations == nil || c.boot == nil { |
| + return s, errRootMigrationBusy |
| + } |
| + raw, tag, err := c.store.Get(ctx, hostops.BackfillKey) |
| + if err != nil { |
| + return s, err |
| + } |
| + var plan hostops.BackfillPlan |
| + if tag == "" || release.VerifyRootEnvelope(c.directory.PublicKey, raw, &plan) != nil || plan.V != 1 || plan.ID == "" || plan.Phase != "collecting" || plan.Hosts == nil || plan.CreatedAt.IsZero() { |
| + return s, errRootMigrationBusy |
| + } |
| + roster, err := c.directory.Read(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + box, ok := roster.Roster.Boxes[c.identity.Box] |
| + if !ok || box.Enrollment != c.identity.Enrollment || plan.DirectoryRevision != roster.Roster.Revision { |
| + return s, errRootMigrationBusy |
| + } |
| + s.DirectoryRevision = roster.Roster.Revision |
| + s.Exclusion = tag + "@" + generationInventoryDigest(plan) |
| + boot, err := c.boot() |
| + if err != nil || boot == "" { |
| + return s, errRootMigrationBusy |
| + } |
| + s.Report = hostops.CensusReport{Box: c.identity.Box, Enrollment: c.identity.Enrollment, BootID: boot, Reservation: plan.ID, DirectoryRevision: s.DirectoryRevision, CapturedAt: time.Now().UTC(), Installations: []hostops.CensusInstallation{}} |
| + // These service-owned fields only refuse; they establish no installation. |
| + s.Report.Seeds, err = hostops.CollectCensusSeeds(c.seeds) |
| + if err != nil { |
| + return s, err |
| + } |
| + records := map[string]hostops.Record{} |
| + apps, err := hostops.Apps(ctx, c.store) |
| + if err != nil { |
| + return s, err |
| + } |
| + for _, app := range apps { |
| + rows, err := hostops.Records(ctx, c.store, app.Account, app.Name) |
| + if err != nil { |
| + return s, err |
| + } |
| + for _, r := range rows { |
| + if _, ok := records[r.Holder.Host]; ok { |
| + return s, errRootMigrationBusy |
| + } |
| + frozen, ok := plan.Hosts[r.Holder.Host] |
| + if !ok || frozen.Key != r.Holder.Key || frozen.ETag != r.ETag { |
| + return s, errRootMigrationBusy |
| + } |
| + records[r.Holder.Host] = r |
| + if r.Holder.Family == "instance" { |
| + var rec provision.Record |
| + if json.Unmarshal(r.Body, &rec) != nil { |
| + return s, errRootMigrationBusy |
| + } |
| + s.Records = append(s.Records, rec) |
| + } |
| + } |
| + } |
| + if len(records) != len(plan.Hosts) { |
| + return s, errRootMigrationBusy |
| + } |
| + keys, err := c.store.List(ctx, "control/hosts/") |
| + if err != nil { |
| + return s, err |
| + } |
| + sort.Strings(keys) |
| + observed := map[string]hostclaim.Observed{} |
| + for _, key := range keys { |
| + if !strings.HasSuffix(key, ".json") || strings.Contains(strings.TrimPrefix(key, "control/hosts/"), "/") { |
| + continue |
| + } |
| + host := strings.TrimSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json") |
| + want, err := hostclaim.Key(host) |
| + if err != nil || key != want { |
| + return s, errRootMigrationBusy |
| + } |
| + o, err := hostclaim.Read(ctx, c.store, host) |
| + if err != nil || o.Absent || o.ETag == "" { |
| + return s, errRootMigrationBusy |
| + } |
| + s.Claims = append(s.Claims, *o.Claim) |
| + observed[host] = o |
| + } |
| + retained, err := c.installations(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + if c.retirements != nil { |
| + retired, err := c.retirements(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + for _, j := range retired { |
| + if !j.Complete || j.Target == nil { |
| + s.MoveJournals++ |
| + } else if _, ok := observed[j.Target.Host]; !ok { |
| + return s, errRootMigrationBusy |
| + } |
| + } |
| + } |
| + if c.suspects != nil { |
| + suspects, err := c.suspects(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + for _, host := range suspects { |
| + if _, ok := observed[host]; !ok { |
| + return s, errRootMigrationBusy |
| + } |
| + } |
| + } |
| + local := map[string]hostguard.RootInstallation{} |
| + for _, v := range retained { |
| + local[v.Host] = v.Installation |
| + } |
| + launches, err := c.launches(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + for _, l := range launches { |
| + if l.Target.Op != "" && !l.WritersClosed { |
| + s.MoveJournals++ |
| + } |
| + if _, ok := local[l.Target.Host]; !ok { |
| + return s, errRootMigrationBusy |
| + } |
| + } |
| + reservations, err := c.reservations(ctx) |
| + if err != nil { |
| + return s, err |
| + } |
| + // Any unfinished reservation must be reconciled through normal generation |
| + // retirement first. A move cannot hide by deleting its record or launch row. |
| + for _, r := range reservations { |
| + if !r.Closed { |
| + s.MoveReservations++ |
| + } |
| + } |
| + names := make([]string, 0, len(observed)) |
| + for host := range observed { |
| + names = append(names, host) |
| + } |
| + sort.Strings(names) |
| + for _, host := range names { |
| + o := observed[host] |
| + h := o.Claim.Holder |
| + _, retained := local[host] |
| + if h == nil { |
| + if retained { |
| + return s, errRootMigrationBusy |
| + } |
| + continue |
| + } |
| + relevant := h.WriterBox == c.identity.Box || o.Claim.Op.Exec == c.identity.Box || retained |
| + for b := range o.Claim.Inventory { |
| + if b == c.identity.Box { |
| + relevant = true |
| + } |
| + } |
| + if !relevant { |
| + continue |
| + } |
| + r, ok := records[host] |
| + if !ok || r.Holder.Key != h.Key || r.Holder.Account != h.Account || r.Holder.App != h.App || h.Stamp == 0 || h.Session == 0 { |
| + return s, errRootMigrationBusy |
| + } |
| + in, exists := local[host] |
| + if exists { |
| + if in.Incarnation != h.Incarnation || in.Stamp != h.Stamp || in.Box != c.identity.Box || in.Enrollment != c.identity.Enrollment || in.Record != h.Key || in.Account != h.Account || in.App != h.App { |
| + return s, errRootMigrationBusy |
| + } |
| + } else { |
| + in = hostguard.RootInstallation{Host: host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: c.identity.Box, Enrollment: c.identity.Enrollment, Migration: c.identity.Generation} |
| + } |
| + s.Items = append(s.Items, rootMigrationItem{Installation: in, Session: h.Session, ClaimETag: o.ETag}) |
| + s.Report.Installations = append(s.Report.Installations, hostops.CensusInstallation{Host: host, Account: h.Account, App: h.App, Family: h.Family, Key: h.Key, Incarnation: h.Incarnation, Stamp: h.Stamp, Local: true}) |
| + delete(local, host) |
| + } |
| + if len(local) != 0 { |
| + return s, errRootMigrationBusy |
| + } |
| + fresh, ftag, err := c.store.Get(ctx, hostops.BackfillKey) |
| + if err != nil || ftag != tag || string(fresh) != string(raw) { |
| + return s, errRootMigrationBusy |
| + } |
| + s.Complete = true |
| + s.Report.Complete = true |
| + return s, nil |
| +} |
diff --git a/internal/host/rootmigration_census_linux_test.go b/internal/host/rootmigration_census_linux_test.go| new file mode 100644 |
| index 000000000..8d167ea4d |
| --- /dev/null |
| +++ b/internal/host/rootmigration_census_linux_test.go |
| @@ -0,0 +1,95 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type migrationSeeds struct { |
| + journal []string |
| + routes []registry.Route |
| +} |
| + |
| +func (s *migrationSeeds) ImportJournalHosts() ([]string, error) { return s.journal, nil } |
| +func (s *migrationSeeds) List() ([]registry.Route, error) { return s.routes, nil } |
| + |
| +func TestPlan14RootMigrationCensusSeeds(t *testing.T) { |
| + store, _, owners, _ := fix4JournalFixture(t) |
| + actor, err := owners.Identity.Verify(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| + plan := hostops.BackfillPlan{V: 1, ID: "migration", Phase: "collecting", DirectoryRevision: 1, CreatedAt: time.Now(), Hosts: map[string]hostops.BackfillHost{}} |
| + raw, err := release.SignRootEnvelope(release.RootSigner(key), plan) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(t.Context(), hostops.BackfillKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + seeds := &migrationSeeds{} |
| + c := rootMigrationCollector{store: store, directory: owners.RetirementAuthority.Directory, identity: rootMigrationIdentity{Deployment: actor.ControlBucket, Box: actor.Box, Enrollment: actor.Enrollment, Generation: 1}, seeds: seeds, installations: func(context.Context) ([]hostguard.FenceView, error) { return nil, nil }, launches: func(context.Context) ([]hostbox.LaunchInventory, error) { return nil, nil }, reservations: func(context.Context) ([]release.GenerationReservation, error) { return nil, nil }, boot: func() (string, error) { return "boot", nil }} |
| + s, err := c.collect(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.preflight(c.identity); err != nil { |
| + t.Fatal(err) |
| + } |
| + if s.Report.Seeds == nil || s.Report.Seeds.Journaled == nil || s.Report.Seeds.Gated == nil { |
| + t.Fatal("missing seed census", s.Report) |
| + } |
| + if err := hostops.PublishCensusReport(t.Context(), store, c.directory, s.Report, owners.EvidenceKey); err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, err := store.List(t.Context(), "control/host-census/") |
| + if err != nil || len(keys) != 1 { |
| + t.Fatal(keys, err) |
| + } |
| + body, _, err := store.Get(t.Context(), keys[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var envelope hostops.CensusEnvelope |
| + if json.Unmarshal(body, &envelope) != nil || envelope.Report.Seeds == nil { |
| + t.Fatal("not a real signed seeds report") |
| + } |
| + for _, kind := range []string{"journal", "gate", "unknown-launch", "reservation"} { |
| + t.Run(kind, func(t *testing.T) { |
| + cc := c |
| + defer func() { seeds.journal = nil; seeds.routes = nil }() |
| + switch kind { |
| + case "journal": |
| + seeds.journal = []string{"a.example"} |
| + case "gate": |
| + seeds.routes = []registry.Route{{Host: "a.example", ImportGate: "closed"}} |
| + case "unknown-launch": |
| + cc.launches = func(context.Context) ([]hostbox.LaunchInventory, error) { |
| + return []hostbox.LaunchInventory{{Target: hostguard.FenceTarget{Host: "unknown.example", Op: "move"}}}, nil |
| + } |
| + case "reservation": |
| + cc.reservations = func(context.Context) ([]release.GenerationReservation, error) { |
| + return []release.GenerationReservation{{}}, nil |
| + } |
| + } |
| + s, err := cc.collect(t.Context()) |
| + if err == nil { |
| + err = s.preflight(c.identity) |
| + } |
| + if err == nil { |
| + t.Fatal("unsafe census accepted") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootmigration_production_linux.go b/internal/host/rootmigration_production_linux.go| new file mode 100644 |
| index 000000000..655f48b1c |
| --- /dev/null |
| +++ b/internal/host/rootmigration_production_linux.go |
| @@ -0,0 +1,236 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +var rootMigrationQuiesce = quiesceLegacyClaimJournals |
| + |
| +const rootMigrationDir = "/var/lib/carlos-claims/authority/migration" |
| + |
| +func migrateRootClaimAuthority(ctx context.Context, store release.Store, r ClaimRuntimeResources, owners ClaimOwnerBindings, actor hostbox.Actor, journals *hostbox.JournalService, launches *hostbox.LaunchJournal) error { |
| + return withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { |
| + state, err := rootstate.Open(rootMigrationDir, true, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer state.Close() |
| + execution, err := rootstate.Open(rootparticipation.Namespace, true, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer execution.Close() |
| + fences, err := openClaimRootAuthority() |
| + if err != nil { |
| + return err |
| + } |
| + defer fences.Close() |
| + root := hostguard.NewRootAuthority(fences) |
| + id := rootMigrationIdentity{Deployment: owners.RetirementAuthority.Directory.Bucket, Box: actor.Box, Enrollment: actor.Enrollment, Generation: 1} |
| + collector := rootMigrationCollector{retirements: productionGenerationRetirements, suspects: func(ctx context.Context) ([]string, error) { return rootMigrationLegacySuspects(ctx, r.DataDir) }, store: store, directory: owners.RetirementAuthority.Directory, identity: id, seeds: r.Registry, installations: root.MigrationInstallations, launches: launches.GenerationLaunches, boot: func() (string, error) { |
| + raw, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + return strings.TrimSpace(string(raw)), err |
| + }, reservations: func(ctx context.Context) ([]release.GenerationReservation, error) { |
| + state, err := rootstate.Open(release.RootGenerationsDir, false, false) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer state.Close() |
| + return (&release.RootGenerations{Store: state}).Reservations(ctx) |
| + }} |
| + roots := map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir} |
| + guard := hostguard.Guard{Root: root, DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}} |
| + // Reads go through the same typed transport used by the running controller. |
| + _, inventory := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| + return journals.Execute(ctx, "root-migration", q) |
| + }) |
| + completeInventory := func(ctx context.Context, item rootMigrationItem, target hostguard.FenceTarget) error { |
| + in, revision, err := inventory.Snapshot(ctx, item.Installation.Host, hostguard.Captured{Incarnation: item.Installation.Incarnation, Stamp: item.Installation.Stamp}) |
| + if err != nil { |
| + return err |
| + } |
| + observed, err := (hostguard.StoreReader{Store: store}).Read(ctx, item.Installation.Host) |
| + if err != nil || observed.Claim == nil { |
| + return errRootMigrationBusy |
| + } |
| + canonical, _, err := rootClaimRecord(ctx, store, observed.Claim, r.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + artifacts, err := rootInventoryArtifacts(item.Installation, canonical, in, revision) |
| + if err != nil { |
| + return err |
| + } |
| + return root.CompleteMigrationInventory(ctx, target, revision, artifacts) |
| + } |
| + coordinator := ClaimStopCoordinator{Guard: guard, Claims: hostguard.StoreReader{Store: store}, Inventory: inventory, Kernel: newProductionStopKernel(launches, roots), Evidence: store, EvidenceSigner: owners.EvidenceKey, RequestLimit: hostbox.RequestLimit, Now: time.Now, NewID: func() string { return "migration-" + randomMigrationID() }} |
| + m := rootMigration{state: state, execution: execution, identity: id, quiesce: rootMigrationQuiesce, observe: collector.collect, deny: func(ctx context.Context, item rootMigrationItem) error { |
| + return root.RetainMigrationDeny(ctx, item.Installation, item.Session) |
| + }, prepareRecovery: func(ctx context.Context) error { |
| + // The outer exclusion owns the edge mask through census completion. The |
| + // importer must neither acquire nor release that same mask itself. |
| + return journals.MigrateLegacy(ctx, filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, func(context.Context) (func(), error) { return func() {}, nil }, legacyJournalProof(store, owners.RetirementAuthority.Directory), func(context.Context) error { return nil }) |
| + }, recover: func(ctx context.Context, item rootMigrationItem) error { |
| + list, err := launches.GenerationLaunches(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + for _, l := range list { |
| + target := l.Target |
| + in := item.Installation |
| + if target.Host != in.Host || target.Incarnation != in.Incarnation || target.Stamp != in.Stamp || target.Session != item.Session { |
| + continue |
| + } |
| + target.ClaimETag = item.ClaimETag |
| + target.FenceID = "migration-" + generationInventoryDigest(item) |
| + target, err = guard.BeginFence(ctx, target) |
| + if err != nil { |
| + return err |
| + } |
| + if receipt, err := guard.FenceReceiptFor(ctx, target); err == nil { |
| + result := StopResult{Target: target, Bytes: receipt.Bytes, EvidenceAt: receipt.EvidenceAt} |
| + // Publish derives immutable key/digest from the stored signed body below. |
| + if err := publishMigrationReceipt(ctx, coordinator, result); err != nil { |
| + return err |
| + } |
| + return completeInventory(ctx, item, target) |
| + } else if !errors.Is(err, hostguard.ErrNoFenceReceipt) { |
| + return err |
| + } |
| + result, err := coordinator.RecoverStopped(ctx, target) |
| + if err != nil { |
| + return err |
| + } |
| + if err := coordinator.Publish(ctx, result); err != nil { |
| + return err |
| + } |
| + return completeInventory(ctx, item, target) |
| + } |
| + return ErrClaimStopInventory |
| + }, ready: func(ctx context.Context, s rootMigrationSnapshot) error { |
| + var all []hostbox.StopInventory |
| + for _, item := range s.Items { |
| + in, revision, err := inventory.Snapshot(ctx, item.Installation.Host, hostguard.Captured{Incarnation: item.Installation.Incarnation, Stamp: item.Installation.Stamp}) |
| + if err != nil { |
| + return err |
| + } |
| + // A missing independently established inventory cannot be filled from the |
| + // agent's markers, even after importing an otherwise valid stop proof. |
| + retained, e := root.Installation(ctx, item.Installation.Host, hostguard.Captured{Incarnation: item.Installation.Incarnation, Stamp: item.Installation.Stamp}) |
| + if e != nil { |
| + return e |
| + } |
| + if revision == 0 || retained.InventoryRevision != revision || !in.Complete { |
| + return ErrClaimStopInventory |
| + } |
| + all = append(all, in) |
| + } |
| + if err := hostbox.CheckMigrationRoots(ctx, roots, claimGlobalSharedPaths(r), all); err != nil { |
| + return err |
| + } |
| + if err := migrationExecutionEmpty(execution); err != nil { |
| + return err |
| + } |
| + if _, _, err := productionGenerationKernelCensus(ctx); err != nil { |
| + return err |
| + } |
| + floors, err := openRootMovesFloors(r.OptDir, rootFloorIdentity{Deployment: id.Deployment, Box: id.Box, Enrollment: id.Enrollment}, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer floors.close() |
| + p, err := floors.projection() |
| + if err != nil { |
| + return err |
| + } |
| + for _, path := range []string{systemBinaryPath(r.OptDir), filepath.Join(systemGuardDir(r.OptDir), "carlos")} { |
| + b, err := readRootFloorBinary(r.OptDir, path) |
| + if err != nil { |
| + return err |
| + } |
| + if !b.supports(p.Floor) { |
| + return errRootFloorHistory |
| + } |
| + } |
| + return nil |
| + }, publish: func(ctx context.Context, report hostops.CensusReport) error { |
| + return hostops.PublishCensusReport(ctx, store, owners.RetirementAuthority.Directory, report, owners.EvidenceKey) |
| + }} |
| + return m.run(ctx) |
| + }) |
| +} |
| + |
| +func migrationExecutionEmpty(state rootstate.Store) error { |
| + names, err := state.Names() |
| + if err != nil { |
| + return err |
| + } |
| + for _, name := range names { |
| + if name == "census.json" || strings.HasSuffix(name, ".lock") { |
| + continue |
| + } |
| + return errRootMigrationBusy |
| + } |
| + return nil |
| +} |
| + |
| +func randomMigrationID() string { |
| + var b [16]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "" |
| + } |
| + return hex.EncodeToString(b[:]) |
| +} |
| +func publishMigrationReceipt(ctx context.Context, c ClaimStopCoordinator, result StopResult) error { |
| + var receipt StopReceipt |
| + if json.Unmarshal(result.Bytes, &receipt) != nil { |
| + return ErrClaimStopReceipt |
| + } |
| + result.Key = stopEvidenceKey(receipt.Host, receipt.OperationID, receipt.Box, receipt.ID) |
| + result.Digest = digestRawStop(result.Bytes) |
| + return c.Publish(ctx, result) |
| +} |
| + |
| +func rootMigrationLegacySuspects(ctx context.Context, dd string) ([]string, error) { |
| + dir, err := safefile.OpenDir(filepath.Join(dd, "hostguard")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer dir.Close() |
| + names, err := dir.Readdirnames(-1) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := []string{} |
| + for _, name := range names { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if strings.HasSuffix(name, ".json") { |
| + out = append(out, strings.TrimSuffix(name, ".json")) |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/host/rootmigration_production_other.go b/internal/host/rootmigration_production_other.go| new file mode 100644 |
| index 000000000..3b6003fe5 |
| --- /dev/null |
| +++ b/internal/host/rootmigration_production_other.go |
| @@ -0,0 +1,13 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func migrateRootClaimAuthority(context.Context, release.Store, ClaimRuntimeResources, ClaimOwnerBindings, hostbox.Actor, *hostbox.JournalService, *hostbox.LaunchJournal) error { |
| + return hostbox.ErrUnsupported |
| +} |
diff --git a/internal/host/rootmigration_root_linux_test.go b/internal/host/rootmigration_root_linux_test.go| new file mode 100644 |
| index 000000000..433930aed |
| --- /dev/null |
| +++ b/internal/host/rootmigration_root_linux_test.go |
| @@ -0,0 +1,170 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootMigrationRootRunner(t *testing.T) { |
| + for _, point := range []string{"rename", "parent-sync"} { |
| + for _, after := range []bool{false, true} { |
| + for cut := 1; cut <= 8; cut++ { |
| + t.Run(fmt.Sprintf("%s/%t/%d", point, after, cut), func(t *testing.T) { |
| + m, s, _ := migrationFixture(t) |
| + dir := generationRootRunnerDir(t) |
| + open := func(name string) rootstate.Store { |
| + state, err := rootstate.Open(filepath.Join(dir, name), true, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return state |
| + } |
| + state, execution, fences := open("migration"), open("exec"), open("fences") |
| + defer execution.Close() |
| + m.state = state |
| + m.execution = execution |
| + s.Items = []rootMigrationItem{{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1}, Session: 9, ClaimETag: "claim"}} |
| + n := 0 |
| + injected := errors.New("interrupted migration syscall") |
| + fault := func(_ string, stage string, op func() error) error { |
| + if stage != point { |
| + return op() |
| + } |
| + n++ |
| + if n != cut { |
| + return op() |
| + } |
| + if after { |
| + if err := op(); err != nil { |
| + return err |
| + } |
| + } |
| + return injected |
| + } |
| + m.state = rootstate.WithWriteFault(state, fault) |
| + root := hostguard.NewRootAuthority(rootstate.WithWriteFault(fences, fault)) |
| + m.deny = func(ctx context.Context, item rootMigrationItem) error { |
| + return root.RetainMigrationDeny(ctx, item.Installation, item.Session) |
| + } |
| + if err := m.run(t.Context()); !errors.Is(err, injected) { |
| + t.Fatal("fault not reached", n, err) |
| + } |
| + state.Close() |
| + fences.Close() |
| + // Reopen real checked descriptors; the service-owned data directory is gone. |
| + state = open("migration") |
| + fences = open("fences") |
| + defer state.Close() |
| + defer fences.Close() |
| + m.state = state |
| + root = hostguard.NewRootAuthority(fences) |
| + if err := m.run(t.Context()); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + c, err := (rootparticipation.Ledger{State: execution}).Census() |
| + if err != nil || !c.Complete { |
| + t.Fatal("no complete census", c, err) |
| + } |
| + view, err := root.View(t.Context(), "a.example", hostguard.Captured{Incarnation: "inc", Stamp: 1}) |
| + if err != nil || len(view.MigrationDenies) != 1 || view.MigrationDenies[0].Session != 9 { |
| + t.Fatal("lost old session deny", view, err) |
| + } |
| + }) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootMigrationMaskRootRunner(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Fatal("root required") |
| + } |
| + unit := "/usr/lib/systemd/system/carlos-edge.service" |
| + for _, path := range []string{unit, "/etc/systemd/system/carlos-edge.service", "/run/systemd/system/carlos-edge.service"} { |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("existing edge unit", path, err) |
| + } |
| + } |
| + if err := os.WriteFile(unit, []byte("[Unit]\nDescription=Migration test\n[Service]\nType=oneshot\nExecStart=/usr/bin/true\n[Install]\nWantedBy=multi-user.target\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + run := func(args ...string) { |
| + t.Helper() |
| + if out, err := exec.Command("systemctl", args...).CombinedOutput(); err != nil { |
| + t.Fatalf("systemctl %v: %v %s", args, err, out) |
| + } |
| + } |
| + t.Cleanup(func() { |
| + exec.Command("systemctl", "unmask", "--runtime", "carlos-edge.service").Run() |
| + os.Remove(unit) |
| + exec.Command("systemctl", "daemon-reload").Run() |
| + }) |
| + run("daemon-reload") |
| + oldOwner, oldMarker := claimQuiesceOwner, claimEdgeMaskOwner |
| + oldSystemctl := claimSystemctl |
| + claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| + if err != nil { |
| + t.Logf("systemctl %v: %v %s", args, err, out) |
| + } |
| + return out, err |
| + } |
| + t.Cleanup(func() { claimSystemctl = oldSystemctl }) |
| + claimQuiesceOwner = func() (int, int, error) { return 65534, 65534, nil } |
| + claimEdgeMaskOwner = filepath.Join(generationRootRunnerDir(t), "edge-mask") |
| + t.Cleanup(func() { claimQuiesceOwner = oldOwner; claimEdgeMaskOwner = oldMarker }) |
| + // An /etc unit takes precedence over a runtime mask. Refuse that conflict; |
| + // a lower-priority fixture then exercises the existing mask protocol itself. |
| + etcUnit := "/etc/systemd/system/carlos-edge.service" |
| + body, err := os.ReadFile(unit) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(etcUnit, body, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.Remove(etcUnit) }) |
| + run("daemon-reload") |
| + run("mask", "--runtime", "carlos-edge.service") |
| + conflict, _, _ := migrationFixture(t) |
| + conflict.quiesce = quiesceLegacyClaimJournals |
| + if conflict.run(t.Context()) == nil { |
| + t.Fatal("ineffective runtime mask allowed migration") |
| + } |
| + if err := os.Remove(etcUnit); err != nil { |
| + t.Fatal(err) |
| + } |
| + run("daemon-reload") |
| + // The operator owns this mask. Both failed and complete migration retain it. |
| + run("mask", "--runtime", "carlos-edge.service") |
| + before, err := os.Lstat(claimEdgeRuntimeMask) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, fail := range []bool{true, false} { |
| + m, _, _ := migrationFixture(t) |
| + m.quiesce = quiesceLegacyClaimJournals |
| + if fail { |
| + m.ready = func(context.Context, rootMigrationSnapshot) error { return os.ErrPermission } |
| + } |
| + err := m.run(t.Context()) |
| + if (err != nil) != fail { |
| + t.Fatal("migration", fail, err) |
| + } |
| + after, err := os.Lstat(claimEdgeRuntimeMask) |
| + if err != nil || !os.SameFile(before, after) { |
| + t.Fatal("operator mask changed", err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor.go b/internal/host/rootmovesfloor.go| index 537bb00d0..2e9adcf9f 100644 |
| --- a/internal/host/rootmovesfloor.go |
| +++ b/internal/host/rootmovesfloor.go |
| @@ -24,10 +24,12 @@ const ( |
| type rootFloorIdentity struct{ Deployment, Box, Enrollment string } |
| type rootFloorReconstruction struct { |
| - Identity rootFloorIdentity |
| - Floor *moves.Floor |
| - NeverArmed bool |
| - Source, Digest, Authorization string |
| + Identity rootFloorIdentity |
| + Floor *moves.Floor |
| + NeverArmed bool |
| + Source, Digest string |
| + Authorization *rootFloorAuthorization `json:"-"` |
| + LegacyAuthorization string `json:"Authorization,omitempty"` |
| } |
| type rootFloorRecord struct { |
| Schema int |
| @@ -145,6 +147,39 @@ func (f rootMovesFloors) bound() (*moves.Floor, error) { |
| } |
| return bound, errors.Join(errs...) |
| } |
| + |
| +// Missing or malformed history contributes no permission and no invented deny. |
| +// Every independently readable retained bound still applies. |
| +func (f rootMovesFloors) localBound() (*moves.Floor, error) { |
| + var bound *moves.Floor |
| + if f.private != nil { |
| + for _, name := range []string{rootFloorAccepted, rootFloorDeny} { |
| + r, err := f.record(name) |
| + if err == nil { |
| + bound = floorMaximum(bound, r.Floor) |
| + } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errRootFloorHistory) { |
| + return bound, err |
| + } |
| + } |
| + } |
| + fl, err := f.selector() |
| + if err == nil { |
| + bound = floorMaximum(bound, fl) |
| + } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errRootFloorHistory) { |
| + return bound, err |
| + } |
| + raw, err := f.public.Read(rootFloorProjectionFile) |
| + if err == nil { |
| + var p rootFloorProjection |
| + if rootparticipation.Decode(raw, &p) == nil && p.rootFloorRecord.valid(f.identity) { |
| + bound = floorMaximum(bound, p.Floor) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return bound, err |
| + } |
| + return bound, nil |
| +} |
| + |
| func (f rootMovesFloors) quarantine(cause error) error { |
| raw, _ := json.Marshal(rootFloorProjection{Accepted: false}) |
| return errors.Join(cause, f.public.Write(rootFloorProjectionFile, raw)) |
| @@ -218,7 +253,7 @@ func (f rootMovesFloors) reconstruct(ctx context.Context, r rootFloorReconstruct |
| return err |
| } |
| defer unlock() |
| - if r.Identity != f.identity || r.Source == "" || len(r.Digest) != 64 || r.Authorization == "" || (r.NeverArmed != (r.Floor == nil)) || (r.Floor != nil && !validRootFloor(r.Floor)) { |
| + if r.Identity != f.identity || r.Source == "" || len(r.Digest) != 64 || (r.NeverArmed != (r.Floor == nil)) || (r.Floor != nil && !validRootFloor(r.Floor)) { |
| return f.quarantine(errRootFloorHistory) |
| } |
| bound, err := f.bound() |
| @@ -235,6 +270,9 @@ func (f rootMovesFloors) reconstruct(ctx context.Context, r rootFloorReconstruct |
| } else if !errors.Is(err, os.ErrNotExist) { |
| return f.quarantine(err) |
| } |
| + if !f.authorizedReconstruction(r.Authorization, generation) { |
| + return f.quarantine(errRootFloorHistory) |
| + } |
| next := rootFloorRecord{Schema: 1, Identity: f.identity, Generation: generation, Floor: r.Floor, NeverArmed: r.NeverArmed, Provenance: r} |
| if !next.valid(f.identity) { |
| return f.quarantine(errRootFloorHistory) |
| @@ -249,6 +287,25 @@ func (f rootMovesFloors) refresh(ctx context.Context, remote *moves.Floor, legac |
| } |
| defer unlock() |
| accepted, aerr := f.record(rootFloorAccepted) |
| + if errors.Is(aerr, os.ErrNotExist) || errors.Is(aerr, errRootFloorHistory) { |
| + bound, err := f.localBound() |
| + if err != nil { |
| + return err |
| + } |
| + for _, fl := range append(legacy, remote) { |
| + if validRootFloor(fl) { |
| + bound = floorMaximum(bound, fl) |
| + } |
| + } |
| + if err := f.quarantine(nil); err != nil { |
| + return err |
| + } |
| + if err := f.public.Write(rootFloorSelector, rootFloorLine(bound)); err != nil { |
| + return err |
| + } |
| + deny := rootFloorRecord{Schema: 1, Identity: f.identity, Generation: 1, Floor: bound, NeverArmed: bound == nil} |
| + return f.writeRecord(rootFloorDeny, deny) |
| + } |
| bound, berr := f.bound() |
| for _, fl := range legacy { |
| if validRootFloor(fl) { |
diff --git a/internal/host/rootmovesfloor_authorization.go b/internal/host/rootmovesfloor_authorization.go| new file mode 100644 |
| index 000000000..93747e76a |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_authorization.go |
| @@ -0,0 +1,55 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "os" |
| +) |
| + |
| +type rootFloorAuthorization struct { |
| + Identity rootFloorIdentity |
| + Generation uint64 |
| + Nonce string |
| +} |
| + |
| +// Only an offline root caller with the checked private store may issue this |
| +// capability. No production caller is installed until migration has full history. |
| +func (f rootMovesFloors) authorizeReconstruction(ctx context.Context) (*rootFloorAuthorization, error) { |
| + unlock, err := f.private.Lock(ctx, "floor.lock") |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer unlock() |
| + generation := uint64(1) |
| + old, err := f.record(rootFloorAccepted) |
| + if err == nil { |
| + generation = old.Generation + 1 |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + var nonce [32]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return nil, err |
| + } |
| + token := rootFloorAuthorization{Identity: f.identity, Generation: generation, Nonce: hex.EncodeToString(nonce[:])} |
| + raw, _ := json.Marshal(token) |
| + if err := f.private.Write("reconstruction-token.json", raw); err != nil { |
| + return nil, err |
| + } |
| + return &token, nil |
| +} |
| +func (f rootMovesFloors) authorizedReconstruction(token *rootFloorAuthorization, generation uint64) bool { |
| + if token == nil || token.Identity != f.identity || token.Generation != generation || len(token.Nonce) != 64 { |
| + return false |
| + } |
| + raw, err := f.private.Read("reconstruction-token.json") |
| + if err != nil { |
| + return false |
| + } |
| + var retained rootFloorAuthorization |
| + return rootparticipation.Decode(raw, &retained) == nil && retained == *token |
| +} |
diff --git a/internal/host/rootmovesfloor_boot.go b/internal/host/rootmovesfloor_boot.go| index 8f1d479d3..3bad40e3a 100644 |
| --- a/internal/host/rootmovesfloor_boot.go |
| +++ b/internal/host/rootmovesfloor_boot.go |
| @@ -36,7 +36,7 @@ func rootFloorPrevVerdict(j rootFloorJudge) error { |
| // Called under release, previous-binary and floor exclusion. A rejected prev |
| // can only fall back to an independently verified current binary above the bound. |
| func rootFloorBootCandidate(ctx context.Context, j rootFloorJudge, journal bool, reader func(context.Context, string) error, idle func(context.Context) error) (string, error) { |
| - p, err := j.floors.projection() |
| + bound, err := j.floors.localBound() |
| if err != nil { |
| return "", err |
| } |
| @@ -47,7 +47,7 @@ func rootFloorBootCandidate(ctx context.Context, j rootFloorJudge, journal bool, |
| if err != nil { |
| return "", err |
| } |
| - if !current.supports(p.Floor) { |
| + if !current.supports(bound) { |
| return "", errRootFloorHistory |
| } |
| return j.current, nil |
| @@ -74,7 +74,7 @@ func rootFloorAllowsPrev(opt string) error { |
| func withRootFloorGuardCandidate(ctx context.Context, opt string, id rootFloorIdentity, journal bool, effect func(string) error) error { |
| return withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { |
| return withPrevLock(opt, func() error { |
| - f, err := openRootMovesFloors(opt, id, false) |
| + f, err := openRootMovesFloors(opt, id, true) |
| if err != nil { |
| return err |
| } |
| @@ -84,9 +84,6 @@ func withRootFloorGuardCandidate(ctx context.Context, opt string, id rootFloorId |
| return err |
| } |
| defer unlock() |
| - if _, err := readRootFloorProjection(opt); err != nil { |
| - return err |
| - } |
| j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }} |
| candidate, err := rootFloorBootCandidate(ctx, j, journal, func(ctx context.Context, path string) error { |
| _, err := productionSysOps().run(ctx, systemVersionWait, path, "system", "floor-allows-prev", "--root", opt) |
diff --git a/internal/host/rootmovesfloor_boot_root_linux_test.go b/internal/host/rootmovesfloor_boot_root_linux_test.go| index a91bcc72c..528352643 100644 |
| --- a/internal/host/rootmovesfloor_boot_root_linux_test.go |
| +++ b/internal/host/rootmovesfloor_boot_root_linux_test.go |
| @@ -66,7 +66,7 @@ func TestPlan14RootFloorBootSelectors(t *testing.T) { |
| } |
| defer f.close() |
| b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| mark := filepath.Join(opt, "executed") |
| @@ -171,7 +171,7 @@ func TestPlan14RootFloorBootSelectors(t *testing.T) { |
| } |
| out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput() |
| got, readErr := os.ReadFile(mark) |
| - if attack == "none" { |
| + if attack == "none" || attack == "no guard" || attack == "missing accepted" || attack == "missing floor" || attack == "corrupt floor" || attack == "crash-1" { |
| if runErr != nil || readErr != nil || string(got) != "v2.0.0" { |
| journal, _ := exec.Command("journalctl", "-u", name, "--no-pager").CombinedOutput() |
| t.Fatalf("positive: %v %s %v %s\n%s", runErr, out, readErr, got, journal) |
| @@ -201,9 +201,6 @@ func TestPlan14RootFloorBootPayload(t *testing.T) { |
| t.Fatal(err) |
| } |
| defer f.close() |
| - if _, err := readRootFloorProjection(opt); err != nil { |
| - t.Fatal(err) |
| - } |
| j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }} |
| // Installation/participation is a fixture here; F4 exercises installed shell |
| // selection, the real signed-binary reader, floor records and prev reader. |
diff --git a/internal/host/rootmovesfloor_boot_test.go b/internal/host/rootmovesfloor_boot_test.go| index 39140d2a8..53d490104 100644 |
| --- a/internal/host/rootmovesfloor_boot_test.go |
| +++ b/internal/host/rootmovesfloor_boot_test.go |
| @@ -13,7 +13,7 @@ func TestPlan14RootFloorBootDecision(t *testing.T) { |
| t.Run(attack, func(t *testing.T) { |
| f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{Deployment: "d", Box: "b", Enrollment: "e"}} |
| b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| current := rootFloorBinary{Version: "v2.0.0", Protocol: 1, Digest: floorTestDigest} |
| @@ -59,7 +59,7 @@ func TestPlan14RootFloorBootDecision(t *testing.T) { |
| return nil |
| } |
| got, err := rootFloorBootCandidate(t.Context(), j, true, gate, idle) |
| - if attack == "missing floor" || attack == "corrupt floor" || attack == "current below" { |
| + if attack == "current below" { |
| if err == nil { |
| t.Fatal("unsafe executable selected", got) |
| } |
diff --git a/internal/host/rootmovesfloor_crash_root_linux_test.go b/internal/host/rootmovesfloor_crash_root_linux_test.go| new file mode 100644 |
| index 000000000..32bb39ecb |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_crash_root_linux_test.go |
| @@ -0,0 +1,90 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootFloorSyscallCrashOrdering(t *testing.T) { |
| + for _, stage := range []string{"write", "chmod", "file-sync", "close", "rename", "parent-sync"} { |
| + for _, after := range []bool{false, true} { |
| + for cut := 1; cut <= 5; cut++ { |
| + t.Run(fmt.Sprintf("%s/after=%t/write=%d", stage, after, cut), func(t *testing.T) { |
| + opt := generationRootRunnerDir(t) |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| + b := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + injected := errors.New("interrupted syscall") |
| + fault := func(_ string, point string, op func() error) error { |
| + if point != stage { |
| + return op() |
| + } |
| + n++ |
| + if n != cut { |
| + return op() |
| + } |
| + if after { |
| + if err := op(); err != nil { |
| + return err |
| + } |
| + } |
| + return injected |
| + } |
| + broken := f |
| + broken.private = rootstate.WithWriteFault(f.private, fault) |
| + broken.public = rootstate.WithWriteFault(f.public, fault) |
| + if err := broken.refresh(t.Context(), &b, nil, nil); !errors.Is(err, injected) { |
| + t.Fatalf("cut not reached: %v", err) |
| + } |
| + f.close() |
| + f, err = openRootMovesFloors(opt, id, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.close() |
| + // Before the second write's rename B is not visible. Thereafter the |
| + // selector itself retains B even before accepted.json is replaced. |
| + bound, err := f.bound() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bVisible := cut > 2 || cut == 2 && (stage == "parent-sync" || stage == "rename" && after) |
| + if bVisible && (bound == nil || *bound != b) { |
| + t.Fatalf("lost visible deny: %+v", bound) |
| + } |
| + if p, e := readRootFloorProjection(opt); e == nil && p.Floor != nil && *p.Floor == b { |
| + t.Fatal("failed syscall acknowledged B") |
| + } |
| + if err := f.refresh(t.Context(), &b, nil, nil); err != nil { |
| + t.Fatal("retry", err) |
| + } |
| + p, err := readRootFloorProjection(opt) |
| + if err != nil || p.Floor == nil || *p.Floor != b { |
| + t.Fatal("retry did not durably accept B", p, err) |
| + } |
| + if err := f.refresh(t.Context(), &a, nil, nil); err == nil { |
| + t.Fatal("regressed after recovery") |
| + } |
| + bound, err = f.bound() |
| + if err != nil || bound == nil || *bound != b { |
| + t.Fatal("weakened deny", bound, err) |
| + } |
| + }) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_recovery_root_linux_test.go b/internal/host/rootmovesfloor_recovery_root_linux_test.go| new file mode 100644 |
| index 000000000..d9c130688 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_recovery_root_linux_test.go |
| @@ -0,0 +1,130 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootFloorRecoveryRollbackRootRunner(t *testing.T) { |
| + scratch := generationRootRunnerDir(t) |
| + t.Setenv("TMPDIR", scratch) |
| + for _, entry := range []string{"recover", "rollback"} { |
| + for _, target := range []string{"v1.0.0", "v2.0.0"} { |
| + t.Run(entry+"/"+target, func(t *testing.T) { |
| + f, _ := applyHappyVersion(t, "v2.0.0") |
| + for path := f.a.optDir; path != scratch; path = filepath.Dir(path) { |
| + if err := os.Chmod(path, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // A compatible previous image can have the same release as the failed |
| + // current image. This tests the real restore without inventing idle evidence. |
| + j := f.brokenBox("v2.0.0", target, target) |
| + delete(f.answer, "version:"+systemPrevPath(f.a.optDir)) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(f.pub)) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy") |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f.a.rootMoves = func() (rootFloorIdentity, bool, error) { return id, true, nil } |
| + floors, err := openRootMovesFloors(f.a.optDir, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer floors.close() |
| + a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| + b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| + if err := floors.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, floors)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(b) |
| + if _, err := f.store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{systemBinaryPath(f.a.optDir), systemPrevPath(f.a.optDir)} { |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + version := "v2.0.0" |
| + if path == systemPrevPath(f.a.optDir) { |
| + version = target |
| + } |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: f.now, Current: release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version, platform), SHA256: hex.EncodeToString(sum[:])}}}} |
| + wire, err := release.SignRootEnvelope(release.RootSigner(f.priv), p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + identities, err := rootstate.Open(filepath.Join(f.a.optDir, "system", "guard", "identities"), true, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := identities.Write(version+".json", wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + identities.Close() |
| + } |
| + if err := refreshRootMovesFloor(t.Context(), f.store, f.a.optDir, f.a.dataDir, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { |
| + t.Error("recovery read network") |
| + return nil, nil, errors.New("offline") |
| + } |
| + if err := os.WriteFile(prevVersionPath(f.a.optDir), []byte("v0.0.1\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + journal, _ := os.ReadFile(f.a.file("journal.json")) |
| + calls := len(f.calls) |
| + if entry == "recover" { |
| + _, err = f.a.recoverIfNeeded(t.Context()) |
| + } else { |
| + err = f.a.rollback(t.Context(), systemRequest{Version: j.Target}, "fixture", &j) |
| + } |
| + if target == "v1.0.0" { |
| + after, _ := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + retained, _ := os.ReadFile(f.a.file("journal.json")) |
| + if err == nil || !bytes.Equal(before, after) || !bytes.Equal(journal, retained) { |
| + t.Fatal("below-floor restore changed state", err) |
| + } |
| + for _, call := range f.calls[calls:] { |
| + if len(call) >= 10 && call[:10] == "systemctl " { |
| + t.Fatal("refused restore touched systemd", call) |
| + } |
| + } |
| + } else { |
| + if entry == "recover" && err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.result() == nil || f.result().Outcome != systemOutcomeRolledBack { |
| + t.Fatal("restore incomplete", err, f.result()) |
| + } |
| + if _, err := os.Stat(f.a.file("journal.json")); !os.IsNotExist(err) { |
| + t.Fatal("successful restore retained journal", err) |
| + } |
| + } |
| + p, e := readRootFloorProjection(f.a.optDir) |
| + if e != nil || p.Floor == nil || *p.Floor != b { |
| + t.Fatal("recovery lost retained B", p, e) |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_refresh.go b/internal/host/rootmovesfloor_refresh.go| index 19117950b..2c9444d3a 100644 |
| --- a/internal/host/rootmovesfloor_refresh.go |
| +++ b/internal/host/rootmovesfloor_refresh.go |
| @@ -43,7 +43,7 @@ type rootFloorJudge struct { |
| } |
| func (b rootFloorBinary) supports(fl *moves.Floor) bool { |
| - if b.Digest == "" || !validFloorRelease(b.Version) || b.Protocol < 1 { |
| + if b.Digest == "" || !validFloorRelease(b.Version) { |
| return false |
| } |
| if fl == nil { |
| @@ -70,7 +70,18 @@ func (j rootFloorJudge) judge(ctx context.Context) error { |
| defer unlock() |
| p, err := f.projection() |
| if err != nil { |
| - return err |
| + bound, err := f.localBound() |
| + if err != nil { |
| + return err |
| + } |
| + current, err := j.binary(j.current) |
| + if err != nil { |
| + return err |
| + } |
| + if !current.supports(bound) { |
| + return errRootFloorHistory |
| + } |
| + return f.public.Write("prev.json", []byte("{}")) |
| } |
| // Revoke a same-generation verdict before any observation that can fail. |
| if err := f.public.Write("prev.json", []byte("{}")); err != nil { |
diff --git a/internal/host/rootmovesfloor_refresh_test.go b/internal/host/rootmovesfloor_refresh_test.go| index 734a023ef..30ecffdc0 100644 |
| --- a/internal/host/rootmovesfloor_refresh_test.go |
| +++ b/internal/host/rootmovesfloor_refresh_test.go |
| @@ -21,7 +21,7 @@ func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) { |
| f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: id} |
| a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| b := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| store := release.NewDirStore(t.TempDir()) |
| @@ -90,7 +90,7 @@ func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) { |
| } |
| } |
| -func TestPlan14RootFloorRecoveryRefresh(t *testing.T) { |
| +func TestPlan14RootFloorRecoveryOffline(t *testing.T) { |
| for _, entry := range []string{"recover", "rollback"} { |
| t.Run(entry, func(t *testing.T) { |
| f, _ := applyHappy(t) |
| @@ -110,8 +110,8 @@ func TestPlan14RootFloorRecoveryRefresh(t *testing.T) { |
| } else { |
| _, err = f.a.restoreFiles(t.Context(), &j) |
| } |
| - if !errors.Is(err, unavailable) || reads != 1 { |
| - t.Fatalf("remote floor not refreshed first: reads=%d err=%v", reads, err) |
| + if err == nil || reads != 0 { |
| + t.Fatalf("recovery used network or accepted missing signed identity: reads=%d err=%v", reads, err) |
| } |
| after, _ := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| retained, _ := os.ReadFile(journal) |
diff --git a/internal/host/rootmovesfloor_root_linux_test.go b/internal/host/rootmovesfloor_root_linux_test.go| index 79e4f82b1..59698b6d7 100644 |
| --- a/internal/host/rootmovesfloor_root_linux_test.go |
| +++ b/internal/host/rootmovesfloor_root_linux_test.go |
| @@ -37,7 +37,7 @@ func TestPlan14RootFloorCrashOrderingRootRunner(t *testing.T) { |
| } |
| a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| b := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "root observation", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "root observation", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| n := 0 |
| @@ -102,7 +102,7 @@ func TestPlan14FloorRiseWithoutPlatformUpdateRootRunner(t *testing.T) { |
| defer f.close() |
| a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| store := release.NewDirStore(t.TempDir()) |
| @@ -237,7 +237,7 @@ func TestPlan14RootFloorAdoptRefreshRootRunner(t *testing.T) { |
| defer f.close() |
| a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| store := release.NewDirStore(storeDir) |
| @@ -322,7 +322,7 @@ func TestPlan14RootFloorSignedTargetRootRunner(t *testing.T) { |
| } |
| defer f.close() |
| b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| store := release.NewDirStore(t.TempDir()) |
| @@ -348,6 +348,17 @@ func TestPlan14RootFloorSignedTargetRootRunner(t *testing.T) { |
| if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err != nil || calls != 1 { |
| t.Fatal("signed target with unknown history refused", err, calls) |
| } |
| + for _, storeName := range []struct { |
| + store rootstate.Store |
| + name string |
| + }{{f.private, rootFloorAccepted}, {f.private, rootFloorDeny}, {f.public, rootFloorSelector}, {f.public, rootFloorProjectionFile}} { |
| + if err := storeName.store.Remove(storeName.name); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err != nil || calls != 2 { |
| + t.Fatal("unknown floor blocked signed version", err, calls) |
| + } |
| changed := *p |
| changed.Current.Version = "other" |
| if rememberRootFloorSystemIdentity(t.Context(), store, pub, opt, &changed) == nil { |
| @@ -356,7 +367,7 @@ func TestPlan14RootFloorSignedTargetRootRunner(t *testing.T) { |
| if err := os.WriteFile(staged, []byte("substitution"), 0755); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err == nil || calls != 1 { |
| + if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err == nil || calls != 2 { |
| t.Fatal("unsigned target executed", err, calls) |
| } |
| } |
diff --git a/internal/host/rootmovesfloor_source.go b/internal/host/rootmovesfloor_source.go| index d862bd74c..35fa1c865 100644 |
| --- a/internal/host/rootmovesfloor_source.go |
| +++ b/internal/host/rootmovesfloor_source.go |
| @@ -49,18 +49,22 @@ func readEffectiveMovesFloor(source rootFloorIdentitySource, opt, dd string) (*m |
| return readLocalFloor(dd) |
| } |
| f, err := openRootMovesFloors(opt, id, false) |
| + if errors.Is(err, os.ErrNotExist) { |
| + public, e := rootstate.Open(rootFloorDir(opt), false, true) |
| + if errors.Is(e, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if e != nil { |
| + return nil, e |
| + } |
| + defer public.Close() |
| + return (rootMovesFloors{public: public, identity: id}).localBound() |
| + } |
| if err != nil { |
| return nil, err |
| } |
| defer f.close() |
| - bound, err := f.bound() |
| - if err != nil { |
| - return bound, err |
| - } |
| - if _, err = f.projection(); err != nil { |
| - return bound, err |
| - } |
| - return bound, nil |
| + return f.localBound() |
| } |
| func withEffectiveMovesFloorLock(source rootFloorIdentitySource, opt, dd string, create bool, fn func() error) error { |
| @@ -71,7 +75,7 @@ func withEffectiveMovesFloorLock(source rootFloorIdentitySource, opt, dd string, |
| if !required { |
| return withFloorLock(dd, create, func(*movesDir) error { return fn() }) |
| } |
| - f, err := openRootMovesFloors(opt, id, false) |
| + f, err := openRootMovesFloors(opt, id, true) |
| if err != nil { |
| return err |
| } |
| @@ -186,17 +190,3 @@ func readRootLegacyFloorJSON(dd string) (*moves.Floor, error) { |
| } |
| return &floor, err |
| } |
| - |
| -// Recovery can run before verifyPointer and armFloor. Fetch its own floor |
| -// before using a retained executable; a failed read leaves the journal pending. |
| -func (a *systemApplier) refreshRootFloorForRecovery(ctx context.Context) error { |
| - id, required, err := a.rootMoves.read() |
| - if err != nil || !required { |
| - return err |
| - } |
| - store, _, err := a.openStore(ctx) |
| - if err != nil { |
| - return err |
| - } |
| - return refreshRootMovesFloor(ctx, store, a.optDir, a.dataDir, id) |
| -} |
diff --git a/internal/host/rootmovesfloor_test.go b/internal/host/rootmovesfloor_test.go| index 36a71dd12..5019a3676 100644 |
| --- a/internal/host/rootmovesfloor_test.go |
| +++ b/internal/host/rootmovesfloor_test.go |
| @@ -20,7 +20,7 @@ func TestPlan14RootFloorCrashOrdering(t *testing.T) { |
| f := rootMovesFloors{private: private, public: public, identity: rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}} |
| a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| b := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| - if err := f.reconstruct(context.Background(), rootFloorReconstruction{Identity: f.identity, Floor: &a, Source: "complete root history", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + if err := f.reconstruct(context.Background(), rootFloorReconstruction{Identity: f.identity, Floor: &a, Source: "complete root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| t.Fatal(err) |
| } |
| n := 0 |
| @@ -86,8 +86,8 @@ func TestPlan14RootFloorReconstructionAuthority(t *testing.T) { |
| if tc == "legacy" { |
| legacy = []*moves.Floor{&b} |
| } |
| - if f.refresh(t.Context(), &a, legacy, nil) == nil { |
| - t.Fatal("unknown history accepted") |
| + if f.refresh(t.Context(), &a, legacy, nil) != nil { |
| + t.Fatal("unknown history blocked refresh") |
| } |
| if _, err := f.projection(); err == nil { |
| t.Fatal("unknown history advertised") |
| @@ -100,7 +100,7 @@ func TestPlan14RootFloorReconstructionAuthority(t *testing.T) { |
| } |
| return |
| } |
| - r := rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "complete history", Digest: floorTestDigest, Authorization: "operator"} |
| + r := rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "complete history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)} |
| if tc == "never armed" { |
| r.Floor = nil |
| r.NeverArmed = true |
| @@ -182,3 +182,87 @@ func TestPlan14RootFloorLegacyIsDenyOnly(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func floorTestAuthorization(t *testing.T, f rootMovesFloors) *rootFloorAuthorization { |
| + t.Helper() |
| + token, err := f.authorizeReconstruction(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return token |
| +} |
| + |
| +func TestPlan14RootFloorReconstructionToken(t *testing.T) { |
| + f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}} |
| + token := floorTestAuthorization(t, f) |
| + for _, kind := range []string{"missing", "box", "generation", "nonce"} { |
| + changed := *token |
| + r := rootFloorReconstruction{Identity: f.identity, NeverArmed: true, Source: "root history", Digest: floorTestDigest, Authorization: &changed} |
| + switch kind { |
| + case "missing": |
| + r.Authorization = nil |
| + case "box": |
| + changed.Identity.Box = "other" |
| + case "generation": |
| + changed.Generation++ |
| + case "nonce": |
| + changed.Nonce = floorTestDigest |
| + } |
| + if f.reconstruct(t.Context(), r) == nil { |
| + t.Fatal("accepted", kind) |
| + } |
| + } |
| + if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: f.identity, NeverArmed: true, Source: "root history", Digest: floorTestDigest, Authorization: token}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorUnknownHistoryRetainsDenies(t *testing.T) { |
| + f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}} |
| + good := rootFloorBinary{Version: "v3.0.0", Protocol: 0, Digest: floorTestDigest} |
| + j := rootFloorJudge{floors: f, current: "current", prev: "prev", binary: func(string) (rootFloorBinary, error) { return good, nil }} |
| + selectCurrent := func() { |
| + t.Helper() |
| + got, err := rootFloorBootCandidate(t.Context(), j, false, nil, nil) |
| + if err != nil || got != "current" { |
| + t.Fatal("unknown signed candidate refused", got, err) |
| + } |
| + } |
| + selectCurrent() |
| + good.Protocol = 2 |
| + bound := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| + if err := f.refresh(t.Context(), nil, []*moves.Floor{&bound}, os.ErrNotExist); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.projection(); err == nil { |
| + t.Fatal("unknown became accepted") |
| + } |
| + selectCurrent() |
| + good.Version = "v1.0.0" |
| + if _, err := rootFloorBootCandidate(t.Context(), j, false, nil, nil); err == nil { |
| + t.Fatal("legacy deny ignored") |
| + } |
| + good.Version = "v3.0.0" |
| + if err := f.private.Write(rootFloorAccepted, []byte("corrupt")); err != nil { |
| + t.Fatal(err) |
| + } |
| + selectCurrent() |
| + good.Version = "v1.0.0" |
| + if _, err := rootFloorBootCandidate(t.Context(), j, false, nil, nil); err == nil { |
| + t.Fatal("corrupt history erased separate deny") |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorPublicDenySurvivesPrivateLoss(t *testing.T) { |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + public := &rollbackMemory{files: map[string][]byte{}} |
| + f := rootMovesFloors{public: public, identity: id} |
| + floor := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| + if err := public.Write(rootFloorSelector, rootFloorLine(&floor)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := f.localBound() |
| + if err != nil || got == nil || *got != floor { |
| + t.Fatal("public deny lost", got, err) |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index c145f925d..174e588c0 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -1600,7 +1600,10 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| } |
| // carlos.prev, now on disk: what this rollback's journal finish makes |
| // current, checked again under activationGuard at that finish. |
| - restored, _ := readLine(prevVersionPath(a.optDir)) |
| + restored, err := a.restoreVersion() |
| + if err != nil { |
| + return failed("identity", err) |
| + } |
| // The previous binary is on disk, so the control service goes back |
| // too, before the edge, as on the way forward. Logged, not fatal: a |
| // rollback's job is a good edge, and a control service that would |
| @@ -1659,9 +1662,6 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| // it comes up at the next boot. No restart, no start: what to do about the |
| // running edge is the caller's, and at boot the answer is nothing. |
| func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (bootstrapPlan, error) { |
| - if err := a.refreshRootFloorForRecovery(ctx); err != nil { |
| - return bootstrapPlan{}, err |
| - } |
| prev := systemPrevPath(a.optDir) |
| if err := a.guardImage(prev); err != nil { |
| return bootstrapPlan{}, err |
| @@ -1673,7 +1673,10 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo |
| // on the box changes, for the version carlos.prev's sidecar records (no |
| // sidecar is below any armed floor). A refusal keeps the journal: the |
| // box stays on the build it is on, and the caller reports it. |
| - prevVersion, _ := readLine(prevVersionPath(a.optDir)) |
| + prevVersion, err := a.restoreVersion() |
| + if err != nil { |
| + return bootstrapPlan{}, err |
| + } |
| running := a.onDiskBuild(ctx) |
| if err := a.activationRule(prevVersion, func() string { return running }); err != nil { |
| return bootstrapPlan{}, err |
| @@ -1800,9 +1803,6 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) { |
| if err != nil || j == nil { |
| return false, err |
| } |
| - if err := a.refreshRootFloorForRecovery(ctx); err != nil { |
| - return false, err |
| - } |
| // The result belongs to the promote being recovered, which the journal |
| // names in full. The request is used only when it is that same promote, |
| // version and promoted_at both: a re-promote of the same version is a |
| @@ -1863,7 +1863,10 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) { |
| return false, fmt.Errorf("system apply: recovery: %w", err) // journal kept: the next run recovers again |
| } |
| // carlos.prev, now on disk: what the finish below makes current. |
| - restored, _ := readLine(prevVersionPath(a.optDir)) |
| + restored, err := a.restoreVersion() |
| + if err != nil { |
| + return false, err |
| + } |
| if a.ops.edgeStartedThisBoot() { |
| // The control service first, as in install and rollback. Not at |
| // boot: it is socket-activated and not yet running, and started |
| @@ -2065,3 +2068,14 @@ func (a *systemApplier) stageCandidate(ctx context.Context, p *release.SystemPoi |
| } |
| return staged, nil |
| } |
| + |
| +func (a *systemApplier) restoreVersion() (string, error) { |
| + if _, required, err := a.rootMoves.read(); err != nil { |
| + return "", err |
| + } else if required { |
| + identity, err := readRootFloorBinary(a.optDir, systemPrevPath(a.optDir)) |
| + return identity.Version, err |
| + } |
| + version, _ := readLine(prevVersionPath(a.optDir)) |
| + return version, nil |
| +} |
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service| index 84b856754..428bbd0fa 100644 |
| --- a/internal/host/units/carlos-system-recover.service |
| +++ b/internal/host/units/carlos-system-recover.service |
| @@ -38,7 +38,7 @@ DefaultDependencies=yes |
| Type=oneshot |
| TimeoutStartSec=10min |
| EnvironmentFile=-/etc/carlos/host.env |
| -ExecStart=/bin/sh -c 'if [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; then exec /opt/carlos/system/guard/carlos system guard-exec recover; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover' |
| +ExecStart=/bin/sh -c 'if [ -x /opt/carlos/system/guard/carlos ] && { [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; }; then exec /opt/carlos/system/guard/carlos system guard-exec recover; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover' |
| ProtectHome=yes |
| PrivateTmp=yes |
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service| index 19be448cf..c64e4c46c 100644 |
| --- a/internal/host/units/carlos-system-update.service |
| +++ b/internal/host/units/carlos-system-update.service |
| @@ -44,7 +44,7 @@ OnFailure=carlos-system-recover.service |
| Type=oneshot |
| TimeoutStartSec=15min |
| EnvironmentFile=-/etc/carlos/host.env |
| -ExecStart=/bin/sh -c 'if [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; then exec /opt/carlos/system/guard/carlos system guard-exec apply; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply' |
| +ExecStart=/bin/sh -c 'if [ -x /opt/carlos/system/guard/carlos ] && { [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; }; then exec /opt/carlos/system/guard/carlos system guard-exec apply; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply' |
| # --- hardening (the carlos-stage.service set) --- |
| ProtectHome=yes |
diff --git a/internal/hostbox/migration_census_linux.go b/internal/hostbox/migration_census_linux.go| new file mode 100644 |
| index 000000000..682643309 |
| --- /dev/null |
| +++ b/internal/hostbox/migration_census_linux.go |
| @@ -0,0 +1,104 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "io/fs" |
| + "os" |
| + "path/filepath" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// CheckMigrationRoots accounts for every managed entry under offline exclusion. |
| +// Only independently retained inventory may classify an installation subtree. |
| +func CheckMigrationRoots(ctx context.Context, roots map[string]string, shared map[string][]string, inventories []StopInventory) error { |
| + entries := map[string]map[string]StopInventoryEntry{} |
| + for _, in := range inventories { |
| + if !in.Complete { |
| + return ErrTypedInventory |
| + } |
| + for _, e := range in.Entries { |
| + if e.Kind != "path" { |
| + continue |
| + } |
| + if roots[e.ManagedRoot] == "" || !managedName(e.RelativePath) || e.Materialization != "materialized" || e.Device == 0 || e.Inode == 0 { |
| + return ErrTypedInventory |
| + } |
| + if entries[e.ManagedRoot] == nil { |
| + entries[e.ManagedRoot] = map[string]StopInventoryEntry{} |
| + } |
| + if _, ok := entries[e.ManagedRoot][e.RelativePath]; ok { |
| + return ErrTypedInventory |
| + } |
| + entries[e.ManagedRoot][e.RelativePath] = e |
| + } |
| + } |
| + for _, name := range []string{"data", "opt", "units"} { |
| + root := roots[name] |
| + if !filepath.IsAbs(root) { |
| + return ErrTypedInventory |
| + } |
| + info, err := os.Lstat(root) |
| + if err != nil { |
| + return err |
| + } |
| + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { |
| + return ErrTypedInventory |
| + } |
| + rules, err := censusRules(shared[name], "migration-census.invalid") |
| + if err != nil { |
| + return err |
| + } |
| + count := 0 |
| + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { |
| + if walkErr != nil { |
| + return walkErr |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + count++ |
| + if count > 65536 { |
| + return ErrTypedInventory |
| + } |
| + rel, err := filepath.Rel(root, path) |
| + if err != nil { |
| + return err |
| + } |
| + rel = filepath.ToSlash(rel) |
| + if rel == "." { |
| + return nil |
| + } |
| + if e, ok := entries[name][rel]; ok { |
| + var st unix.Stat_t |
| + if err := unix.Lstat(path, &st); err != nil { |
| + return err |
| + } |
| + if uint64(st.Dev) != e.Device || st.Ino != e.Inode || st.Mode&unix.S_IFMT != e.Type || d.Type()&os.ModeSymlink != 0 { |
| + return ErrTypedInventory |
| + } |
| + delete(entries[name], rel) |
| + if d.IsDir() { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if rule, ok := rules[rel]; ok { |
| + return rule.check(path, d) |
| + } |
| + if censusInTree(rules, rel) && (d.IsDir() || d.Type().IsRegular()) { |
| + return nil |
| + } |
| + if d.IsDir() { |
| + return nil |
| + } |
| + return ErrTypedInventory |
| + }); err != nil { |
| + return err |
| + } |
| + if len(entries[name]) != 0 { |
| + return ErrTypedInventory |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostbox/migration_census_other.go b/internal/hostbox/migration_census_other.go| new file mode 100644 |
| index 000000000..07ca0f2d9 |
| --- /dev/null |
| +++ b/internal/hostbox/migration_census_other.go |
| @@ -0,0 +1,9 @@ |
| +//go:build !linux |
| + |
| +package hostbox |
| + |
| +import "context" |
| + |
| +func CheckMigrationRoots(context.Context, map[string]string, map[string][]string, []StopInventory) error { |
| + return ErrUnsupported |
| +} |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index 50ea2c032..1893386e0 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -219,6 +219,9 @@ func (r record) admits(c *hostclaim.Claim, captured Captured) error { |
| if captured.Stamp != r.ActiveStamp { |
| return ErrStampMismatch |
| } |
| + if migrationDenied(r.migrationDenies, captured, c.Holder.Session) { |
| + return ErrFenced |
| + } |
| if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { |
| return ErrFenced |
| } |
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go| index 1e611edf8..12cb446ec 100644 |
| --- a/internal/hostguard/record.go |
| +++ b/internal/hostguard/record.go |
| @@ -26,6 +26,7 @@ type Captured struct { |
| } |
| type record struct { |
| + migrationDenies []MigrationDeny |
| quarantined bool |
| root bool |
| installation RootInstallation |
diff --git a/internal/hostguard/root.go b/internal/hostguard/root.go| index b7ae573bc..b6f2f5a8b 100644 |
| --- a/internal/hostguard/root.go |
| +++ b/internal/hostguard/root.go |
| @@ -39,8 +39,9 @@ type RootAuthority struct { |
| func NewRootAuthority(store rootstate.Store) *RootAuthority { return &RootAuthority{store: store} } |
| type rootRecord struct { |
| - Quarantined bool `json:",omitempty"` |
| - RebindCommit []byte `json:",omitempty"` |
| + MigrationDenies []MigrationDeny `json:",omitempty"` |
| + Quarantined bool `json:",omitempty"` |
| + RebindCommit []byte `json:",omitempty"` |
| Schema int |
| Installation RootInstallation |
| Retired []Captured |
| @@ -68,6 +69,12 @@ func (a *RootAuthority) read(host string) (record, error) { |
| return record{}, ErrMalformedRecord |
| } |
| r := saved.State |
| + r.migrationDenies = saved.MigrationDenies |
| + for _, deny := range r.migrationDenies { |
| + if deny.Incarnation == "" || deny.Stamp == 0 || deny.Session == 0 { |
| + return record{}, ErrMalformedRecord |
| + } |
| + } |
| r.quarantined = saved.Quarantined |
| r.root = true |
| r.installation = saved.Installation |
| @@ -89,7 +96,7 @@ func (a *RootAuthority) write(r record) error { |
| return ErrMalformedRecord |
| } |
| r.installation.Host, r.installation.Incarnation, r.installation.Stamp = r.Host, r.Incarnation, r.ActiveStamp |
| - b, err := json.Marshal(rootRecord{Quarantined: r.quarantined, RebindCommit: r.rebindCommit, Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| + b, err := json.Marshal(rootRecord{MigrationDenies: r.migrationDenies, Quarantined: r.quarantined, RebindCommit: r.rebindCommit, Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| if err != nil { |
| return err |
| } |
| @@ -158,6 +165,9 @@ func (g Guard) CommitRootInventoryArtifacts(ctx context.Context, in RootInstalla |
| return ErrRecordIncarnation |
| } |
| } |
| + if migrationDenied(r.migrationDenies, want, c.Holder.Session) { |
| + return ErrFenced |
| + } |
| if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, want) { |
| return ErrFenced |
| } |
diff --git a/internal/hostguard/rootmigration.go b/internal/hostguard/rootmigration.go| new file mode 100644 |
| index 000000000..df74858db |
| --- /dev/null |
| +++ b/internal/hostguard/rootmigration.go |
| @@ -0,0 +1,85 @@ |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| +) |
| + |
| +type MigrationDeny struct { |
| + Incarnation string |
| + Stamp, Session uint64 |
| +} |
| + |
| +func migrationDenied(denies []MigrationDeny, c Captured, session uint64) bool { |
| + for _, d := range denies { |
| + if d.Incarnation == c.Incarnation && c.Stamp <= d.Stamp && session <= d.Session { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// RetainMigrationDeny adds only refusal authority. The offline root collector |
| +// supplies the independently observed installation; this method grants no work. |
| +func (a *RootAuthority) RetainMigrationDeny(ctx context.Context, in RootInstallation, session uint64) error { |
| + if a == nil || a.store == nil || validateFileHost(in.Host) != nil || in.Incarnation == "" || in.Stamp == 0 || in.Migration == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box == "" || in.Enrollment == "" || session == 0 { |
| + return ErrInvalidInput |
| + } |
| + unlock, err := a.store.Lock(ctx, in.Host+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + r, err := a.read(in.Host) |
| + if errors.Is(err, ErrNoRecord) { |
| + r = record{V: 1, Host: in.Host, Incarnation: in.Incarnation, ActiveStamp: in.Stamp, root: true, installation: in} |
| + } else if err != nil { |
| + return err |
| + } |
| + if r.installation.Host != in.Host || r.installation.Incarnation != in.Incarnation || r.installation.Stamp != in.Stamp || r.installation.Account != in.Account || r.installation.App != in.App || r.installation.Record != in.Record || r.installation.Box != in.Box || r.installation.Enrollment != in.Enrollment || r.installation.Migration != in.Migration { |
| + return ErrRecordIncarnation |
| + } |
| + for i, d := range r.migrationDenies { |
| + if d.Incarnation == in.Incarnation && d.Stamp == in.Stamp { |
| + if session > r.migrationDenies[i].Session { |
| + r.migrationDenies[i].Session = session |
| + } |
| + return a.write(r) |
| + } |
| + } |
| + r.migrationDenies = append(r.migrationDenies, MigrationDeny{Incarnation: in.Incarnation, Stamp: in.Stamp, Session: session}) |
| + return a.write(r) |
| +} |
| + |
| +// CompleteMigrationInventory binds the checked inventory only after root has |
| +// retained its own completed stop receipt for this denied session. |
| +func (a *RootAuthority) CompleteMigrationInventory(ctx context.Context, target FenceTarget, revision uint64, artifacts InstallationArtifacts) error { |
| + if target.validate(target.Host) != nil || revision == 0 || artifacts.Schema != 1 { |
| + return ErrInvalidInput |
| + } |
| + unlock, err := a.store.Lock(ctx, target.Host+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + r, err := a.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + captured := Captured{Incarnation: target.Incarnation, Stamp: target.Stamp} |
| + if r.Incarnation != target.Incarnation || r.ActiveStamp != target.Stamp || !migrationDenied(r.migrationDenies, captured, target.Session) { |
| + return ErrFenceTarget |
| + } |
| + completed := false |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target == target { |
| + completed = true |
| + } |
| + } |
| + if !completed || r.installation.InventoryRevision != 0 && r.installation.InventoryRevision != revision { |
| + return ErrFenceTarget |
| + } |
| + r.installation.InventoryRevision = revision |
| + r.installation.Artifacts = artifacts |
| + return a.write(r) |
| +} |
diff --git a/internal/hostguard/rootmigration_inventory.go b/internal/hostguard/rootmigration_inventory.go| new file mode 100644 |
| index 000000000..6095077be |
| --- /dev/null |
| +++ b/internal/hostguard/rootmigration_inventory.go |
| @@ -0,0 +1,40 @@ |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// MigrationInstallations is a refusal-only census, including incomplete |
| +// inventory. It makes no repair writes before the active-move preflight. |
| +func (a *RootAuthority) MigrationInstallations(ctx context.Context) ([]FenceView, error) { |
| + names, err := a.store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + sort.Strings(names) |
| + out := []FenceView{} |
| + for _, name := range names { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if strings.HasSuffix(name, ".lock") { |
| + continue |
| + } |
| + if !strings.HasSuffix(name, ".json") { |
| + return nil, ErrMalformedRecord |
| + } |
| + host := strings.TrimSuffix(name, ".json") |
| + r, err := a.read(host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + view, err := a.viewLocked(host, Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, view) |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/hostguard/rootmigration_test.go b/internal/hostguard/rootmigration_test.go| new file mode 100644 |
| index 000000000..5c7ae54af |
| --- /dev/null |
| +++ b/internal/hostguard/rootmigration_test.go |
| @@ -0,0 +1,52 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestPlan14MigrationDenyCoversRootAndAgent(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + root := NewRootAuthority(&rootTestStore{dir: t.TempDir()}) |
| + f.g.Root = root |
| + f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app" |
| + in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "app", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1} |
| + if err := f.g.EstablishRoot(t.Context(), in, allow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { return nil }); err != nil { |
| + t.Fatal("canonical", err) |
| + } |
| + if err := root.RetainMigrationDeny(t.Context(), in, target.Session); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { t.Fatal("old session ran"); return nil }); !errors.Is(err, ErrFenced) { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.EstablishRoot(t.Context(), in, allow); !errors.Is(err, ErrFenced) { |
| + t.Fatal("re-establishment bypass", err) |
| + } |
| + view, err := root.View(t.Context(), target.Host, captured) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := view.admit(target.Host, captured, target.Session); !errors.Is(err, ErrFenced) { |
| + t.Fatal("agent view bypass", err) |
| + } |
| + f.obs.Claim.Holder.Session++ |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { return nil }); err != nil { |
| + t.Fatal("fresh authoritative session denied", err) |
| + } |
| + if err := view.admit(target.Host, captured, target.Session+1); err != nil { |
| + t.Fatal("new session view", err) |
| + } |
| + if err := root.RetainMigrationDeny(t.Context(), in, target.Session-1); err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := root.View(t.Context(), target.Host, captured) |
| + if err != nil || again.MigrationDenies[0].Session != target.Session { |
| + t.Fatal("deny regressed", again, err) |
| + } |
| +} |
diff --git a/internal/hostguard/rootview.go b/internal/hostguard/rootview.go| index 8b47f1877..a96ac3e59 100644 |
| --- a/internal/hostguard/rootview.go |
| +++ b/internal/hostguard/rootview.go |
| @@ -3,14 +3,15 @@ package hostguard |
| import "context" |
| type FenceView struct { |
| - Schema int |
| - Host string |
| - Captured Captured |
| - Installation RootInstallation |
| - Retired []Captured |
| - Pending *FenceTarget |
| - Receipts []FenceReceipt |
| - Barrier *FenceTarget |
| + MigrationDenies []MigrationDeny |
| + Schema int |
| + Host string |
| + Captured Captured |
| + Installation RootInstallation |
| + Retired []Captured |
| + Pending *FenceTarget |
| + Receipts []FenceReceipt |
| + Barrier *FenceTarget |
| } |
| type FenceViewProvider func(context.Context, string, Captured) (FenceView, error) |
| @@ -57,7 +58,7 @@ func (a *RootAuthority) viewLocked(host string, captured Captured) (FenceView, e |
| if !known { |
| return FenceView{}, ErrStampMismatch |
| } |
| - return FenceView{Schema: 1, Host: host, Captured: captured, Installation: r.installation, Retired: r.rootRetired, Pending: r.ActiveFence, Receipts: r.FenceJournal, Barrier: r.barrier}, nil |
| + return FenceView{MigrationDenies: r.migrationDenies, Schema: 1, Host: host, Captured: captured, Installation: r.installation, Retired: r.rootRetired, Pending: r.ActiveFence, Receipts: r.FenceJournal, Barrier: r.barrier}, nil |
| } |
| func (v FenceView) validate(host string, c Captured) error { |
| if v.Schema != 1 || v.Host != host || v.Installation.Host != host || v.Installation.Migration == 0 || v.Captured != c { |
| @@ -88,6 +89,9 @@ func (v FenceView) admit(host string, c Captured, claimSession uint64) error { |
| return ErrRetiredStamp |
| } |
| } |
| + if migrationDenied(v.MigrationDenies, c, claimSession) { |
| + return ErrFenced |
| + } |
| if v.Barrier != nil { |
| return ErrFenced |
| } |
| @@ -115,6 +119,6 @@ func (g Guard) fenceLocked(ctx context.Context, host string, c Captured, fn func |
| if err := view.validate(host, c); err != nil { |
| return err |
| } |
| - r := record{V: 1, Host: host, Incarnation: view.Installation.Incarnation, ActiveStamp: view.Installation.Stamp, ActiveFence: view.Pending, FenceJournal: view.Receipts, root: true, installation: view.Installation, rootRetired: view.Retired, barrier: view.Barrier} |
| + r := record{migrationDenies: view.MigrationDenies, V: 1, Host: host, Incarnation: view.Installation.Incarnation, ActiveStamp: view.Installation.Stamp, ActiveFence: view.Pending, FenceJournal: view.Receipts, root: true, installation: view.Installation, rootRetired: view.Retired, barrier: view.Barrier} |
| return fn(&recordDir{snapshot: &r}) |
| } |
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go| index 89c7b52cd..03753045b 100644 |
| --- a/internal/provision/plan14_p3_census_test.go |
| +++ b/internal/provision/plan14_p3_census_test.go |
| @@ -21,6 +21,7 @@ func TestPlan14P3OverlayPreservesLegacySeal(t *testing.T) { |
| // record-key site. These calls write root floor files, never instance records. |
| func plan14P3WriteCensusInventory() []censusEntry { |
| return append(plan14EWriteCensusInventory(), |
| + censusEntry{"internal/host/rootmovesfloor.go", "rootMovesFloors.refresh", "key-site", "key:writeRecord", "d444ecd7e5303e8cf069feb2fe70897529ca6d11a1500d5683aba3a69c1837d6"}, |
| censusEntry{"internal/host/rootmovesfloor.go", "rootMovesFloors.persist", "key-site", "key:writeRecord", "2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e"}, |
| censusEntry{"internal/host/rootmovesfloor.go", "rootMovesFloors.writeRecord", "key-site", "key:writeRecord", "a8a51bdd73f8ab1447edf2241d19a41ba1bcb0084ef045d370e5282aca7e9e66"}, |
| ) |
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv| index a375dbb96..c9817e5b9 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-p3.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-p3.tsv |
| @@ -1,54 +1,101 @@ |
| -# Plan 14 Part 3 run 1; all entries require controller ruling. |
| -# No accepted-legacy fingerprints changed. Load after plan14-root-reads-e.tsv. |
| -internal/console/boxselect.go Server.selectMoveBoxes authority F,J4 rev3=PROPOSED-p3;identity=console service user;basis=refuses missing/quarantined accepted signal, generation zero, stale floor or incompatible prev at authoritative move admission 7e3e9c39c9b6d79b99fa00fae233c109d7169659f646b49e43ec7ec320c723fa |
| -internal/host/guardarm.go RunSystemGuardArm authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:OpenFile@565,file:ReadFile@1142,file:ReadFile@1756,file:ReadFile@5797,file:ReadFile@865,file:Stat@3448 3eb11b09adc3b122f555513cc35aac8124f89149e72db995351cde384b2cf809 |
| -internal/host/guardexec.go RunSystemGuardExec authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Exec@1896,file:Exec@2049,file:Stat@952 00fb661d2fd0e57450beb310eb39f9a2152001a67632329777d2c814f3c178d9 |
| -internal/host/guardtransaction.go systemApplier.guardRun authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors c0a0ea8fe6a524d95b3a579ecf296c8075e435478c6daae2b0fa06518e88314f |
| -internal/host/movesfloor.go rootJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority 0c9aa7c7df1ddd5b21798bc0158c5496dad16d400a69e8ace2dd61e8e16f0d58 |
| -internal/host/movesfloor.go rootJudgeMovesPrevWithSource authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority file:Stat@289,file:Stat@490 fa942c1c4b8af2d2930be427e303f3b9838c0fce33f1b3f8e63e17093b638269 |
| -internal/host/movesfloor.go rootJudgePrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority 8b5ecf5a0f33ee58d603f308302bcc8e68678da6b1e6ee7ed03845c35747f8c6 |
| -internal/host/movesfloor.go withMovesFields authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Lstat@238,file:Stat@697 3e615afa769bb64bcb0ffdc1ad7b85b67883a34cd6afd83ce7da74a1f4218983 |
| -internal/host/rootmovesfloor.go openRootMovesFloors authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@167,file:Open@269 4ceed7e158d3cfc0b9b77df47c7656aea044f95e0706ed3cb9e1df698995f42a |
| -internal/host/rootmovesfloor.go rootMovesFloors.persist authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e |
| -internal/host/rootmovesfloor.go rootMovesFloors.projection authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@109 6b0ed0053db247385ccbabfd95a738fd48a8d27b14882b5e73b4e5a82eda26b6 |
| -internal/host/rootmovesfloor.go rootMovesFloors.reconstruct authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors e14cbe7dfcbcaf50bff3e1318da0080f184ac23a654ac1f5d9f333c71e3f549a |
| -internal/host/rootmovesfloor.go rootMovesFloors.record authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@108 2e2bc3e9e915e4a0a70c40933de3928284bbdab25ee223e4cf05500a12913b83 |
| -internal/host/rootmovesfloor.go rootMovesFloors.refresh authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors b4afeaa9dcd9fd5f33ae3d8d8810224e6025adf4ff5b8dc7671e95cc96ff2777 |
| -internal/host/rootmovesfloor.go rootMovesFloors.selector authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@73 5c46438590323610c67dc5bbfaa191fc5ba23e3b280b6063dbd48e48e7ce3927 |
| -internal/host/rootmovesfloor_boot.go rootFloorAllowsPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 8fb85b6abbc251b4456d6ad6cce02331048804870e6b64a56e12f8fda0107d0d |
| -internal/host/rootmovesfloor_boot.go rootFloorBootCandidate authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 80607853f76e6ffb588c8168a557107f66a12c34acdd6fafee390ae5bfa5d612 |
| -internal/host/rootmovesfloor_boot.go rootFloorJournalPresent authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors b189d04dc579f4825f5668efd1f93f178acca738bb2ed886a14f3b5321d7755b |
| -internal/host/rootmovesfloor_boot.go rootFloorPrevVerdict authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@131 04a9909b6ff4c81380c1d974ce028ae525ff53513fb71f78b0e1076d35391967 |
| -internal/host/rootmovesfloor_boot.go withRootFloorGuardCandidate authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@218 92937e2a80d01e17d839a43cf42bb81974b289266cad60dff81c6c60cfb37e6f |
| -internal/host/rootmovesfloor_identity.go rememberRootFloorSystemIdentity authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@543 b4e56a0637c28e5fb65d3a33b7dfd1fea8bed55acbf5231bf26e0011168141fe |
| -internal/host/rootmovesfloor_refresh.go readRootFloorBinary authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@684,file:Read@941,file:Stat@276 47062453004252de69e807a67ac94e40243d69b3a55033dacf2d8354a5d635a6 |
| -internal/host/rootmovesfloor_refresh.go rootFloorAdvertisementFor authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Read@203 4e464b6add24a06b1c3d7a5859be7a1055750bc25f27c8ca641241be3baa2ef8 |
| -internal/host/rootmovesfloor_refresh.go rootFloorDowngradeIdle authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@68 8bb82e0b179e0dfadd6f375299d9f40ceea1c2b911b1d53f18fb4958957c74ac |
| -internal/host/rootmovesfloor_refresh.go rootFloorJudge.judge authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 0c7aa349e001578024ea11cd91b25cf01cc67fa8adcc4db4e8190eb4d16d7621 |
| -internal/host/rootmovesfloor_refresh.go rootFloorJudge.refresh authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 362798a60b4e3d6fc798db52bfc204979f724efe33a858f3d7ca2c1ca12f6eaa |
| -internal/host/rootmovesfloor_refresh.go rootRefreshAndJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 2d4d1ce1d6143555f3b94b19359c014ffb20272f41f756f7c02bbf6ff29a70a9 |
| -internal/host/rootmovesfloor_refresh.go withRootMovesFields authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Open@405 29fb652e22df888f18c691b91db6b83b9781d89d005a2fae431d8102e77b2f88 |
| -internal/host/rootmovesfloor_source.go readEffectiveMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 47496b77002d64977e309e1edc70a6fb2c87e7748575b5dce60a4e75ee89ecac |
| -internal/host/rootmovesfloor_source.go readRootFloorProjection authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Open@83,file:Read@243 170e020e2f05437de39145566fdd4055cc11bd668fe24a1ca2458ece49195aeb |
| -internal/host/rootmovesfloor_source.go readRootLegacyFloorJSON authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:ReadAll@443,file:Stat@280 1af52c869107011648314ac12a9687ce641329b5a92fa7d487c278fa082bf4f2 |
| -internal/host/rootmovesfloor_source.go refreshRootMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors a41df2c69e7d869b5232d545440b8135e8c7124d490965e641b8d1fba83cb490 |
| -internal/host/rootmovesfloor_source.go rootFloorLegacy authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:ReadAll@710,file:Stat@518 37e37f7b57fb768eea5cf750fb1a88a1a43b4239180878f5479a77f276dce424 |
| -internal/host/rootmovesfloor_source.go rootMovesFloorIdentity authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 457cdc76e2cff8fdfe4d146c7baf086dd58d54f151ce9416c32efd0a03778d98 |
| -internal/host/rootmovesfloor_source.go systemApplier.refreshRootFloorForRecovery authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors eb0e38f5184c8036d0611973a46bd66b926273263edcada89cc0b6d54f189b24 |
| -internal/host/rootmovesfloor_source.go withEffectiveMovesFloorLock authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors dbadcd8e7e8844502ee5006eb7e2d2a5f5312f91316bcfc41937035b34a85206 |
| -internal/host/systemapply.go RunSystemApply authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@1265 22440d8ad127c66958b9c4d06244db4ca1aec1b9dfd60fad8f5bceb24f789b25 |
| -internal/host/systemapply.go systemApplier.activationGuard authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=promote only protected F floor/downgrade effect from frozen legacy; preserve original fingerprint and no-census behavior; unrelated registry decisions remain legacy whole:Route@1083 49988cef27c704d1c937176f1bcad1d22a229beb18a8ef2a2ef949cd39fae9e6 |
| -internal/host/systemapply.go systemApplier.activationRule authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=promote only protected F floor/downgrade effect from frozen legacy; preserve original fingerprint and no-census behavior; unrelated registry decisions remain legacy whole:Route@1019,whole:Route@810,whole:Route@817 bbe33714cf851396d775273545cece1aeed399f275bdaf303e126dc724ee0961 |
| -internal/host/systemapply.go systemApplier.armFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors d7e16eaaa3994896bf9acd763777cc4d8998056dfa2d492c5320395102009a16 |
| -internal/host/systemapply.go systemApplier.layoutReversalReasons authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors ee4e5ee2961e86b6d2e700601d57bb605c3c945515ffc678d35919d70dc2b0af |
| -internal/host/systemapply.go systemApplier.notePrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors d55d08f057fb6ea467e293b4e7f0ca3e2e639d26147cab2d1e823530404ff2db |
| -internal/host/systemapply.go systemApplier.restoreFiles authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Stat@319 95f8a9a2262e5472ca32b68ce8032d9928f84da894ff9bfaec291c010b448337 |
| -internal/host/units/carlos-system-recover.service external authority F,J4 rev3=PROPOSED-p3;identity=uid 0, installed systemd selector;basis=root marker/census selects retained guard reader before any legacy absence branch; embedded bootstrap output; TestPlan14RootFloorBootSelectors file:external@0 48ea0ae9be57a99f5910b442e16ba3a79f7ddd4c30a2c6910fbcba612bcb247d |
| -internal/host/units/carlos-system-update.service external authority F,J4 rev3=PROPOSED-p3;identity=uid 0, installed systemd selector;basis=root marker/census selects retained guard reader before any legacy absence branch; embedded bootstrap output; TestPlan14RootFloorBootSelectors file:external@0 b2ede25244b33563fa29920fbeaa59f687f6d0f398607fc76590b56d90f1c37c |
| -internal/rootstate/state.go Sync authority F,J4 rev3=PROPOSED-p3;identity=uid 0 writer / service-user projection reader;basis=sync existing checked descriptor; no ownership change, new file or permission grant 2e5d0a1411eff4cfb942f20517ecc2653a05ae3e4d586c14092d65de48ceff02 |
| -internal/rootstate/state_linux.go directory.Sync authority F,J4 rev3=PROPOSED-p3;identity=uid 0 writer / service-user projection reader;basis=sync existing checked descriptor; no ownership change, new file or permission grant b932169c2132a038eb12459c8537f0f308d4ba5987d5d3f296f2bd435f598fe3 |
| -@coverage internal/console 478ab8732720175aa6c9ac9e49364b9401b69230e7939b17b2f04c9d4c8c91fd |
| +# Plan 14 Part 3 run 2; all rows remain proposed for controller review. |
| +# Earlier overlays and frozen legacy fingerprints are unchanged. |
| +internal/console/boxselect.go Server.selectMoveBoxes authority F,J4 rev3=PROPOSED-p3;identity=carlos console serve, console.service uid carlos-console;basis=applies accepted/generation refusal only to advertised root-floor participants; legacy selection retains main exactly d51a6a29a190dd36e896567adf4e0e0e1c8c9a08ff909cfbedd7732c2321ae78 |
| +internal/host/claimjournal_migration.go RunSystemJournalMigrate authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=explicit offline root operator entry requests full G2 bootstrap after verified claim owner setup 4f204d8a97620bfb22430a94e236d6c5b14a5265b35f68e21fb2ad34903ae0f3 |
| +internal/host/claimjournal_migration.go migrateClaimJournals authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=retains legacy migration helper for existing import/mask behavior; explicit operator entry requests G2 separately e21da6b220796cc799dcd6dcbfe5b566b7bb6753394c786f44ac66cfcc07c6e3 |
| +internal/host/claimjournal_migration.go migrateClaimJournalsWith authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=opens verified actor and root journal then selects full offline census or unchanged legacy journal import e9b719120521b324163865a620812115a126cafc21b62ffa41282cf4e8093c34 |
| +internal/host/guardarm.go RunSystemGuardArm authority F,J4 rev3=PROPOSED-p3;identity=carlos system guard-arm, offline CLI uid 0;basis=root guard-arm checks retained census floor before installing guard; no census retains main file:OpenFile@565,file:ReadFile@1142,file:ReadFile@1756,file:ReadFile@5797,file:ReadFile@865,file:Stat@3448 3eb11b09adc3b122f555513cc35aac8124f89149e72db995351cde384b2cf809 |
| +internal/host/guardexec.go RunSystemGuardExec authority F,J4 rev3=PROPOSED-p3;identity=carlos system guard-exec apply/recover root units uid 0; edge unit uid carlos; console unit uid carlos-console;basis=root apply/recover selects signed image from local bound; non-root edge bypasses private floor entirely file:Exec@1936,file:Exec@2089,file:Stat@952 6179ce002e1de67f7fbcc50a2dacf57038e9cb3017c7e457573e6f1889d2b2c7 |
| +internal/host/guardtransaction.go systemApplier.guardRun authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=verifies signed target digest and local retained bounds before executing a version or bootstrap request c0a0ea8fe6a524d95b3a579ecf296c8075e435478c6daae2b0fa06518e88314f |
| +internal/host/movesfloor.go rootJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt-only wrapper delegates to root-aware moves judge; no direct input reads 0c9aa7c7df1ddd5b21798bc0158c5496dad16d400a69e8ace2dd61e8e16f0d58 |
| +internal/host/movesfloor.go rootJudgeMovesPrevWithSource authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt refreshes root floor only for census participants; legacy registry judgement otherwise unchanged file:Stat@289,file:Stat@490 fa942c1c4b8af2d2930be427e303f3b9838c0fce33f1b3f8e63e17093b638269 |
| +internal/host/movesfloor.go rootJudgePrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt combines moves and seeds verdict errors; seeds root ratchet still pending P3-R4 8b5ecf5a0f33ee58d603f308302bcc8e68678da6b1e6ee7ed03845c35747f8c6 |
| +internal/host/movesfloor.go withMovesFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent uses root-owned public projection when participating and its original writable mirror otherwise file:Lstat@238,file:Stat@697 3e615afa769bb64bcb0ffdc1ad7b85b67883a34cd6afd83ce7da74a1f4218983 |
| +internal/host/movesfloorgate.go floorGateMain authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=dispatches explicit root previous gate; legacy argument shape preserved 6123e52c08365414c1135aef0fbcdbadcd68b38596cc81d8d9ace482eb555949 |
| +internal/host/rootmigration.go rootMigration.census authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=writes pending or complete root participation identity and revokes failed completion writes 7d9e3a6c0ed825e22e0a9c2d1910656f612acb37a1b217c2170c1f682f309d37 |
| +internal/host/rootmigration.go rootMigration.run authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=signed exclusion and repeated census precede pending root grant barrier, prepared/fenced/recovered/complete writes; signed report is last file:Read@508 6365ba54bc1c68fcb3ec9b7f232b0b0be9f3773b1151b3759e9c3a7e4329da83 |
| +internal/host/rootmigration.go rootMigration.write authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=serializes migration phase records through checked private store durability 25c2c65aaae5b4ca1761fe0e3c456fbcd29d8dc58e5bdd40f94bf2ffe855ca7d |
| +internal/host/rootmigration.go rootMigrationSnapshot.fingerprint authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=binds exclusion and membership across observations/restart while allowing boot metadata and independently recovered payload revision 115b07f84d5aa210cd35d70e2ba4b53ff4deb0e4636a737a5dc144bf34a9473c |
| +internal/host/rootmigration.go rootMigrationSnapshot.preflight authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=refuses active moves, unfinished seeds, incomplete inventories or mismatched migration identity before effects 60675bcf7e54360a9daaddffb9458b5eab2636c3db8b9a2c87541c6e4dbbce97 |
| +internal/host/rootmigration_census.go rootMigrationCollector.collect authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=signed collecting manifest and actor directory bound complete records/claims census; registry Seeds are refusal-only file:Exec@4684,file:Read@2868,file:Read@689 6cfe43917f24a6dd6c7e3eee503f84897c944ec0c251ddfc64c16884f5090438 |
| +internal/host/rootmigration_production_linux.go migrateRootClaimAuthority authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline root composes claim denies, authenticated legacy proofs, root inventory and kernel scan under release/mask exclusion file:Open@1538,file:Open@302,file:Open@384,file:Open@508,file:Read@2593,file:ReadFile@1340,whole:Route@2927 ee738c2f20fdc767f96fe1a90e0e28ec67ec52f93b4512f97f86ac7b56875ecb |
| +internal/host/rootmigration_production_linux.go migrationExecutionEmpty authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=any retained execution entry other than census/locks refuses migration; conservative settled-record limitation remains c17efaf8e7191cf0cd36d0ee462793c78094e40aef46567a446e503aee494641 |
| +internal/host/rootmigration_production_linux.go publishMigrationReceipt authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=reconstructs immutable key/digest from original signed receipt bytes without changing evidence time db91dbe64b57fe10aa1635fb46938e2ccb585afb6aa138800507acac6a83c377 |
| +internal/host/rootmigration_production_linux.go randomMigrationID authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=cryptographic nonce identifies newly collected stop evidence, never authorizes a stop file:Read@64 c2779c80b5628eca7d484ffba0b289c1068317b1f474c06e1ee5b39c3f769196 |
| +internal/host/rootmigration_production_linux.go rootMigrationLegacySuspects authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=agent-writable hostguard filenames only identify unexplained hosts to refuse, never authorize inventory file:Readdirnames@277 6bfa73d903baa8a028abb007a7ec1da0066dbcda966e92e29b50a2b56fda10ab |
| +internal/host/rootmigration_production_other.go migrateRootClaimAuthority authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline root composes claim denies, authenticated legacy proofs, root inventory and kernel scan under release/mask exclusion 327e0adfe387eac7bc74b87258efc9831f5a35ee9c9c9e71640d4e6b54853aae |
| +internal/host/rootmovesfloor.go openRootMovesFloors authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=opens checked public and private root-owned floor descriptors; private parent is mode 0700 file:Open@167,file:Open@269 4ceed7e158d3cfc0b9b77df47c7656aea044f95e0706ed3cb9e1df698995f42a |
| +internal/host/rootmovesfloor.go rootMovesFloors.localBound authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=retains every readable independent deny; missing or corrupt history adds no invented refusal or acceptance file:Read@583 cc70feddffa2f611e9de3250d42b84a506f3d5ee688921c551125c4df60ac43a |
| +internal/host/rootmovesfloor.go rootMovesFloors.persist authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=writes quarantine before selector and deny ratchet, then accepted state, then synced public acknowledgement 2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e |
| +internal/host/rootmovesfloor.go rootMovesFloors.projection authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=matches public acceptance to private generation, identity and retained componentwise maximum file:Read@109 6b0ed0053db247385ccbabfd95a738fd48a8d27b14882b5e73b4e5a82eda26b6 |
| +internal/host/rootmovesfloor.go rootMovesFloors.reconstruct authority F,J4 rev3=PROPOSED-p3;identity=offline root reconstruction capability, uid 0; no production caller installed;basis=requires private root-issued box/generation/nonce token and refuses weakening; no production caller installed 1a6b2923d55208b8a53cfd17d3cf84f01087485f7b5cadc9012cb1b123120038 |
| +internal/host/rootmovesfloor.go rootMovesFloors.record authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=bounded strict private JSON read requires matching deployment, box and enrollment file:Read@108 2e2bc3e9e915e4a0a70c40933de3928284bbdab25ee223e4cf05500a12913b83 |
| +internal/host/rootmovesfloor.go rootMovesFloors.refresh authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=normal refresh ratchets accepted history; unknown history persists only observed denies and never advertises accepted d444ecd7e5303e8cf069feb2fe70897529ca6d11a1500d5683aba3a69c1837d6 |
| +internal/host/rootmovesfloor.go rootMovesFloors.selector authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=strict bounded canonical protocol/release line supplies independent crash-surviving deny file:Read@73 5c46438590323610c67dc5bbfaa191fc5ba23e3b280b6063dbd48e48e7ce3927 |
| +internal/host/rootmovesfloor_authorization.go rootMovesFloors.authorizeReconstruction authority F,J4 rev3=PROPOSED-p3;identity=offline root reconstruction capability, uid 0; no production caller installed;basis=issues private nonce capability for this root identity and next generation under stable lock; no production caller file:Read@426 a5235f6aab5714cee3e3e41604eed5b1eff7aa4355f9444f44c4811f7dfbbc08 |
| +internal/host/rootmovesfloor_authorization.go rootMovesFloors.authorizedReconstruction authority F,J4 rev3=PROPOSED-p3;identity=offline root reconstruction capability, uid 0; no production caller installed;basis=requires exact private persisted nonce, box identity and next generation; caller strings cannot authorize file:Read@249 c449d7087a6cbcdf4388b7945e84045a0b895349b1bc14347aca7040c822391c |
| +internal/host/rootmovesfloor_boot.go rootFloorAllowsPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=old-reader-resistant root gate requires current generation verdict and signed previous executable 8fb85b6abbc251b4456d6ad6cce02331048804870e6b64a56e12f8fda0107d0d |
| +internal/host/rootmovesfloor_boot.go rootFloorBootCandidate authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=local-only selection requires previous verdict plus idle proof; fallback current must be signed and support all readable denies 386a486b3235ad5152e009281500dc0f54e00283d96ea44f9d5a28eacd3f6758 |
| +internal/host/rootmovesfloor_boot.go rootFloorJournalPresent authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=checked authority-file presence selects recovery without opening a deployment store b189d04dc579f4825f5668efd1f93f178acca738bb2ed886a14f3b5321d7755b |
| +internal/host/rootmovesfloor_boot.go rootFloorPrevVerdict authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=matches actual signed previous binary to identity and accepted generation before allowing its gate file:Read@131 04a9909b6ff4c81380c1d974ce028ae525ff53513fb71f78b0e1076d35391967 |
| +internal/host/rootmovesfloor_boot.go withRootFloorGuardCandidate authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=holds release, previous-binary and private-floor exclusion around local signed selection and exec file:Open@218 2426c860c29e05c74bfb9d1beaab396688bad4fab4f1f1d278026099bea44337 |
| +internal/host/rootmovesfloor_identity.go rememberRootFloorSystemIdentity authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=re-fetches and verifies signed pointer equality before retaining its executable identity file:Open@543 b4e56a0637c28e5fb65d3a33b7dfd1fea8bed55acbf5231bf26e0011168141fe |
| +internal/host/rootmovesfloor_refresh.go readRootFloorBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=checked root executable inode/digest must match independently verified signed identity envelope file:Open@684,file:Read@941,file:Stat@276 47062453004252de69e807a67ac94e40243d69b3a55033dacf2d8354a5d635a6 |
| +internal/host/rootmovesfloor_refresh.go rootFloorAdvertisementFor authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent reports accepted only for matching generation with signed compatible current and previous verdict file:Read@203 4e464b6add24a06b1c3d7a5859be7a1055750bc25f27c8ca641241be3baa2ef8 |
| +internal/host/rootmovesfloor_refresh.go rootFloorBinary.supports authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=signed digest/release are required; protocol minimum applies only when a retained floor exists, preserving unknown-history permission 7a6eaf5432f2668aa47f24d9c667355a985f83eadb8c4619a704168288b16fbf |
| +internal/host/rootmovesfloor_refresh.go rootFloorDowngradeIdle authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participation ledger and staged-generation pins may only refuse downgrade file:Open@68 8bb82e0b179e0dfadd6f375299d9f40ceea1c2b911b1d53f18fb4958957c74ac |
| +internal/host/rootmovesfloor_refresh.go rootFloorJudge.judge authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=revokes previous verdict before observation; unknown history checks signed current without accepted acknowledgement 12e0e178fb45ae956aa4b61a5ba8f1630c29ae067e7a50bf0fd0acf97952fc1f |
| +internal/host/rootmovesfloor_refresh.go rootFloorJudge.refresh authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=refreshes retained floor before signed current/previous judgement, even when previous is absent 362798a60b4e3d6fc798db52bfc204979f724efe33a858f3d7ca2c1ca12f6eaa |
| +internal/host/rootmovesfloor_refresh.go rootRefreshAndJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt opens deployment store then refreshes floor before any no-prev return 2d4d1ce1d6143555f3b94b19359c014ffb20272f41f756f7c02bbf6ff29a70a9 |
| +internal/host/rootmovesfloor_refresh.go withRootMovesFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent publishes only checked root public verdict and explicitly quarantines unreadable participant state file:Open@405 29fb652e22df888f18c691b91db6b83b9781d89d005a2fae431d8102e77b2f88 |
| +internal/host/rootmovesfloor_source.go readEffectiveMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root enforcement uses all readable retained denies; no census uses original local mirror file:Open@321 287a0c1120786ed693b2b138c40082fc9eb5f5104065ec317b32ac1e7473f7ae |
| +internal/host/rootmovesfloor_source.go readRootFloorProjection authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=checked root public read plus directory fsync precedes honest acknowledgement file:Open@83,file:Read@243 170e020e2f05437de39145566fdd4055cc11bd668fe24a1ca2458ece49195aeb |
| +internal/host/rootmovesfloor_source.go readRootLegacyFloorJSON authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=agent-writable legacy JSON is bounded strict deny-only evidence; never permission file:ReadAll@443,file:Stat@280 1af52c869107011648314ac12a9687ce641329b5a92fa7d487c278fa082bf4f2 |
| +internal/host/rootmovesfloor_source.go refreshRootMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=normal adopt/update combines remote object with independent deny-only legacy evidence under root floor lock a41df2c69e7d869b5232d545440b8135e8c7124d490965e641b8d1fba83cb490 |
| +internal/host/rootmovesfloor_source.go rootFloorLegacy authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=agent-writable mirrors contribute only componentwise denies; malformed sibling cannot erase usable evidence file:ReadAll@710,file:Stat@518 37e37f7b57fb768eea5cf750fb1a88a1a43b4239180878f5479a77f276dce424 |
| +internal/host/rootmovesfloor_source.go rootMovesFloorIdentity authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root-only census reader supplies deployment/box/enrollment; edge mode cannot call it 457cdc76e2cff8fdfe4d146c7baf086dd58d54f151ce9416c32efd0a03778d98 |
| +internal/host/rootmovesfloor_source.go withEffectiveMovesFloorLock authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root census path holds private stable floor lock; no-census path uses unchanged legacy exclusion 9c40f6835a408e2c3e7ad9251a424ae1a03013fe630296cf1a751271da39283b |
| +internal/host/systemapply.go RunSystemApply authority D,F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root system apply takes D3 release exclusion only for census participation before normal apply file:Open@1265 22440d8ad127c66958b9c4d06244db4ca1aec1b9dfd60fad8f5bceb24f789b25 |
| +internal/host/systemapply.go systemApplier.activationGuard authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=rechecks local root floor and root downgrade-idle proof before effects; registry holds add refusal only whole:Route@1083 49988cef27c704d1c937176f1bcad1d22a229beb18a8ef2a2ef949cd39fae9e6 |
| +internal/host/systemapply.go systemApplier.activationRule authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=provisional version/registry refusal check; every effect caller must subsequently pass activationGuard whole:Route@1019,whole:Route@810,whole:Route@817 bbe33714cf851396d775273545cece1aeed399f275bdaf303e126dc724ee0961 |
| +internal/host/systemapply.go systemApplier.armFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=normal update refreshes root floor in census mode; unknown history remains unacknowledged; seeds path pending root conversion d7e16eaaa3994896bf9acd763777cc4d8998056dfa2d492c5320395102009a16 |
| +internal/host/systemapply.go systemApplier.floorAllowsForward authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=forward candidate checked against census local root floor or main legacy floor fc96e9d74e0b9b32778ac4a86363e4095a08d0b11c45e6c1321b6da9082d0f66 |
| +internal/host/systemapply.go systemApplier.floorArmed authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root retained moves denies determine arming; legacy seeds arming still pending P3-R4 conversion 561ff8cfef8a66cc72ed1baef8729f2b1f513d4fae44acd7cac9b23648b6485c |
| +internal/host/systemapply.go systemApplier.layoutReversalReasons authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participation and retained floor contribute refusal reasons before layout reversal ee4e5ee2961e86b6d2e700601d57bb605c3c945515ffc678d35919d70dc2b0af |
| +internal/host/systemapply.go systemApplier.notePrev authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participants judge signed previous binary against retained state; legacy mode unchanged d55d08f057fb6ea467e293b4e7f0ca3e2e639d26147cab2d1e823530404ff2db |
| +internal/host/systemapply.go systemApplier.recoverIfNeeded authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=recovery keeps journal until local signed restore/forward checks finish; no remote floor refresh ea7db9e194e0beed0bba492f08a96ab6a46f2f9dc1e35d8e30abe9d0733d664c |
| +internal/host/systemapply.go systemApplier.restoreFiles authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=local signed previous identity supplies rollback version before copy/rename; never reads remote store file:Stat@226 85c25d0ff4f3e83f967f55afc39ed1f58e050285fc03eb56d70fc8fab0f9caff |
| +internal/host/systemapply.go systemApplier.restoreVersion authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=census rollback and recovery completion use signed previous executable identity; legacy sidecar remains only without census 57cf91fc81568e5b1ce05428ed928242c825c6847bb193333137845c7190d6c4 |
| +internal/host/systemapply.go systemApplier.verifyPointer authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=signed pointer verified and independently cached for census target execution before earlier history exists 50961a77b1c9b4e915302c6b95cd57d9f0bbc238e7bd00b94f06a5960ab541c1 |
| +internal/host/units/carlos-system-recover.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 f16b260868c4b258c804b3a4720e17f02f6c020eda9dcc2a67d2ca032a077b45 |
| +internal/host/units/carlos-system-update.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 cfcaeea6308021ce5a7cb088464e8df3abc6b03ffaa0c7a0153c6d91bf1e3fd6 |
| +internal/hostbox/migration_census_linux.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline scan requires every managed payload to match independent retained inode/type inventory or explicit shared path rules file:Lstat@1678,file:Lstat@932,file:WalkDir@1202 3eb5e56f13b0932364b68c7d80ad18135b262614b105c41bfcc3477d6f558e79 |
| +internal/hostbox/migration_census_other.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline scan requires every managed payload to match independent retained inode/type inventory or explicit shared path rules 594ecda260fcf80baf5a2709c9cc5a04e91c655c8bd2f7ac307eef44216eb9c8 |
| +internal/hostguard/guard.go record.admits authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=existing lease checks additionally refuse migration-retired sessions 3e0a40e659744b48ed8aa3f3f6cd367d45098bbcbcffba4c6876d62e34856f3f |
| +internal/hostguard/root.go Guard.CommitRootInventoryArtifacts authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=retained migration deny refuses inventory commit for an old session before authority transfer edd70bbf6c152e17d73a39fb7cd0d6ebf7382ae2bf1f80160f3ddc2c4c827937 |
| +internal/hostguard/root.go RootAuthority.read authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=strict checked root record decoding includes retained migration session denials file:Read@175 7ff862f573a05a17951546a56ff791d42962d693331a69e8338bd8eb4f283d6b |
| +internal/hostguard/root.go RootAuthority.write authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=retains migration denials atomically with the existing root installation, fence and retired-state record 678156528bb5d3e92f8c07835ff7a7d05afb5d498ff604966d2b6d9293867c0c |
| +internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=root completed fence receipt and retained session deny required before installing recovered inventory revision a548470f72eb9f5f9a457c292e06a303f4a7dca2068f10eda5fcbb4e118ab783 |
| +internal/hostguard/rootmigration.go RootAuthority.RetainMigrationDeny authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=root retains old incarnation/stamp/session denial under stable per-host lock; grants no launch authority fb821d19b6ca6e1454d025013d27ef8a856a684262421d3ee76804723e875c37 |
| +internal/hostguard/rootmigration.go migrationDenied authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=old sessions at/below retained captured stamp refuse root and agent admission 6ccf5c00e33d880aa50ea15d2bce22a3cce14455d5cc7f05197e42f789da8805 |
| +internal/hostguard/rootmigration_inventory.go RootAuthority.MigrationInstallations authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=read-only root installation enumeration includes incomplete records and never repairs before preflight ad855b461cb12e5499ecec74b210f6f41a758a3ed36c0eac0ef5931bc7fad6c2 |
| +internal/hostguard/rootview.go FenceView.admit authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=agent view applies the same retained root migration session denial f512598847c55a6c585206dc8e1daf7f6dd8625c4d408795ffe72ef3a442982a |
| +internal/hostguard/rootview.go Guard.fenceLocked authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=projects root-only migration denials into agent admission snapshot without trusting local hostguard records 290295ed69e06548a41e101317098ae2d6aac10c41b260e120af55860686a96d |
| +internal/hostguard/rootview.go RootAuthority.viewLocked authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=projects root migration denials together with canonical installation and existing fences 5db6824b38f6ad3e4072008ffa3ff9fab98db6a5d40ecba59ca30900e331a46f |
| +internal/rootstate/fault_rootrunner_linux.go WithWriteFault diagnostic A,F rev3=PROPOSED-p3;identity=rootrunner test binary uid 0 only;basis=rootrunner-only wrapper injects failures into the same validated production write steps; absent from shipped binary 31c00dbe4dd1441867d2a1184598be8a8fc5aa743e6f4227968b3b7f50d9c11d |
| +internal/rootstate/fault_rootrunner_linux.go faultStore.Write diagnostic A,F rev3=PROPOSED-p3;identity=rootrunner test binary uid 0 only;basis=rootrunner-only descriptor-backed fault wrapper, never a production authority source 353d08820be5e4ecf0076826fc5e46b00ce7bbb5a8f831e05e7e50225a72aded |
| +internal/rootstate/state.go Sync authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=syncs an existing checked root descriptor before acknowledgement 2e5d0a1411eff4cfb942f20517ecc2653a05ae3e4d586c14092d65de48ceff02 |
| +internal/rootstate/state_linux.go directory.Sync authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=rechecks private descriptor identity then fsyncs directory durability b932169c2132a038eb12459c8537f0f308d4ba5987d5d3f296f2bd435f598fe3 |
| +internal/rootstate/state_linux.go directory.Write authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=delegates to shared validated durable-write implementation; production syscall behavior unchanged 3d117162b0a08938a6a1f83fa57dadcb0abda157565feb96159063db4505c67a |
| +internal/rootstate/state_linux.go directory.write authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=rechecks descriptor and uses same validated durable write for production and privileged fault wrapper file:Read@289 87c8f8ba3b34012a0bafb198116719039215ac34b0d09d0ff705a5eb22cb2f35 |
| +internal/safefile/fault_rootrunner_linux.go WriteAtFault diagnostic A,F rev3=PROPOSED-p3;identity=rootrunner test binary uid 0 only;basis=rootrunner-only seam runs actual safefile operations before/after chosen interruption dff2fd53a3561df3aa04b6f7462ab62c4cc32c826d152464989b6a399d83056c |
| +internal/safefile/file_linux.go writeAt authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=production wrapper always invokes each real syscall operation eadb1d4dd961ebc7dc7ba8a469669003695d618d6c0dc13794763c7fa881d22c |
| +internal/safefile/file_linux.go writeAtSteps authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=checked staged file write, chmod, sync, close, rename and parent sync propagate interruptions before acknowledgement file:Fstat@1116,file:Fstat@700,file:Openat@522,file:Openat@875,file:Read@277 865b847b97df6753e1abd16a00d018d27cdabdd23c96d42a97b381103d088d21 |
| +@coverage internal/console 5ce330f276a80d2ae901698ac81c7e2861f6363ba70aa1e5f9164b76c0c03bd8 |
| @coverage internal/edgesfile 46558f38ee9cef8def5a1915a2545e91795ede008b4b296f102a7ffd45dd1b82 |
| -@coverage internal/host f3a1d713d0139a5ceb8090e826e54c2a471d355b49451f9af806f645dcbedc79 |
| -@coverage internal/host/units 4ec2973162254c97bad311eb1e10536e5aea1e53f4c4614238e350e94a8e0095 |
| -@coverage internal/rootstate 058f59efad955ffeb098d79b4bda9dd9407078fb302dea501624b81297be4cf7 |
| +@coverage internal/host 92c9b29e256e6afa8fe90457752c2dc86f3cecb2340af0f655d097bef164a885 |
| +@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461 |
| +@coverage internal/hostbox 85594e031100a619d3bae5e555dcda2e496bdc4d91d0a53b0f4da0dbe6bbd261 |
| +@coverage internal/hostguard f97ec930b80a6b09fec120e4b25f17a01012400dcf433e96586a4140b6db2c63 |
| +@coverage internal/rootstate 8415892a08d6c9c1b2d71aa25b0952cbcaa53e533213b7fac56dd0666ee787cf |
| +@coverage internal/safefile 7030f312f7a60487381f0dbae2cdecaaa83625b639b6af15104f912592550816 |
diff --git a/internal/rootstate/fault_rootrunner_linux.go b/internal/rootstate/fault_rootrunner_linux.go| new file mode 100644 |
| index 000000000..c8387d7c3 |
| --- /dev/null |
| +++ b/internal/rootstate/fault_rootrunner_linux.go |
| @@ -0,0 +1,27 @@ |
| +//go:build linux && rootrunner |
| + |
| +package rootstate |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/safefile" |
| + "os" |
| +) |
| + |
| +type faultStore struct { |
| + Store |
| + step func(string, string, func() error) error |
| +} |
| + |
| +func WithWriteFault(s Store, step func(string, string, func() error) error) Store { |
| + return faultStore{Store: s, step: step} |
| +} |
| + |
| +func (s faultStore) Write(name string, body []byte) error { |
| + d, ok := s.Store.(*directory) |
| + if !ok { |
| + return os.ErrPermission |
| + } |
| + return d.write(name, body, func(fd int, name string, body []byte, mode os.FileMode) error { |
| + return safefile.WriteAtFault(fd, name, body, mode, func(stage string, op func() error) error { return s.step(name, stage, op) }) |
| + }) |
| +} |
diff --git a/internal/rootstate/state_linux.go b/internal/rootstate/state_linux.go| index d86aab80d..e1122aae5 100644 |
| --- a/internal/rootstate/state_linux.go |
| +++ b/internal/rootstate/state_linux.go |
| @@ -147,6 +147,10 @@ func (d *directory) Read(name string) ([]byte, error) { |
| } |
| func (d *directory) Write(name string, body []byte) error { |
| + return d.write(name, body, safefile.WriteAt) |
| +} |
| + |
| +func (d *directory) write(name string, body []byte, write func(int, string, []byte, os.FileMode) error) error { |
| if !component(name) || len(body) > MaxRecord || uint32(os.Geteuid()) != d.owner { |
| return os.ErrPermission |
| } |
| @@ -156,7 +160,7 @@ func (d *directory) Write(name string, body []byte) error { |
| if _, err := d.Read(name); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return err |
| } |
| - return safefile.WriteAt(int(d.file.Fd()), name, body, fileMode(d.public)) |
| + return write(int(d.file.Fd()), name, body, fileMode(d.public)) |
| } |
| func (d *directory) Names() ([]string, error) { |
diff --git a/internal/safefile/fault_rootrunner_linux.go b/internal/safefile/fault_rootrunner_linux.go| new file mode 100644 |
| index 000000000..e70615b02 |
| --- /dev/null |
| +++ b/internal/safefile/fault_rootrunner_linux.go |
| @@ -0,0 +1,12 @@ |
| +//go:build linux && rootrunner |
| + |
| +package safefile |
| + |
| +import ( |
| + "io" |
| + "os" |
| +) |
| + |
| +func WriteAtFault(dir int, name string, body []byte, mode os.FileMode, step func(string, func() error) error) error { |
| + return writeAtSteps(dir, name, mode, func(w io.Writer) error { _, err := w.Write(body); return err }, step) |
| +} |
diff --git a/internal/safefile/file_linux.go b/internal/safefile/file_linux.go| index ce729d0ef..af06c15ed 100644 |
| --- a/internal/safefile/file_linux.go |
| +++ b/internal/safefile/file_linux.go |
| @@ -139,6 +139,10 @@ func OpenDir(path string) (*os.File, error) { |
| } |
| func writeAt(dir int, path string, mode os.FileMode, write func(io.Writer) error) error { |
| + return writeAtSteps(dir, path, mode, write, func(_ string, op func() error) error { return op() }) |
| +} |
| + |
| +func writeAtSteps(dir int, path string, mode os.FileMode, write func(io.Writer) error, step func(string, func() error) error) error { |
| if path != filepath.Base(path) || path == "." || path == ".." { |
| return fmt.Errorf("invalid file name") |
| } |
| @@ -176,20 +180,20 @@ func writeAt(dir int, path string, mode os.FileMode, write func(io.Writer) error |
| if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| return fmt.Errorf("not a regular file with one link") |
| } |
| - if err := write(f); err != nil { |
| + if err := step("write", func() error { return write(f) }); err != nil { |
| return err |
| } |
| - if err := f.Chmod(mode); err != nil { |
| + if err := step("chmod", func() error { return f.Chmod(mode) }); err != nil { |
| return err |
| } |
| - if err := f.Sync(); err != nil { |
| + if err := step("file-sync", f.Sync); err != nil { |
| return err |
| } |
| - if err := f.Close(); err != nil { |
| + if err := step("close", f.Close); err != nil { |
| return err |
| } |
| - if err := unix.Renameat(stage, "data", dir, filepath.Base(path)); err != nil { |
| + if err := step("rename", func() error { return unix.Renameat(stage, "data", dir, filepath.Base(path)) }); err != nil { |
| return err |
| } |
| - return unix.Fsync(dir) |
| + return step("parent-sync", func() error { return unix.Fsync(dir) }) |
| } |
diff --git a/internal/safefile/plan14_sync_linux_test.go b/internal/safefile/plan14_sync_linux_test.go| new file mode 100644 |
| index 000000000..17fbd002c |
| --- /dev/null |
| +++ b/internal/safefile/plan14_sync_linux_test.go |
| @@ -0,0 +1,53 @@ |
| +package safefile |
| + |
| +import ( |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestPlan14DurableWriteInterruptedSync(t *testing.T) { |
| + for _, point := range []string{"file-sync", "parent-sync"} { |
| + t.Run(point, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "floor") |
| + if err := os.WriteFile(path, []byte("old"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + fd, err := OpenDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer fd.Close() |
| + interrupted := errors.New("sync interrupted") |
| + err = writeAtSteps(int(fd.Fd()), "floor", 0600, func(w io.Writer) error { _, e := w.Write([]byte("new")); return e }, func(stage string, op func() error) error { |
| + if e := op(); e != nil { |
| + return e |
| + } |
| + if stage == point { |
| + return interrupted |
| + } |
| + return nil |
| + }) |
| + if !errors.Is(err, interrupted) { |
| + t.Fatal("lost durability failure", err) |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "old" |
| + if point == "parent-sync" { |
| + want = "new" |
| + } |
| + if string(body) != want { |
| + t.Fatal("publication crossed unsynced boundary", string(body)) |
| + } |
| + if err := WriteAt(int(fd.Fd()), "floor", []byte("new"), 0600); err != nil { |
| + t.Fatal("retry", err) |
| + } |
| + }) |
| + } |
| +} |