WIP claim Plan 14 Part 3 checkpoint 1 (not for merge)
F2-F4: root moves floor persistence and reconstruction, refresh with both prev branches, both generated boot selectors; G1 audited; 47 PROPOSED-p3 census rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 files changed,
+2227
−17
internal/console/boxselect.go+1 −1internal/console/boxselect_test.go+3 −0internal/console/plan14_root_floor_test.go+57 −0internal/edgesfile/snapshot.go+2 −0internal/host/edgesfile.go+2 −0internal/host/guardarm.go+18 −0internal/host/guardexec.go+19 −0internal/host/guardtransaction.go+16 −0internal/host/movesfloor.go+20 −1internal/host/movesfloorgate.go+3 −0internal/host/rootmovesfloor.go+293 −0internal/host/rootmovesfloor_boot.go+112 −0internal/host/rootmovesfloor_boot_root_linux_test.go+248 −0internal/host/rootmovesfloor_boot_test.go+83 −0internal/host/rootmovesfloor_identity.go+36 −0internal/host/rootmovesfloor_refresh.go+259 −0internal/host/rootmovesfloor_refresh_test.go+123 −0internal/host/rootmovesfloor_root_linux_test.go+362 −0internal/host/rootmovesfloor_source.go+202 −0internal/host/rootmovesfloor_test.go+184 −0internal/host/seedsfloor_test.go+4 −4internal/host/systemapply.go+78 −8internal/host/units/carlos-system-recover.service+1 −1internal/host/units/carlos-system-update.service+1 −1internal/provision/plan14_p3_census_test.go+27 −0internal/provision/plan14_root_reads_test.go+3 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-p3.tsv+54 −0internal/rootstate/state.go+9 −0internal/rootstate/state_linux.go+6 −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 285.757s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.777s
ok github.com/carlosframework/platform/cmd/moveprobe 92.475s
ok github.com/carlosframework/platform/cmd/zddprobe 0.721s
ok github.com/carlosframework/platform/internal/activator 250.238s
ok github.com/carlosframework/platform/internal/agentsock 0.013s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.496s
ok github.com/carlosframework/platform/internal/apperr 0.076s
ok github.com/carlosframework/platform/internal/applog 0.129s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.679s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.704s
ok github.com/carlosframework/platform/internal/changelog 0.355s
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 389.056s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.720s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.355s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.726s
? 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.134s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.020s
ok github.com/carlosframework/platform/internal/geo 0.024s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.163s
ok github.com/carlosframework/platform/internal/host 143.065s
ok github.com/carlosframework/platform/internal/hostactors 0.411s
ok github.com/carlosframework/platform/internal/hostbox 2.363s
ok github.com/carlosframework/platform/internal/hostclaim 0.598s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.195s
ok github.com/carlosframework/platform/internal/hostfence 0.035s
ok github.com/carlosframework/platform/internal/hostfiles 10.525s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 1.038s
ok github.com/carlosframework/platform/internal/hostops 1.679s
? 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.501s
ok github.com/carlosframework/platform/internal/hostresolve 0.163s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 4.173s
ok github.com/carlosframework/platform/internal/instanceexec 2.770s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.558s
ok github.com/carlosframework/platform/internal/lease 9.823s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.192s
ok github.com/carlosframework/platform/internal/maintenance 0.081s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.535s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.110s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.070s
ok github.com/carlosframework/platform/internal/moves/archive 0.822s
ok github.com/carlosframework/platform/internal/objstore 0.196s
ok github.com/carlosframework/platform/internal/ops 0.174s
ok github.com/carlosframework/platform/internal/park 0.252s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 17.346s
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.448s
ok github.com/carlosframework/platform/internal/relay 7.245s
ok github.com/carlosframework/platform/internal/release 21.511s
ok github.com/carlosframework/platform/internal/rollout 3.922s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.009s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.586s
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.530s
ok github.com/carlosframework/platform/internal/seeds 0.015s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.899s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.995s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.298s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.007s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.620s
=== 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: 47120546 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/15 13:49:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEB82227785725, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go| index 605ff0a2e..82bd519a2 100644 |
| --- a/internal/console/boxselect.go |
| +++ b/internal/console/boxselect.go |
| @@ -102,7 +102,7 @@ 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.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| + if f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0 || f.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| sel.FloorOK = false |
| } |
| } |
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go| index 0c3ee66d2..21680f0f8 100644 |
| --- a/internal/console/boxselect_test.go |
| +++ b/internal/console/boxselect_test.go |
| @@ -104,6 +104,7 @@ 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 |
| @@ -113,6 +114,8 @@ 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 |
diff --git a/internal/console/plan14_root_floor_test.go b/internal/console/plan14_root_floor_test.go| new file mode 100644 |
| index 000000000..7e2e22024 |
| --- /dev/null |
| +++ b/internal/console/plan14_root_floor_test.go |
| @@ -0,0 +1,57 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) { |
| + for _, state := range []string{"accepted", "missing", "quarantined", "old generation", "old floor", "old prev", "compatible prev"} { |
| + t.Run(state, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.rel.Put(t.Context(), moves.FloorKey, []byte(`{"min":1,"min_release":"v2.0.0"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box")) |
| + key := edgesFilePrefix + "uk-box.json" |
| + raw, etag, err := fx.rel.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m map[string]any |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch state { |
| + case "missing": |
| + delete(m, "moves_floor_state") |
| + case "quarantined": |
| + m["moves_floor_state"] = "quarantined" |
| + case "old generation": |
| + m["moves_floor_generation"] = 0 |
| + case "old floor": |
| + m["moves_floor_min_release"] = "v1.0.0" |
| + case "old prev": |
| + m["prev_release"] = "v1.0.0" |
| + m["prev_allowed_for"] = "v1.0.0" |
| + case "compatible prev": |
| + m["prev_release"] = "v2.0.0" |
| + m["prev_allowed_for"] = "v2.0.0" |
| + } |
| + raw, _ = json.Marshal(m) |
| + if _, err := fx.rel.Put(t.Context(), key, raw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := fx.srv.selectMoveBoxes(t.Context(), "acme.go.tito.io", "uk", "ie", "bab/titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := state == "accepted" || state == "compatible prev" |
| + if got.FloorOK != want { |
| + t.Fatalf("FloorOK=%v want %v", got.FloorOK, want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/edgesfile/snapshot.go b/internal/edgesfile/snapshot.go| index 10308ccbb..7efff95fb 100644 |
| --- a/internal/edgesfile/snapshot.go |
| +++ b/internal/edgesfile/snapshot.go |
| @@ -72,6 +72,8 @@ type PlacementView struct { |
| // carlos.prev was judged at or above. |
| MovesProtocol int `json:"moves_protocol"` |
| MovesFloorMinRelease string `json:"moves_floor_min_release"` |
| + MovesFloorState string `json:"moves_floor_state,omitempty"` |
| + MovesFloorGeneration uint64 `json:"moves_floor_generation,omitempty"` |
| PrevRelease string `json:"prev_release"` |
| PrevAllowedFor string `json:"prev_allowed_for"` |
| // The seeds floor (seeded-instances spec §3.3), read only by the |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index bc848fa75..d8689a643 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -296,6 +296,8 @@ type edgesFile struct { |
| // MovesFloorMinRelease is the min_release of the floor armed on this box |
| // (<DataDir>/.moves/floor.min-release); absent: none armed. |
| MovesFloorMinRelease string `json:"moves_floor_min_release,omitempty"` |
| + MovesFloorState string `json:"moves_floor_state,omitempty"` |
| + MovesFloorGeneration uint64 `json:"moves_floor_generation,omitempty"` |
| // PrevRelease is carlos.prev's release (carlos.prev.version); "none" |
| // when there is no carlos.prev; absent when it is unknown. |
| PrevRelease string `json:"prev_release,omitempty"` |
diff --git a/internal/host/guardarm.go b/internal/host/guardarm.go| index e9c8d5665..6a37a8919 100644 |
| --- a/internal/host/guardarm.go |
| +++ b/internal/host/guardarm.go |
| @@ -129,6 +129,24 @@ func RunSystemGuardArm(args []string) { |
| log.Fatal(err) |
| } |
| } |
| + // A migrated box retains its recovery checker until the root floor and |
| + // selector are durable. No-census arming keeps its original ordering. |
| + if id, required, e := rootMovesFloorIdentity(); e != nil { |
| + log.Fatal(e) |
| + } else if required { |
| + projection, e := readRootFloorProjection(release.OptDir()) |
| + if e != nil || projection.Identity != id { |
| + log.Fatal(errRootFloorHistory) |
| + } |
| + signed, e := release.VerifySystemIdentity(d.PublicKey, identity) |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + image := rootFloorBinary{Version: signed.Current.ReleaseTag, Protocol: signed.Current.MovesProtocol, Digest: hash} |
| + if !image.supports(projection.Floor) { |
| + log.Fatal(errRootFloorHistory) |
| + } |
| + } |
| // The checker is outside system/router and outside every restore set. |
| if err = copyFileDurable("/proc/self/exe", filepath.Join(d.Dir, "carlos"), 0755); err != nil { |
| log.Fatal(err) |
diff --git a/internal/host/guardexec.go b/internal/host/guardexec.go| index a5c1bdb3f..ddc2268ef 100644 |
| --- a/internal/host/guardexec.go |
| +++ b/internal/host/guardexec.go |
| @@ -85,6 +85,25 @@ func RunSystemGuardExec(args []string) { |
| default: |
| log.Fatal(hostfloor.ErrFloor) |
| } |
| + if component == "router" { |
| + id, required, e := rootMovesFloorIdentity() |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + if required { |
| + journal, e := rootFloorJournalPresent(release.OptDir()) |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + e = withRootFloorGuardCandidate(context.Background(), release.OptDir(), id, journal && len(args) > 1 && args[0] == "system", func(selected string) error { |
| + return d.Exec(selected, runtime.GOOS+"-"+runtime.GOARCH, args, os.Environ(), component) |
| + }) |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + return |
| + } |
| + } |
| if err = d.Exec(candidate, runtime.GOOS+"-"+runtime.GOARCH, args, os.Environ(), component); err != nil { |
| log.Fatal(err) |
| } |
diff --git a/internal/host/guardtransaction.go b/internal/host/guardtransaction.go| index 30ef1013d..067bfaf5b 100644 |
| --- a/internal/host/guardtransaction.go |
| +++ b/internal/host/guardtransaction.go |
| @@ -358,6 +358,22 @@ func (a *systemApplier) closeGuardJournal() error { |
| return a.endGuard() |
| } |
| func (a *systemApplier) guardRun(ctx context.Context, wait time.Duration, name string, args ...string) (string, error) { |
| + if _, required, err := a.rootMoves.read(); err != nil { |
| + return "", err |
| + } else if required { |
| + binary, err := readRootFloorBinary(a.optDir, name) |
| + if err != nil { |
| + return "", err |
| + } |
| + floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !binary.supports(floor) { |
| + return "", errRootFloorHistory |
| + } |
| + } |
| + |
| if !a.guarded() { |
| return a.ops.run(ctx, wait, name, args...) |
| } |
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go| index 081203653..7c38d9b0e 100644 |
| --- a/internal/host/movesfloor.go |
| +++ b/internal/host/movesfloor.go |
| @@ -454,7 +454,11 @@ func withPrevLock(optDir string, fn func() error) error { |
| // half, then the seeds half (seedsfloor.go). Each runs whatever the other |
| // returns. |
| func rootJudgePrev(reg *registry.Registry) error { |
| - return errors.Join(rootJudgeMovesPrev(reg), rootJudgeSeedsPrev()) |
| + return rootJudgePrevWithSource(reg, rootMovesFloorIdentity) |
| +} |
| + |
| +func rootJudgePrevWithSource(reg *registry.Registry, source rootFloorIdentitySource) error { |
| + return errors.Join(rootJudgeMovesPrevWithSource(reg, source), rootJudgeSeedsPrev()) |
| } |
| // rootJudgeMovesPrev is the moves half: the same judge decidePrev runs, |
| @@ -462,6 +466,15 @@ func rootJudgePrev(reg *registry.Registry) error { |
| // the sidecar is missing (a box whose carlos.prev was installed by a build |
| // before the floor existed). |
| func rootJudgeMovesPrev(reg *registry.Registry) error { |
| + return rootJudgeMovesPrevWithSource(reg, rootMovesFloorIdentity) |
| +} |
| +func rootJudgeMovesPrevWithSource(reg *registry.Registry, source rootFloorIdentitySource) error { |
| + if id, required, err := source.read(); err != nil { |
| + return err |
| + } else if required { |
| + return rootRefreshAndJudgeMovesPrev(id) |
| + } |
| + |
| optDir, dd := release.OptDir(), dataDir() |
| if _, err := os.Stat(filepath.Join(dd, moves.FloorFile)); errors.Is(err, os.ErrNotExist) { |
| // No floor armed: nothing to judge and no verdict to keep. The one |
| @@ -508,6 +521,12 @@ func movesFloorLoop(ctx context.Context, s release.Store, dataDir string) { |
| // there is no carlos.prev; absent: unknown) and the verdict (absent: no |
| // verdict, which the door reads as below the floor). |
| func withMovesFields(f edgesFile, dataDir, optDir string) edgesFile { |
| + // The public root marker is the agent's mode indication; it cannot inspect |
| + // the private census. G2 installs this marker before completing migration. |
| + if _, err := os.Lstat(rootFloorDir(optDir)); !errors.Is(err, os.ErrNotExist) { |
| + return withRootMovesFields(f, optDir) |
| + } |
| + |
| f.MovesProtocol = moves.ProtocolVersion |
| // From floor.json, the file every Go reader trusts: floor.min-release |
| // is written first and can be ahead of it (writeFloorFiles). |
diff --git a/internal/host/movesfloorgate.go b/internal/host/movesfloorgate.go| index 075856a4c..336d157c9 100644 |
| --- a/internal/host/movesfloorgate.go |
| +++ b/internal/host/movesfloorgate.go |
| @@ -67,6 +67,9 @@ func RunSystemFloorAllowsPrev(args []string) { |
| // carlos.prev may not run. It returns the exit code. |
| func floorGateMain(args []string, floorOwners, verdictOwners []uint32, stderr io.Writer) int { |
| err := errFloorGateUsage |
| + if len(args) == 2 && args[0] == "--root" { |
| + err = rootFloorAllowsPrev(args[1]) |
| + } |
| if len(args) == 2 && filepath.IsAbs(args[0]) && filepath.IsAbs(args[1]) { |
| err = floorAllowsPrev(args[0], args[1], floorOwners, verdictOwners) |
| } |
diff --git a/internal/host/rootmovesfloor.go b/internal/host/rootmovesfloor.go| new file mode 100644 |
| index 000000000..537bb00d0 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor.go |
| @@ -0,0 +1,293 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const ( |
| + rootFloorAccepted = "accepted.json" |
| + rootFloorDeny = "deny.json" |
| + rootFloorSelector = "floor.line" |
| + rootFloorProjectionFile = "accepted.json" |
| +) |
| + |
| +type rootFloorIdentity struct{ Deployment, Box, Enrollment string } |
| +type rootFloorReconstruction struct { |
| + Identity rootFloorIdentity |
| + Floor *moves.Floor |
| + NeverArmed bool |
| + Source, Digest, Authorization string |
| +} |
| +type rootFloorRecord struct { |
| + Schema int |
| + Identity rootFloorIdentity |
| + Generation uint64 |
| + Floor *moves.Floor |
| + NeverArmed bool |
| + Provenance rootFloorReconstruction |
| +} |
| +type rootFloorProjection struct { |
| + rootFloorRecord |
| + Accepted bool |
| +} |
| +type rootMovesFloors struct { |
| + private, public rootstate.Store |
| + identity rootFloorIdentity |
| +} |
| + |
| +func rootFloorDir(opt string) string { return filepath.Join(opt, "system", "guard", "moves") } |
| +func openRootMovesFloors(opt string, id rootFloorIdentity, create bool) (rootMovesFloors, error) { |
| + f := rootMovesFloors{identity: id} |
| + var err error |
| + f.public, err = rootstate.Open(rootFloorDir(opt), create, true) |
| + if err != nil { |
| + return f, err |
| + } |
| + f.private, err = rootstate.Open(filepath.Join(rootFloorDir(opt), "private"), create, false) |
| + if err != nil { |
| + f.public.Close() |
| + return rootMovesFloors{}, err |
| + } |
| + return f, nil |
| +} |
| +func (f rootMovesFloors) close() { f.private.Close(); f.public.Close() } |
| +func validRootFloor(fl *moves.Floor) bool { |
| + return fl != nil && fl.Min > 0 && validFloorRelease(fl.MinRelease) |
| +} |
| +func (r rootFloorRecord) valid(id rootFloorIdentity) bool { |
| + return r.Schema == 1 && r.Identity == id && id.Deployment != "" && id.Box != "" && id.Enrollment != "" && r.Generation > 0 && ((validRootFloor(r.Floor) && !r.NeverArmed) || (r.Floor == nil && r.NeverArmed)) |
| +} |
| +func (f rootMovesFloors) record(name string) (rootFloorRecord, error) { |
| + var r rootFloorRecord |
| + raw, err := f.private.Read(name) |
| + if err != nil { |
| + return r, err |
| + } |
| + if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &r) != nil || !r.valid(f.identity) { |
| + return r, errRootFloorHistory |
| + } |
| + return r, nil |
| +} |
| +func floorMaximum(a, b *moves.Floor) *moves.Floor { |
| + if a == nil { |
| + if b == nil { |
| + return nil |
| + } |
| + c := *b |
| + return &c |
| + } |
| + c := *a |
| + if b != nil { |
| + if b.Min > c.Min { |
| + c.Min = b.Min |
| + } |
| + if at, _ := moves.ReleaseAtOrAbove(b.MinRelease, c.MinRelease); at { |
| + c.MinRelease = b.MinRelease |
| + } |
| + } |
| + return &c |
| +} |
| +func rootFloorLine(fl *moves.Floor) []byte { |
| + if fl == nil { |
| + return []byte("0 none\n") |
| + } |
| + return []byte(fmt.Sprintf("%d %s\n", fl.Min, fl.MinRelease)) |
| +} |
| +func (f rootMovesFloors) selector() (*moves.Floor, error) { |
| + raw, err := f.public.Read(rootFloorSelector) |
| + if err != nil { |
| + return nil, err |
| + } |
| + fields := strings.Fields(string(raw)) |
| + if len(raw) > 256 || len(fields) != 2 { |
| + return nil, errRootFloorHistory |
| + } |
| + if string(raw) == "0 none\n" { |
| + return nil, nil |
| + } |
| + min, err := strconv.Atoi(fields[0]) |
| + fl := &moves.Floor{Min: min, MinRelease: fields[1]} |
| + if err != nil || !validRootFloor(fl) || string(raw) != string(rootFloorLine(fl)) { |
| + return nil, errRootFloorHistory |
| + } |
| + return fl, nil |
| +} |
| + |
| +// A selector visible after an interrupted write remains a deny, even when the |
| +// accepted JSON still names the preceding generation. |
| +func (f rootMovesFloors) bound() (*moves.Floor, error) { |
| + var bound *moves.Floor |
| + var errs []error |
| + 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) { |
| + errs = append(errs, err) |
| + } |
| + } |
| + fl, err := f.selector() |
| + if err == nil { |
| + bound = floorMaximum(bound, fl) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + errs = append(errs, err) |
| + } |
| + return bound, errors.Join(errs...) |
| +} |
| +func (f rootMovesFloors) quarantine(cause error) error { |
| + raw, _ := json.Marshal(rootFloorProjection{Accepted: false}) |
| + return errors.Join(cause, f.public.Write(rootFloorProjectionFile, raw)) |
| +} |
| +func (f rootMovesFloors) projection() (rootFloorProjection, error) { |
| + var p rootFloorProjection |
| + raw, err := f.public.Read(rootFloorProjectionFile) |
| + if err != nil { |
| + return p, err |
| + } |
| + if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &p) != nil || !p.Accepted || !p.rootFloorRecord.valid(f.identity) { |
| + return p, errRootFloorHistory |
| + } |
| + bound, err := f.selector() |
| + if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) { |
| + return p, errRootFloorHistory |
| + } |
| + if f.private != nil { |
| + accepted, err := f.record(rootFloorAccepted) |
| + if err != nil || accepted.Generation != p.Generation || accepted.Identity != p.Identity || string(rootFloorLine(accepted.Floor)) != string(rootFloorLine(p.Floor)) { |
| + return p, errRootFloorHistory |
| + } |
| + bound, err := f.bound() |
| + if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) { |
| + return p, errRootFloorHistory |
| + } |
| + } |
| + return p, nil |
| +} |
| +func (f rootMovesFloors) writeRecord(name string, r rootFloorRecord) error { |
| + raw, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + return f.private.Write(name, raw) |
| +} |
| + |
| +// Caller holds the stable private floor lock. Every retry rewrites and syncs |
| +// all prerequisites; visible bytes are never taken as proof of an earlier sync. |
| +func (f rootMovesFloors) persist(r rootFloorRecord, bound *moves.Floor, accept bool) error { |
| + if err := f.quarantine(nil); err != nil { |
| + return err |
| + } |
| + if err := f.public.Write(rootFloorSelector, rootFloorLine(bound)); err != nil { |
| + return err |
| + } |
| + deny := r |
| + deny.Floor = bound |
| + deny.NeverArmed = bound == nil |
| + if err := f.writeRecord(rootFloorDeny, deny); err != nil { |
| + return err |
| + } |
| + if !accept { |
| + return errRootFloorHistory |
| + } |
| + if err := f.writeRecord(rootFloorAccepted, r); err != nil { |
| + return err |
| + } |
| + raw, _ := json.Marshal(rootFloorProjection{rootFloorRecord: r, Accepted: true}) |
| + if err := f.public.Write(rootFloorProjectionFile, raw); err != nil { |
| + return f.quarantine(err) |
| + } |
| + return nil |
| +} |
| + |
| +// This entry is only for a root-authorized complete reconstruction, including |
| +// an explicit never-armed attestation. Normal refresh cannot manufacture it. |
| +func (f rootMovesFloors) reconstruct(ctx context.Context, r rootFloorReconstruction) error { |
| + unlock, err := f.private.Lock(ctx, "floor.lock") |
| + if err != nil { |
| + 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)) { |
| + return f.quarantine(errRootFloorHistory) |
| + } |
| + bound, err := f.bound() |
| + if err != nil { |
| + return f.quarantine(err) |
| + } |
| + if bound != nil && (r.Floor == nil || floorRegression(bound, r.Floor) != nil) { |
| + return f.quarantine(errRootFloorHistory) |
| + } |
| + generation := uint64(1) |
| + old, err := f.record(rootFloorAccepted) |
| + if err == nil { |
| + generation = old.Generation + 1 |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return f.quarantine(err) |
| + } |
| + 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) |
| + } |
| + return f.persist(next, floorMaximum(bound, r.Floor), true) |
| +} |
| + |
| +func (f rootMovesFloors) refresh(ctx context.Context, remote *moves.Floor, legacy []*moves.Floor, readErr error) error { |
| + unlock, err := f.private.Lock(ctx, "floor.lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + accepted, aerr := f.record(rootFloorAccepted) |
| + bound, berr := f.bound() |
| + for _, fl := range legacy { |
| + if validRootFloor(fl) { |
| + bound = floorMaximum(bound, fl) |
| + } else { |
| + readErr = errors.Join(readErr, errRootFloorHistory) |
| + } |
| + } |
| + if remote != nil && !validRootFloor(remote) { |
| + readErr = errors.Join(readErr, errRootFloorHistory) |
| + remote = nil |
| + } |
| + // Even uninitialized or corrupt histories retain every usable observed deny. |
| + next := accepted |
| + if aerr != nil { |
| + next = rootFloorRecord{Schema: 1, Identity: f.identity, Generation: 1} |
| + } |
| + next.Generation++ |
| + refusal := errors.Join(readErr, berr, aerr) |
| + if remote != nil { |
| + if bound != nil { |
| + refusal = errors.Join(refusal, floorRegression(bound, remote)) |
| + } |
| + bound = floorMaximum(bound, remote) |
| + } |
| + if refusal == nil && remote == nil && string(rootFloorLine(bound)) != string(rootFloorLine(accepted.Floor)) { |
| + refusal = errRootFloorHistory |
| + } |
| + if refusal == nil { |
| + next.Floor = bound |
| + next.NeverArmed = bound == nil |
| + } |
| + // A corrupt selector cannot be safely overwritten from incomplete evidence. |
| + if berr != nil { |
| + return f.quarantine(refusal) |
| + } |
| + if refusal == nil && !next.valid(f.identity) { |
| + return f.quarantine(errRootFloorHistory) |
| + } |
| + err = f.persist(next, bound, refusal == nil) |
| + return errors.Join(refusal, err) |
| +} |
diff --git a/internal/host/rootmovesfloor_boot.go b/internal/host/rootmovesfloor_boot.go| new file mode 100644 |
| index 000000000..8f1d479d3 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_boot.go |
| @@ -0,0 +1,112 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func rootFloorPrevVerdict(j rootFloorJudge) error { |
| + p, err := j.floors.projection() |
| + if err != nil { |
| + return err |
| + } |
| + raw, err := j.floors.public.Read("prev.json") |
| + if err != nil { |
| + return err |
| + } |
| + var v rootFloorVerdict |
| + if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &v) != nil || v.Identity != p.Identity || v.Generation != p.Generation || !v.PrevOK || v.PrevAbsent { |
| + return errRootFloorHistory |
| + } |
| + binary, err := j.binary(j.prev) |
| + if err != nil { |
| + return err |
| + } |
| + if binary != v.Prev || !binary.supports(p.Floor) { |
| + return errRootFloorHistory |
| + } |
| + return nil |
| +} |
| + |
| +// 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() |
| + if err != nil { |
| + return "", err |
| + } |
| + if journal && rootFloorPrevVerdict(j) == nil && idle(ctx) == nil && reader(ctx, j.prev) == nil { |
| + return j.prev, nil |
| + } |
| + current, err := j.binary(j.current) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !current.supports(p.Floor) { |
| + return "", errRootFloorHistory |
| + } |
| + return j.current, nil |
| +} |
| + |
| +// The additional argument deliberately fails in an older floor-allows-prev |
| +// reader. It is an internal unit protocol, not an operator reset interface. |
| +func rootFloorAllowsPrev(opt string) error { |
| + if !filepath.IsAbs(opt) || filepath.Clean(opt) != opt { |
| + return errFloorGateUsage |
| + } |
| + p, err := readRootFloorProjection(opt) |
| + if err != nil { |
| + return err |
| + } |
| + f, err := openRootMovesFloors(opt, p.Identity, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + return rootFloorPrevVerdict(rootFloorJudge{floors: f, prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }}) |
| +} |
| + |
| +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) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + unlock, err := f.private.Lock(ctx, "floor.lock") |
| + if err != nil { |
| + 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) |
| + return err |
| + }, rootFloorDowngradeIdle) |
| + if err != nil { |
| + return err |
| + } |
| + return effect(candidate) |
| + }) |
| + }) |
| +} |
| + |
| +func rootFloorJournalPresent(opt string) (bool, error) { |
| + f, err := rootstate.OpenAuthorityFile(filepath.Join(systemRootDir(opt), "journal.json")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + return true, f.Close() |
| +} |
diff --git a/internal/host/rootmovesfloor_boot_root_linux_test.go b/internal/host/rootmovesfloor_boot_root_linux_test.go| new file mode 100644 |
| index 000000000..a91bcc72c |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_boot_root_linux_test.go |
| @@ -0,0 +1,248 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootFloorBootSelectors(t *testing.T) { |
| + dir := generationRootRunnerDir(t) |
| + self, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary, err := os.ReadFile(self) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + runner := filepath.Join(dir, "runner.test") |
| + if err := os.WriteFile(runner, binary, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + pid1, err := os.ReadFile("/proc/1/comm") |
| + if err != nil || strings.TrimSpace(string(pid1)) != "systemd" { |
| + t.Fatal("root runner requires systemd", err) |
| + } |
| + stage := filepath.Join(dir, "stage") |
| + if err := bootstrap(stage, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, unit := range []string{"carlos-system-update.service", "carlos-system-recover.service"} { |
| + for _, attack := range []string{"none", "below", "stale verdict", "missing floor", "corrupt floor", "old reader", "no guard", "missing accepted", "crash-1", "crash-2", "crash-3", "crash-4", "crash-5"} { |
| + t.Run(unit+"/"+attack, func(t *testing.T) { |
| + opt, err := os.MkdirTemp(dir, "box-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(opt, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + mark := filepath.Join(opt, "executed") |
| + makeBinary := func(path, version string, reader bool) { |
| + body := "#!/bin/sh\nif [ \"$2\" = floor-allows-prev ]; then\n" |
| + if reader { |
| + body += " PLAN14_P3_GATE=1 exec " + runner + " -test.run=^TestPlan14RootFloorBootPayload$\n" |
| + } else { |
| + body += " exit 2\n" |
| + } |
| + body += "fi\nprintf '" + version + "' > " + mark + "\n" |
| + rootFloorSignRunnerBytes(t, opt, path, version, []byte(body), key) |
| + } |
| + makeBinary(systemBinaryPath(opt), "v2.0.0", true) |
| + makeBinary(systemPrevPath(opt), "v2.0.0", true) |
| + j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, idle: func(context.Context) error { return nil }} |
| + if err := j.judge(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "below": |
| + makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| + makeBinary(systemPrevPath(opt), "v1.0.0", true) |
| + case "stale verdict": |
| + if err := f.refresh(t.Context(), &b, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| + case "missing accepted": |
| + if err := f.private.Remove(rootFloorAccepted); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing floor": |
| + if err := f.public.Remove(rootFloorSelector); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "corrupt floor": |
| + if err := f.public.Write(rootFloorSelector, []byte("broken")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "old reader": |
| + makeBinary(systemPrevPath(opt), "v2.0.0", false) |
| + if err := j.judge(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| + } |
| + if strings.HasPrefix(attack, "crash-") { |
| + fail := int(attack[len(attack)-1] - '0') |
| + n := 0 |
| + broken := f |
| + broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: true} |
| + broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: true} |
| + if err := broken.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v3.0.0"}, nil, nil); err == nil { |
| + t.Fatal("crash injection succeeded") |
| + } |
| + } |
| + |
| + guard := filepath.Join(opt, "system", "guard", "carlos") |
| + if attack != "no guard" { |
| + if err := os.WriteFile(guard, []byte("#!/bin/sh\nexec "+runner+" -test.run=^TestPlan14RootFloorBootPayload$\n"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "system", "router"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(stage, "etc", "systemd", "system", unit)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body := strings.ReplaceAll(string(raw), "/opt/carlos", opt) |
| + body = strings.ReplaceAll(body, "/data", filepath.Join(opt, "data")) |
| + // Preserve the service's hardening and actual selector. Remove only its |
| + // deployment dependencies; all paths and identity are fixture-owned. |
| + var lines []string |
| + for _, line := range strings.Split(body, "\n") { |
| + if strings.HasPrefix(line, "OnFailure=") || strings.HasPrefix(line, "Before=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "Wants=") || strings.HasPrefix(line, "EnvironmentFile=") { |
| + continue |
| + } |
| + lines = append(lines, line) |
| + if line == "[Service]" { |
| + lines = append(lines, "Environment=PLAN14_P3_OPT="+opt, "Environment=CARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub)) |
| + } |
| + } |
| + name := "plan14-p3-" + filepath.Base(opt) + ".service" |
| + path := filepath.Join("/run/systemd/system", name) |
| + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + exec.Command("systemctl", "stop", name).Run() |
| + os.Remove(path) |
| + exec.Command("systemctl", "daemon-reload").Run() |
| + exec.Command("systemctl", "reset-failed", name).Run() |
| + }) |
| + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput() |
| + got, readErr := os.ReadFile(mark) |
| + if attack == "none" { |
| + 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) |
| + } |
| + } else if runErr == nil || !os.IsNotExist(readErr) { |
| + t.Fatalf("unsafe selector: %v %s marker=%s err=%v", runErr, out, got, readErr) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorBootPayload(t *testing.T) { |
| + opt := os.Getenv("PLAN14_P3_OPT") |
| + if opt == "" { |
| + t.Fatal("unit payload only") |
| + } |
| + if os.Getenv("PLAN14_P3_GATE") == "1" { |
| + if err := rootFloorAllowsPrev(opt); err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, false) |
| + if err != nil { |
| + 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. |
| + path, err := rootFloorBootCandidate(t.Context(), j, true, func(ctx context.Context, path string) error { |
| + cmd := exec.CommandContext(ctx, path, "system", "floor-allows-prev", "--root", opt) |
| + cmd.Env = os.Environ() |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("%w: %s", err, out) |
| + } |
| + return nil |
| + }, func(context.Context) error { return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := exec.CommandContext(t.Context(), path, "system", "apply").CombinedOutput(); err != nil { |
| + t.Fatal(err, string(out)) |
| + } |
| +} |
| + |
| +func rootFloorSignRunnerBytes(t *testing.T, opt, path, version string, raw []byte, key ed25519.PrivateKey) { |
| + t.Helper() |
| + if err := os.WriteFile(path, raw, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + s, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + sum := sha256.Sum256(raw) |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.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(key), p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Multiple fixture binaries may share a release tag, but never a digest. |
| + if err := s.Write(hex.EncodeToString(sum[:])+".json", wire); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_boot_test.go b/internal/host/rootmovesfloor_boot_test.go| new file mode 100644 |
| index 000000000..39140d2a8 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_boot_test.go |
| @@ -0,0 +1,83 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +func TestPlan14RootFloorBootDecision(t *testing.T) { |
| + for _, attack := range []string{"none", "current below", "prev below", "stale verdict", "missing floor", "corrupt floor", "old reader", "pending participation", "replaced binary"} { |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + current := rootFloorBinary{Version: "v2.0.0", Protocol: 1, Digest: floorTestDigest} |
| + prev := current |
| + j := rootFloorJudge{floors: f, current: "current", prev: "prev", binary: func(path string) (rootFloorBinary, error) { |
| + if path == "prev" { |
| + return prev, nil |
| + } |
| + return current, nil |
| + }, idle: func(context.Context) error { return nil }} |
| + if err := j.judge(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "current below": |
| + current.Version = "v1.0.0" |
| + prev = current |
| + case "prev below": |
| + prev.Version = "v1.0.0" |
| + case "stale verdict": |
| + if err := f.refresh(t.Context(), &b, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing floor": |
| + f.public.Remove(rootFloorSelector) |
| + case "corrupt floor": |
| + f.public.Write(rootFloorSelector, []byte("broken")) |
| + case "replaced binary": |
| + prev.Digest = "replacement" |
| + } |
| + calls := 0 |
| + gate := func(context.Context, string) error { |
| + calls++ |
| + if attack == "old reader" { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + } |
| + idle := func(context.Context) error { |
| + if attack == "pending participation" { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + } |
| + got, err := rootFloorBootCandidate(t.Context(), j, true, gate, idle) |
| + if attack == "missing floor" || attack == "corrupt floor" || attack == "current below" { |
| + if err == nil { |
| + t.Fatal("unsafe executable selected", got) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "current" |
| + if attack == "none" { |
| + want = "prev" |
| + } |
| + if got != want { |
| + t.Fatal(got, want) |
| + } |
| + if attack == "prev below" && calls != 0 { |
| + t.Fatal("below-floor previous binary executed for gate") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_identity.go b/internal/host/rootmovesfloor_identity.go| new file mode 100644 |
| index 000000000..334caadf2 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_identity.go |
| @@ -0,0 +1,36 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "path/filepath" |
| + "reflect" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// Retain the independently verified pointer before its artifact can replace a |
| +// recovery binary. A pointer race retries; no existing identity is overwritten. |
| +func rememberRootFloorSystemIdentity(ctx context.Context, store release.Store, pub ed25519.PublicKey, opt string, expected *release.SystemPointer) error { |
| + raw, _, err := store.Get(ctx, release.SystemPointerKey("router", expected.Channel)) |
| + if err != nil { |
| + return err |
| + } |
| + verified, err := release.VerifySystemIdentity(pub, raw) |
| + if err != nil { |
| + return err |
| + } |
| + if verified.Component != "router" || verified.Channel != expected.Channel || !reflect.DeepEqual(verified.Current, expected.Current) { |
| + return errRootFloorHistory |
| + } |
| + dir, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer dir.Close() |
| + digest := sha256.Sum256(raw) |
| + return dir.Write(hex.EncodeToString(digest[:])+".json", raw) |
| +} |
diff --git a/internal/host/rootmovesfloor_refresh.go b/internal/host/rootmovesfloor_refresh.go| new file mode 100644 |
| index 000000000..19117950b |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_refresh.go |
| @@ -0,0 +1,259 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rootFloorBinary struct { |
| + Version, Digest string |
| + Protocol int |
| + File rootstate.FileIdentity |
| +} |
| +type rootFloorVerdict struct { |
| + Identity rootFloorIdentity |
| + Generation uint64 |
| + Current, Prev rootFloorBinary |
| + CurrentOK, PrevAbsent, PrevOK bool |
| +} |
| +type rootFloorAdvertisement struct { |
| + State, MinRelease, PrevRelease, PrevAllowedFor string |
| + Generation uint64 |
| +} |
| +type rootFloorJudge struct { |
| + floors rootMovesFloors |
| + remote func(context.Context) (*moves.Floor, error) |
| + legacy func() ([]*moves.Floor, error) |
| + binary func(string) (rootFloorBinary, error) |
| + idle func(context.Context) error |
| + current, prev string |
| +} |
| + |
| +func (b rootFloorBinary) supports(fl *moves.Floor) bool { |
| + if b.Digest == "" || !validFloorRelease(b.Version) || b.Protocol < 1 { |
| + return false |
| + } |
| + if fl == nil { |
| + return true |
| + } |
| + at, err := moves.ReleaseAtOrAbove(b.Version, fl.MinRelease) |
| + return err == nil && at && b.Protocol >= fl.Min |
| +} |
| + |
| +func (j rootFloorJudge) refresh(ctx context.Context) error { |
| + remote, rerr := j.remote(ctx) |
| + legacy, lerr := j.legacy() |
| + if err := j.floors.refresh(ctx, remote, legacy, errors.Join(rerr, lerr)); err != nil { |
| + return err |
| + } |
| + return j.judge(ctx) |
| +} |
| +func (j rootFloorJudge) judge(ctx context.Context) error { |
| + f := j.floors |
| + unlock, err := f.private.Lock(ctx, "floor.lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + p, err := f.projection() |
| + if err != nil { |
| + return err |
| + } |
| + // Revoke a same-generation verdict before any observation that can fail. |
| + if err := f.public.Write("prev.json", []byte("{}")); err != nil { |
| + return err |
| + } |
| + v := rootFloorVerdict{Identity: f.identity, Generation: p.Generation} |
| + v.Current, err = j.binary(j.current) |
| + if err != nil { |
| + return err |
| + } |
| + v.CurrentOK = v.Current.supports(p.Floor) |
| + v.Prev, err = j.binary(j.prev) |
| + if errors.Is(err, os.ErrNotExist) { |
| + v.PrevAbsent = true |
| + } else if err != nil { |
| + return err |
| + } else if v.Prev.supports(p.Floor) { |
| + if err := j.idle(ctx); err != nil { |
| + return err |
| + } |
| + v.PrevOK = true |
| + } |
| + raw, _ := json.Marshal(v) |
| + if err := f.public.Write("prev.json", raw); err != nil { |
| + return errors.Join(err, f.public.Write("prev.json", []byte("{}"))) |
| + } |
| + return nil |
| +} |
| +func (f rootMovesFloors) advertisement() (rootFloorAdvertisement, error) { |
| + p, err := f.projection() |
| + if err != nil { |
| + return rootFloorAdvertisement{}, err |
| + } |
| + return rootFloorAdvertisementFor(f.public, p) |
| +} |
| +func rootFloorAdvertisementFor(public rootstate.Store, p rootFloorProjection) (rootFloorAdvertisement, error) { |
| + ad := rootFloorAdvertisement{State: "quarantined", Generation: p.Generation} |
| + raw, err := public.Read("prev.json") |
| + if err != nil { |
| + return ad, err |
| + } |
| + var v rootFloorVerdict |
| + if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &v) != nil || v.Identity != p.Identity || v.Generation != p.Generation || !v.CurrentOK || !v.Current.supports(p.Floor) { |
| + return ad, errRootFloorHistory |
| + } |
| + ad.State = "accepted" |
| + if p.Floor != nil { |
| + ad.MinRelease = p.Floor.MinRelease |
| + } |
| + if v.PrevAbsent { |
| + ad.PrevRelease = "none" |
| + } else { |
| + ad.PrevRelease = v.Prev.Version |
| + if v.PrevOK && v.Prev.supports(p.Floor) && p.Floor != nil { |
| + ad.PrevAllowedFor = p.Floor.MinRelease |
| + } |
| + } |
| + return ad, nil |
| +} |
| + |
| +// A binary's signed identity, not its mutable version sidecar, supplies its |
| +// release and protocol. The cache is root-owned; each envelope is reverified. |
| +func readRootFloorBinary(opt, path string) (rootFloorBinary, error) { |
| + var b rootFloorBinary |
| + f, err := rootstate.OpenAuthorityFile(path) |
| + if err != nil { |
| + return b, err |
| + } |
| + defer f.Close() |
| + b.File, err = rootstate.Identity(f) |
| + if err != nil { |
| + return b, err |
| + } |
| + st, err := f.Stat() |
| + if err != nil { |
| + return b, err |
| + } |
| + if st.Size() > 512<<20 || st.Mode()&0111 == 0 { |
| + return b, os.ErrPermission |
| + } |
| + sum := sha256.New() |
| + if _, err := io.Copy(sum, f); err != nil { |
| + return b, err |
| + } |
| + b.Digest = hex.EncodeToString(sum.Sum(nil)) |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return b, err |
| + } |
| + identities, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), false, true) |
| + if err != nil { |
| + return b, err |
| + } |
| + defer identities.Close() |
| + names, err := identities.Names() |
| + if err != nil { |
| + return b, err |
| + } |
| + for _, name := range names { |
| + raw, err := identities.Read(name) |
| + if err != nil { |
| + return b, err |
| + } |
| + p, err := release.VerifySystemIdentity(pub, raw) |
| + if err != nil { |
| + return b, err |
| + } |
| + if p.Current.Artifacts[runtime.GOOS+"-"+runtime.GOARCH].SHA256 == b.Digest { |
| + b.Version, b.Protocol = p.Current.ReleaseTag, p.Current.MovesProtocol |
| + return b, nil |
| + } |
| + } |
| + return b, errRootFloorHistory |
| +} |
| + |
| +func rootFloorDowngradeIdle(ctx context.Context) error { |
| + l, err := rootparticipation.Open() |
| + if err != nil { |
| + return err |
| + } |
| + defer l.State.Close() |
| + if err := l.Idle(ctx); err != nil { |
| + return err |
| + } |
| + pins, err := productionRootGenerationPins().StagedPins(ctx, release.OptDir()) |
| + if err != nil { |
| + return err |
| + } |
| + if len(pins) > 0 { |
| + return errRootFloorHistory |
| + } |
| + return nil |
| +} |
| + |
| +func rootRefreshAndJudgeMovesPrev(id rootFloorIdentity) error { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| + defer cancel() |
| + opt, dd := release.OptDir(), dataDir() |
| + // The adopt pass already owns release exclusion. Acquire no release lock here. |
| + return withPrevLock(opt, func() error { |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), idle: rootFloorDowngradeIdle, |
| + binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, |
| + legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(dd) }, |
| + remote: func(ctx context.Context) (*moves.Floor, error) { |
| + s, err := openDeployStore(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return moves.ReadFloor(ctx, s) |
| + }} |
| + return j.refresh(ctx) |
| + }) |
| +} |
| + |
| +func withRootMovesFields(f edgesFile, opt string) edgesFile { |
| + f.MovesProtocol = moves.ProtocolVersion |
| + f.MovesFloorState = "quarantined" |
| + f.MovesFloorMinRelease, f.PrevRelease, f.PrevAllowedFor = "", "", "" |
| + p, err := readRootFloorProjection(opt) |
| + if err != nil { |
| + return f |
| + } |
| + if p.Identity.Deployment != os.Getenv("CARLOS_DEPLOYMENT_BUCKET") || p.Identity.Box != f.Box { |
| + return f |
| + } |
| + public, err := rootstate.Open(rootFloorDir(opt), false, true) |
| + if err != nil { |
| + return f |
| + } |
| + defer public.Close() |
| + ad, err := rootFloorAdvertisementFor(public, p) |
| + if err != nil { |
| + return f |
| + } |
| + if rootstate.Sync(public) != nil { |
| + return f |
| + } |
| + f.MovesFloorState, f.MovesFloorGeneration = ad.State, ad.Generation |
| + f.MovesFloorMinRelease, f.PrevRelease, f.PrevAllowedFor = ad.MinRelease, ad.PrevRelease, ad.PrevAllowedFor |
| + return f |
| +} |
diff --git a/internal/host/rootmovesfloor_refresh_test.go b/internal/host/rootmovesfloor_refresh_test.go| new file mode 100644 |
| index 000000000..734a023ef |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_refresh_test.go |
| @@ -0,0 +1,123 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) { |
| + for _, prev := range []string{"none", "v2.0.0", "v1.0.0"} { |
| + t.Run(prev, func(t *testing.T) { |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + raw, _ := json.Marshal(b) |
| + if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + mirror := t.TempDir() |
| + if _, err := syncLocalFloor(t.Context(), store, mirror); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, err := f.projection(); err != nil || p.Floor.MinRelease != a.MinRelease { |
| + t.Fatal("mirror acknowledged B", p, err) |
| + } |
| + reads := 0 |
| + read := func(context.Context) (*moves.Floor, error) { reads++; return &b, nil } |
| + binary := func(path string) (rootFloorBinary, error) { |
| + if path == "prev" && prev == "none" { |
| + return rootFloorBinary{}, os.ErrNotExist |
| + } |
| + v := "v2.0.0" |
| + if path == "prev" { |
| + v = prev |
| + } |
| + return rootFloorBinary{Version: v, Protocol: 2, Digest: floorTestDigest}, nil |
| + } |
| + judge := rootFloorJudge{floors: f, remote: read, legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(mirror) }, binary: binary, current: "current", prev: "prev", idle: func(context.Context) error { return nil }} |
| + if err := judge.refresh(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if reads != 1 { |
| + t.Fatal("no-prev path skipped authoritative GET") |
| + } |
| + p, err := f.projection() |
| + if err != nil || p.Floor == nil || *p.Floor != b { |
| + t.Fatal("root refresh", p, err) |
| + } |
| + ad, err := f.advertisement() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ad.PrevRelease != prev { |
| + t.Fatal("prev identity", ad) |
| + } |
| + if (ad.PrevAllowedFor == b.MinRelease) != (prev == "v2.0.0") { |
| + t.Fatal("verdict", ad) |
| + } |
| + if err := os.RemoveAll(mirror); err != nil { |
| + t.Fatal(err) |
| + } |
| + if again, err := f.advertisement(); err != nil || again != ad { |
| + t.Fatal("mirror changed advertisement", again, err) |
| + } |
| + // A failed persistence cannot publish the new generation's acknowledgement. |
| + n := 0 |
| + broken := f |
| + broken.private = &floorFaultStore{Store: f.private, n: &n, fail: 1} |
| + judge.floors = broken |
| + if judge.refresh(t.Context()) == nil { |
| + t.Fatal("failed floor sync accepted") |
| + } |
| + if _, err := f.advertisement(); err == nil { |
| + t.Fatal("failed sync left acceptance") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorRecoveryRefresh(t *testing.T) { |
| + for _, entry := range []string{"recover", "rollback"} { |
| + t.Run(entry, func(t *testing.T) { |
| + f, _ := applyHappy(t) |
| + j := f.brokenBox("v2.0.0", "v1.0.0", "v1.0.0") |
| + journal := filepath.Join(systemRootDir(f.a.optDir), "journal.json") |
| + before, _ := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + original, _ := os.ReadFile(journal) |
| + unavailable := errors.New("provider unavailable") |
| + reads := 0 |
| + f.a.rootMoves = func() (rootFloorIdentity, bool, error) { |
| + return rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}, true, nil |
| + } |
| + f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { reads++; return nil, nil, unavailable } |
| + var err error |
| + if entry == "recover" { |
| + _, err = f.a.recoverIfNeeded(t.Context()) |
| + } 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) |
| + } |
| + after, _ := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + retained, _ := os.ReadFile(journal) |
| + if !bytes.Equal(before, after) || !bytes.Equal(original, retained) { |
| + t.Fatal("failed refresh changed executable or journal") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_root_linux_test.go b/internal/host/rootmovesfloor_root_linux_test.go| new file mode 100644 |
| index 000000000..79e4f82b1 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_root_linux_test.go |
| @@ -0,0 +1,362 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "sync/atomic" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14RootFloorCrashOrderingRootRunner(t *testing.T) { |
| + for _, after := range []bool{false, true} { |
| + for fail := 1; fail <= 5; fail++ { |
| + dir := generationRootRunnerDir(t) |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(dir, 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: "root observation", Digest: floorTestDigest, Authorization: "operator"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + broken := f |
| + broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: after} |
| + broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: after} |
| + if err := broken.refresh(t.Context(), &b, nil, nil); err == nil { |
| + t.Fatal("injected write succeeded") |
| + } |
| + f.close() |
| + f, err = openRootMovesFloors(dir, id, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, e := readRootFloorProjection(dir); e == nil && p.Floor != nil && *p.Floor == b { |
| + t.Fatal("failed write acknowledged B") |
| + } |
| + if err := f.refresh(t.Context(), &b, nil, nil); err != nil { |
| + t.Fatal("retry", err) |
| + } |
| + if p, e := readRootFloorProjection(dir); e != nil || p.Floor == nil || *p.Floor != b { |
| + t.Fatal("durable retry", p, e) |
| + } |
| + f.close() |
| + // Root-owned leaf under a service-owned ancestor must not open. |
| + if err := os.Chown(rootFloorDir(dir), 65534, 65534); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := rootstate.Open(filepath.Join(rootFloorDir(dir), "private"), false, false); err == nil { |
| + t.Fatal("hostile parent accepted") |
| + } |
| + if err := os.Chown(rootFloorDir(dir), 0, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14FloorRiseWithoutPlatformUpdateRootRunner(t *testing.T) { |
| + for _, prev := range []string{"none", "v2.0.0", "v1.0.0"} { |
| + t.Run(prev, func(t *testing.T) { |
| + opt := generationRootRunnerDir(t) |
| + dd := filepath.Join(opt, "data") |
| + if err := os.Mkdir(dd, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy") |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + rootFloorRunnerBinary(t, opt, systemBinaryPath(opt), "v2.0.0", key) |
| + if prev != "none" { |
| + rootFloorRunnerBinary(t, opt, systemPrevPath(opt), prev, key) |
| + } |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + raw, _ := json.Marshal(b) |
| + if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := syncLocalFloor(t.Context(), store, dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ad := withMovesFields(edgesFile{Box: "box"}, dd, opt); ad.MovesFloorMinRelease == b.MinRelease { |
| + t.Fatal("agent mirror acknowledged B") |
| + } |
| + gets := 0 |
| + j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), idle: func(context.Context) error { return nil }, binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(dd) }, remote: func(ctx context.Context) (*moves.Floor, error) { gets++; return moves.ReadFloor(ctx, store) }} |
| + if err := j.refresh(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + ad := withMovesFields(edgesFile{Box: "box"}, dd, opt) |
| + if gets != 1 || ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != b.MinRelease || ad.PrevRelease != prev { |
| + t.Fatal("root projection", gets, ad) |
| + } |
| + if (ad.PrevAllowedFor == b.MinRelease) != (prev == "v2.0.0") { |
| + t.Fatal("prev verdict", ad) |
| + } |
| + if err := os.RemoveAll(filepath.Join(dd, ".moves")); err != nil { |
| + t.Fatal(err) |
| + } |
| + again := withMovesFields(edgesFile{Box: "box"}, dd, opt) |
| + if again.MovesFloorMinRelease != ad.MovesFloorMinRelease || again.PrevRelease != ad.PrevRelease { |
| + t.Fatal("mirror deletion changed acceptance") |
| + } |
| + if err := os.WriteFile(systemBinaryPath(opt), []byte("forged"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if j.refresh(t.Context()) == nil { |
| + t.Fatal("unsigned binary accepted") |
| + } |
| + if withMovesFields(edgesFile{Box: "box"}, dd, opt).MovesFloorState == "accepted" { |
| + t.Fatal("unsigned current acknowledged") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func rootFloorRunnerBinary(t *testing.T, opt, path, version string, key ed25519.PrivateKey) { |
| + t.Helper() |
| + raw := []byte("#!/bin/sh\n# " + version + "\nexit 0\n") |
| + if err := os.WriteFile(path, raw, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + identities, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer identities.Close() |
| + sum := sha256.Sum256(raw) |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.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(key), p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := release.VerifySystemIdentity(key.Public().(ed25519.PublicKey), wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := identities.Write(version+".json", wire); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorAgentProjectionPayload(t *testing.T) { |
| + opt := os.Getenv("PLAN14_P3_AGENT_OPT") |
| + if opt == "" || os.Geteuid() == 0 { |
| + t.Fatal("service-UID payload only") |
| + } |
| + ad := withMovesFields(edgesFile{Box: "box"}, filepath.Join(opt, "data"), opt) |
| + if ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != "v2.0.0" { |
| + t.Fatal("service projection refused", ad) |
| + } |
| + // The same UID must be unable to change either projection or private state. |
| + for _, path := range []string{filepath.Join(rootFloorDir(opt), rootFloorProjectionFile), filepath.Join(rootFloorDir(opt), "private", rootFloorAccepted)} { |
| + if err := os.WriteFile(path, []byte("forged"), 0644); err == nil { |
| + t.Fatal("service changed root floor") |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorAdoptRefreshRootRunner(t *testing.T) { |
| + // This fixture owns its whole namespace; refuse any existing root census. |
| + if err := os.Mkdir("/var/lib/carlos-claims", 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll("/var/lib/carlos-claims"); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + census, err := rootstate.Open(rootparticipation.Namespace, true, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(rootparticipation.Census{Schema: 1, Box: "box", Enrollment: "en", Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}) |
| + if err := census.Write("census.json", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + census.Close() |
| + opt := generationRootRunnerDir(t) |
| + dd := filepath.Join(opt, "data") |
| + if err := os.Mkdir(dd, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + storeDir := filepath.Join(opt, "deployment") |
| + if err := os.Mkdir(storeDir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_OPT_DIR", opt) |
| + t.Setenv("CARLOS_DATA_DIR", dd) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", storeDir) |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + rootFloorRunnerBinary(t, opt, systemBinaryPath(opt), "v2.0.0", key) |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(storeDir) |
| + raw, _ = json.Marshal(b) |
| + if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := syncLocalFloor(t.Context(), store, dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The real provider reads only this loopback S3 fixture; it makes no AWS call. |
| + var gets atomic.Int32 |
| + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != "GET" || r.URL.Path != "/deploy/"+moves.FloorKey { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + gets.Add(1) |
| + w.Header().Set("ETag", "fixture") |
| + w.Write(raw) |
| + })) |
| + defer endpoint.Close() |
| + t.Setenv("AWS_REGION", "eu-west-1") |
| + t.Setenv("AWS_DEFAULT_REGION", "eu-west-1") |
| + t.Setenv("AWS_ACCESS_KEY_ID", "fixture") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "fixture") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_ENDPOINT_URL_S3", endpoint.URL) |
| + t.Setenv("CARLOS_S3_ENDPOINT", endpoint.URL) |
| + t.Setenv("AWS_PROFILE", "") |
| + t.Setenv("AWS_DEFAULT_PROFILE", "") |
| + if err := withClaimRootRelease(t.Context(), true, func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(opt, "release"), true, false) }, func() error { return rootJudgePrev(nil) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gets.Load() == 0 { |
| + t.Fatal("no authoritative GET before no-prev return") |
| + } |
| + ad := withMovesFields(edgesFile{Box: "box"}, dd, opt) |
| + if ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != b.MinRelease || ad.PrevRelease != "none" { |
| + t.Fatal("actual adopt refresh", ad) |
| + } |
| + rawBinary, err := os.ReadFile(os.Args[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + agentBinary := filepath.Join(opt, "agent.test") |
| + if err := os.WriteFile(agentBinary, rawBinary, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.CommandContext(t.Context(), agentBinary, "-test.run=^TestPlan14RootFloorAgentProjectionPayload$", "-test.v") |
| + cmd.Dir = opt |
| + cmd.Env = append(os.Environ(), "PLAN14_P3_AGENT_OPT="+opt) |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: 65534, Gid: 65534}} |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("service UID: %v\n%s", err, out) |
| + } |
| + // Direct apply/recovery adapters cannot use erased legacy files to go back. |
| + if err := os.RemoveAll(filepath.Join(dd, ".moves")); err != nil { |
| + t.Fatal(err) |
| + } |
| + app := systemApplier{optDir: opt, dataDir: dd, rootMoves: rootMovesFloorIdentity} |
| + if app.floorAllowsForward("v1.0.0") == nil { |
| + t.Fatal("legacy deletion allowed downgrade") |
| + } |
| + if err := app.floorAllowsForward("v2.0.0"); err != nil { |
| + t.Fatal("same floor refused", err) |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorSignedTargetRootRunner(t *testing.T) { |
| + opt := generationRootRunnerDir(t) |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + bytes := []byte("new signed target without earlier root history") |
| + sum := sha256.Sum256(bytes) |
| + p, err := release.PromoteSystem(t.Context(), store, release.RootSigner(key), "router", "stable", release.SystemRelease{Version: "v3.0.0", ReleaseTag: "v3.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v3.0.0", platform), SHA256: hex.EncodeToString(sum[:])}}}, release.SystemPromoteOptions{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + staged := filepath.Join(opt, "staging", "carlos") |
| + if err := os.Mkdir(filepath.Dir(staged), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(staged, bytes, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := 0 |
| + a := systemApplier{optDir: opt, channel: "stable", rootMoves: func() (rootFloorIdentity, bool, error) { return id, true, nil }, openStore: func(context.Context) (release.Store, ed25519.PublicKey, error) { return store, pub, nil }, ops: sysOps{run: func(context.Context, time.Duration, string, ...string) (string, error) { calls++; return "ran", nil }}} |
| + if _, err := a.verifyPointer(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err != nil || calls != 1 { |
| + t.Fatal("signed target with unknown history refused", err, calls) |
| + } |
| + changed := *p |
| + changed.Current.Version = "other" |
| + if rememberRootFloorSystemIdentity(t.Context(), store, pub, opt, &changed) == nil { |
| + t.Fatal("pointer substitution accepted") |
| + } |
| + 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 { |
| + t.Fatal("unsigned target executed", err, calls) |
| + } |
| +} |
diff --git a/internal/host/rootmovesfloor_source.go b/internal/host/rootmovesfloor_source.go| new file mode 100644 |
| index 000000000..d862bd74c |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_source.go |
| @@ -0,0 +1,202 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "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 rootMovesFloorIdentity() (rootFloorIdentity, bool, error) { |
| + l, required, err := rootparticipation.OpenForUpdate() |
| + if l.State != nil { |
| + defer l.State.Close() |
| + } |
| + if err != nil || !required { |
| + return rootFloorIdentity{}, required, err |
| + } |
| + c, err := l.Census() |
| + id := rootFloorIdentity{Deployment: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), Box: c.Box, Enrollment: c.Enrollment} |
| + if err == nil && id.Deployment == "" { |
| + err = errRootFloorHistory |
| + } |
| + return id, true, err |
| +} |
| + |
| +type rootFloorIdentitySource func() (rootFloorIdentity, bool, error) |
| + |
| +func (source rootFloorIdentitySource) read() (rootFloorIdentity, bool, error) { |
| + if source == nil { |
| + return rootFloorIdentity{}, false, nil |
| + } |
| + return source() |
| +} |
| + |
| +// Root callers retain main's floor implementation until a census exists. |
| +func readEffectiveMovesFloor(source rootFloorIdentitySource, opt, dd string) (*moves.Floor, error) { |
| + id, required, err := source.read() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !required { |
| + return readLocalFloor(dd) |
| + } |
| + f, err := openRootMovesFloors(opt, id, false) |
| + 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 |
| +} |
| + |
| +func withEffectiveMovesFloorLock(source rootFloorIdentitySource, opt, dd string, create bool, fn func() error) error { |
| + id, required, err := source.read() |
| + if err != nil { |
| + return err |
| + } |
| + if !required { |
| + return withFloorLock(dd, create, func(*movesDir) error { return fn() }) |
| + } |
| + f, err := openRootMovesFloors(opt, id, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + unlock, err := f.private.Lock(context.Background(), "floor.lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + return fn() |
| +} |
| + |
| +// Read both mirrors independently. Missing files add nothing; a malformed |
| +// bounded file quarantines, while any usable sibling still contributes a deny. |
| +func rootFloorLegacy(dd string) ([]*moves.Floor, error) { |
| + var legacy []*moves.Floor |
| + fl, jerr := readRootLegacyFloorJSON(dd) |
| + if fl != nil { |
| + if validRootFloor(fl) { |
| + legacy = append(legacy, fl) |
| + } else { |
| + jerr = errors.Join(jerr, errRootFloorHistory) |
| + } |
| + } |
| + file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorMinReleaseFile), "/")...) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return legacy, jerr |
| + } |
| + if err != nil { |
| + return legacy, errors.Join(jerr, err) |
| + } |
| + defer file.Close() |
| + st, err := file.Stat() |
| + if err != nil { |
| + return legacy, errors.Join(jerr, err) |
| + } |
| + if !st.Mode().IsRegular() || st.Size() > 256 { |
| + return legacy, errors.Join(jerr, errRootFloorHistory) |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(file, 257)) |
| + line := strings.TrimSpace(string(raw)) |
| + if err != nil || !validFloorRelease(line) { |
| + return legacy, errors.Join(jerr, err, errRootFloorHistory) |
| + } |
| + legacy = append(legacy, &moves.Floor{Min: 1, MinRelease: line}) |
| + return legacy, jerr |
| +} |
| + |
| +func refreshRootMovesFloor(ctx context.Context, store release.Store, opt, dd string, id rootFloorIdentity) error { |
| + f, err := openRootMovesFloors(opt, id, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + remote, rerr := moves.ReadFloor(ctx, store) |
| + legacy, lerr := rootFloorLegacy(dd) |
| + return f.refresh(ctx, remote, legacy, errors.Join(rerr, lerr)) |
| +} |
| + |
| +// Reading the final projection can race the writer's last directory sync. |
| +// Sync after reading makes those already-fsynced file bytes durable before the |
| +// honest agent advertises them, even if root died just after the rename. |
| +func readRootFloorProjection(opt string) (rootFloorProjection, error) { |
| + s, err := rootstate.Open(rootFloorDir(opt), false, true) |
| + if err != nil { |
| + return rootFloorProjection{}, err |
| + } |
| + defer s.Close() |
| + var p rootFloorProjection |
| + raw, err := s.Read(rootFloorProjectionFile) |
| + if err != nil { |
| + return p, err |
| + } |
| + if err = rootstate.Sync(s); err != nil { |
| + return p, err |
| + } |
| + if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &p) != nil || !p.Accepted || !p.rootFloorRecord.valid(p.Identity) { |
| + return p, errRootFloorHistory |
| + } |
| + f := rootMovesFloors{public: s, identity: p.Identity} |
| + bound, err := f.selector() |
| + if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) { |
| + return p, errRootFloorHistory |
| + } |
| + return p, nil |
| +} |
| + |
| +func readRootLegacyFloorJSON(dd string) (*moves.Floor, error) { |
| + file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorFile), "/")...) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer file.Close() |
| + st, err := file.Stat() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes { |
| + return nil, errRootFloorHistory |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(file, movesFloorMaxBytes+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var floor moves.Floor |
| + err = rootparticipation.Decode(raw, &floor) |
| + if !validRootFloor(&floor) { |
| + return nil, errors.Join(err, errRootFloorHistory) |
| + } |
| + 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| new file mode 100644 |
| index 000000000..36a71dd12 |
| --- /dev/null |
| +++ b/internal/host/rootmovesfloor_test.go |
| @@ -0,0 +1,184 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// The disk implementation and privileged suite repeat this matrix using checked |
| +// rootstate descriptors. This suite keeps every write boundary runnable in CI. |
| +func TestPlan14RootFloorCrashOrdering(t *testing.T) { |
| + for _, after := range []bool{false, true} { |
| + for fail := 1; fail <= 5; fail++ { |
| + private := &rollbackMemory{files: map[string][]byte{}} |
| + public := &rollbackMemory{files: map[string][]byte{}} |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + wrap := func(s rootstate.Store) rootstate.Store { |
| + return &floorFaultStore{Store: s, n: &n, fail: fail, after: after} |
| + } |
| + broken := f |
| + broken.private, broken.public = wrap(private), wrap(public) |
| + if err := broken.refresh(context.Background(), &b, nil, nil); err == nil { |
| + t.Fatalf("boundary %d after=%v succeeded", fail, after) |
| + } |
| + // No failed transaction may advertise its new accepted generation. |
| + if p, err := f.projection(); err == nil && p.Floor != nil && *p.Floor == b { |
| + t.Fatalf("boundary %d after=%v acknowledged B", fail, after) |
| + } |
| + // A visible stricter selector must not be overwritten by a weaker retry. |
| + bound, _ := f.bound() |
| + if bound != nil && *bound == b { |
| + if err := f.refresh(context.Background(), &a, nil, nil); err == nil { |
| + t.Fatal("regression accepted after interrupted rise") |
| + } |
| + } |
| + if err := f.refresh(context.Background(), &b, nil, nil); err != nil { |
| + t.Fatalf("retry %d/%v: %v", fail, after, err) |
| + } |
| + p, err := f.projection() |
| + if err != nil || p.Floor == nil || *p.Floor != b { |
| + t.Fatal("retry did not durably accept B", p, err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +const floorTestDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| + |
| +type floorFaultStore struct { |
| + rootstate.Store |
| + n *int |
| + fail int |
| + after bool |
| +} |
| + |
| +func (s *floorFaultStore) Write(name string, raw []byte) error { |
| + *s.n++ |
| + if *s.n == s.fail && !s.after { |
| + return os.ErrInvalid |
| + } |
| + err := s.Store.Write(name, raw) |
| + if *s.n == s.fail && s.after { |
| + return os.ErrInvalid |
| + } |
| + return err |
| +} |
| + |
| +func TestPlan14RootFloorReconstructionAuthority(t *testing.T) { |
| + a := moves.Floor{Min: 1, MinRelease: "v1.0.0"} |
| + b := moves.Floor{Min: 2, MinRelease: "v2.0.0"} |
| + for _, tc := range []string{"unknown", "legacy", "corrupt", "deleted", "regressed", "stronger legacy", "never armed", "wrong identity"} { |
| + t.Run(tc, func(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"}} |
| + if tc == "unknown" || tc == "legacy" { |
| + var legacy []*moves.Floor |
| + if tc == "legacy" { |
| + legacy = []*moves.Floor{&b} |
| + } |
| + if f.refresh(t.Context(), &a, legacy, nil) == nil { |
| + t.Fatal("unknown history accepted") |
| + } |
| + if _, err := f.projection(); err == nil { |
| + t.Fatal("unknown history advertised") |
| + } |
| + if tc == "legacy" { |
| + got, _ := f.bound() |
| + if got == nil || *got != b { |
| + t.Fatal("legacy deny lost", got) |
| + } |
| + } |
| + return |
| + } |
| + r := rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "complete history", Digest: floorTestDigest, Authorization: "operator"} |
| + if tc == "never armed" { |
| + r.Floor = nil |
| + r.NeverArmed = true |
| + } |
| + if tc == "wrong identity" { |
| + r.Identity.Enrollment = "other" |
| + if f.reconstruct(t.Context(), r) == nil { |
| + t.Fatal("foreign reconstruction") |
| + } |
| + return |
| + } |
| + if err := f.reconstruct(t.Context(), r); err != nil { |
| + t.Fatal(err) |
| + } |
| + var remote *moves.Floor |
| + var legacy []*moves.Floor |
| + var legacyErr error |
| + switch tc { |
| + case "corrupt": |
| + remote = &a |
| + legacyErr = os.ErrInvalid |
| + case "regressed": |
| + remote = &a |
| + case "stronger legacy": |
| + legacy = []*moves.Floor{{Min: 3, MinRelease: "v1.0.0"}} |
| + remote = &b |
| + case "never armed": |
| + remote = &a |
| + } |
| + err := f.refresh(t.Context(), remote, legacy, legacyErr) |
| + bad := tc == "corrupt" || tc == "regressed" || tc == "stronger legacy" |
| + if (err != nil) != bad { |
| + t.Fatal(err) |
| + } |
| + if bad { |
| + if _, err := f.projection(); err == nil { |
| + t.Fatal("quarantine advertised") |
| + } |
| + } |
| + if tc == "deleted" { |
| + got, e := f.bound() |
| + if e != nil || got == nil || *got != b { |
| + t.Fatal(got, e) |
| + } |
| + } |
| + if tc == "never armed" { |
| + p, e := f.projection() |
| + if e != nil || p.Floor == nil || *p.Floor != a { |
| + t.Fatal(p, e) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootFloorLegacyIsDenyOnly(t *testing.T) { |
| + for _, body := range []string{`{"min":2,"min_release":"v2.0.0"} {}`, `{"min":2,"min_release":"v2.0.0","reset":true}`, `broken`} { |
| + dd := t.TempDir() |
| + dir := filepath.Join(dd, ".moves") |
| + if err := os.Mkdir(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "floor.json"), []byte(body), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "floor.min-release"), []byte("v3.0.0\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy, err := rootFloorLegacy(dd) |
| + if err == nil { |
| + t.Fatal("malformed mirror was not quarantined", body) |
| + } |
| + max := (*moves.Floor)(nil) |
| + for _, fl := range legacy { |
| + max = floorMaximum(max, fl) |
| + } |
| + if max == nil || max.MinRelease != "v3.0.0" { |
| + t.Fatal("usable deny disappeared", max) |
| + } |
| + } |
| +} |
diff --git a/internal/host/seedsfloor_test.go b/internal/host/seedsfloor_test.go| index fc92d7e2c..26289b0ef 100644 |
| --- a/internal/host/seedsfloor_test.go |
| +++ b/internal/host/seedsfloor_test.go |
| @@ -63,7 +63,7 @@ func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) { |
| if err := judgeSeedsPrev(opt, dd); err != nil { |
| t.Fatal(err) |
| } |
| - if err := rootJudgePrev(nil); err != nil { |
| + if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) { |
| @@ -125,7 +125,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) { |
| t.Fatal(err) |
| } |
| armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| - if err := rootJudgePrev(nil); err != nil { |
| + if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| t.Fatal(err) |
| } |
| if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" { |
| @@ -133,7 +133,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) { |
| } |
| // The floor rises past carlos.prev: the next pass takes the verdict away. |
| armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"}) |
| - if err := rootJudgePrev(nil); err != nil { |
| + if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| @@ -143,7 +143,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) { |
| writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) |
| os.Remove(filepath.Join(dd, seeds.FloorFile)) |
| os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile)) |
| - if err := rootJudgePrev(nil); err != nil { |
| + if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index ad80a4729..c145f925d 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -30,6 +30,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| "github.com/carlosframework/platform/internal/version" |
| ) |
| @@ -114,14 +115,22 @@ func RunSystemApply(args []string) { |
| log.Printf("system apply: %s=%q is not a channel; this box follows %s", systemChannelEnv, invalidChannel, channel) |
| } |
| a := &systemApplier{ |
| - ops: productionSysOps(), openStore: openSystemStore, channel: channel, |
| + rootMoves: rootMovesFloorIdentity, ops: productionSysOps(), openStore: openSystemStore, channel: channel, |
| optDir: release.OptDir(), dataDir: dataDir(), etcDir: "/etc", build: version.Build, |
| platform: runtime.GOOS + "-" + runtime.GOARCH, gateWait: systemGateWait, soak: systemGateSoak, |
| regionHA: regionHAEnabled(os.Getenv), |
| } |
| a.floorHook = installFloorHook // decided under the apply lock, in Run |
| - if err := a.Run(ctx, *recover); err != nil { |
| - log.Fatalf("%v", err) |
| + var runErr error |
| + if _, required, err := a.rootMoves.read(); err != nil { |
| + runErr = err |
| + } else if required { |
| + runErr = withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { return a.Run(ctx, *recover) }) |
| + } else { |
| + runErr = a.Run(ctx, *recover) |
| + } |
| + if runErr != nil { |
| + log.Fatalf("%v", runErr) |
| } |
| } |
| @@ -208,6 +217,7 @@ func productionSysOps() sysOps { |
| } |
| type systemApplier struct { |
| + rootMoves rootFloorIdentitySource |
| participation *rootparticipation.Ledger |
| floor *hostfloor.Disk |
| // floorHook, when set, decides legacy or floor-guarded state once Run |
| @@ -786,6 +796,17 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri |
| } |
| } |
| } |
| + if id, required, err := a.rootMoves.read(); err != nil { |
| + return err |
| + } else if required { |
| + f, err := openRootMovesFloors(a.optDir, id, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.close() |
| + j := rootFloorJudge{floors: f, current: systemBinaryPath(a.optDir), prev: systemPrevPath(a.optDir), idle: rootFloorDowngradeIdle, binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(a.optDir, path) }} |
| + return errors.Join(j.judge(ctx), judgeSeedsPrev(a.optDir, a.dataDir)) |
| + } |
| return a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir))) |
| } |
| err := withPrevLock(a.optDir, steps) |
| @@ -808,7 +829,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri |
| // (armed, value unknown). An armed seeds floor (seedsfloor.go) counts too, |
| // since the floor's steps write and judge both. |
| func (a *systemApplier) floorArmed() bool { |
| - f, err := readLocalFloor(a.dataDir) |
| + f, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir) |
| return err != nil || f != nil || seedsFloorArmed(a.dataDir) |
| } |
| @@ -834,6 +855,15 @@ func (a *systemApplier) floorStep(err error) error { |
| // deployment that never published a floor updates exactly as main does. |
| // The seeds floor is armed first, by the same rule (armSeedsFloor). |
| func (a *systemApplier) armFloor(ctx context.Context) error { |
| + if id, required, err := a.rootMoves.read(); err != nil { |
| + return err |
| + } else if required { |
| + if err := refreshRootMovesFloor(ctx, a.store, a.optDir, a.dataDir, id); err != nil { |
| + return err |
| + } |
| + return a.armSeedsFloor(ctx) |
| + } |
| + |
| if err := a.armSeedsFloor(ctx); err != nil { |
| return err |
| } |
| @@ -948,12 +978,12 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error) |
| return fn() |
| } |
| locked := false |
| - err := withFloorLock(a.dataDir, true, func(*movesDir) error { |
| + err := withEffectiveMovesFloorLock(a.rootMoves, a.optDir, a.dataDir, true, func() error { |
| locked = true |
| if err := seedsActivationRefusal(target, a.dataDir); err != nil { |
| return err |
| } |
| - floor, err := readLocalFloor(a.dataDir) |
| + floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir) |
| if err != nil { |
| return refuse("moves protocol floor: the floor on this box cannot be read: %v", err) |
| } |
| @@ -963,6 +993,16 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error) |
| if err := floorRefusal(target, floor); err != nil { |
| return err |
| } |
| + if _, required, err := a.rootMoves.read(); err != nil { |
| + return err |
| + } else if required { |
| + if at, _ := moves.ReleaseAtOrAbove(target, running); !at { |
| + if err := rootFloorDowngradeIdle(context.Background()); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + |
| frozen := false |
| err = a.freezeHolds(func(held []registry.Route) error { |
| frozen = true |
| @@ -1024,7 +1064,7 @@ func (a *systemApplier) activationRule(version string, running func() string) er |
| if err := seedsActivationRefusal(version, a.dataDir); err != nil { |
| return err |
| } |
| - floor, err := readLocalFloor(a.dataDir) |
| + floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir) |
| if err != nil { |
| return refuse("moves protocol floor: the floor on this box cannot be read: %v", err) |
| } |
| @@ -1058,7 +1098,7 @@ func (a *systemApplier) floorAllowsForward(version string) error { |
| if err := seedsActivationRefusal(version, a.dataDir); err != nil { |
| return err |
| } |
| - floor, err := readLocalFloor(a.dataDir) |
| + floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir) |
| if err != nil { |
| return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err) |
| } |
| @@ -1138,6 +1178,23 @@ const systemApplyLayoutRefusal = "%s predates the instance layout, and this box |
| // way of a release before the layout. A registry that will not open cannot |
| // vouch that nothing is nested, so its error is itself a reason. |
| func (a *systemApplier) layoutReversalReasons() []string { |
| + if _, required, err := a.rootMoves.read(); err != nil { |
| + return []string{err.Error()} |
| + } else if required { |
| + if err := rootFloorDowngradeIdle(context.Background()); err != nil { |
| + return []string{err.Error()} |
| + } |
| + snapshot, err := productionGenerationInventory(context.Background()) |
| + if err != nil { |
| + return []string{err.Error()} |
| + } |
| + for _, view := range snapshot.Installations { |
| + if !view.Installation.Empty { |
| + return []string{errRootFloorHistory.Error()} |
| + } |
| + } |
| + } |
| + |
| open := a.openRegistry |
| if open == nil { |
| open = func() (*registry.Registry, error) { return openSystemRegistry(a.dataDir) } |
| @@ -1602,6 +1659,9 @@ 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 |
| @@ -1740,6 +1800,9 @@ 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 |
| @@ -1909,6 +1972,13 @@ func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPoint |
| p, _, err := release.GetSystemPointer(ctx, store, pub, release.SystemComponentRouter, a.channel) |
| switch { |
| case err == nil: |
| + if _, required, e := a.rootMoves.read(); e != nil { |
| + return nil, e |
| + } else if required { |
| + if e := rememberRootFloorSystemIdentity(ctx, store, pub, a.optDir, p); e != nil { |
| + return nil, e |
| + } |
| + } |
| return p, nil |
| case errors.Is(err, os.ErrNotExist): |
| return nil, fmt.Errorf("%w: no %s pointer for this box's channel", errSystemRefused, a.channel) |
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service| index 9f2ceba80..84b856754 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 [ -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 [ -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 aa1f16c94..19be448cf 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 [ -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 [ -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/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go| new file mode 100644 |
| index 000000000..89c7b52cd |
| --- /dev/null |
| +++ b/internal/provision/plan14_p3_census_test.go |
| @@ -0,0 +1,27 @@ |
| +package provision |
| + |
| +import "testing" |
| + |
| +func TestPlan14P3OverlayPreservesLegacySeal(t *testing.T) { |
| + root := censusRoot(t) |
| + inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv", "plan14-root-reads-e.tsv", "plan14-root-reads-p3.tsv"} { |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if name == "plan14-root-reads-e.tsv" || name == "plan14-root-reads-p3.tsv" { |
| + if errs := checkRootReadLegacy(inv, "10e024eccd584f40b64e959a105c551302585e3e14e55104fc250dae5960898a"); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The conservative write scanner recognizes the local helper's name as a |
| +// record-key site. These calls write root floor files, never instance records. |
| +func plan14P3WriteCensusInventory() []censusEntry { |
| + return append(plan14EWriteCensusInventory(), |
| + 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/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 8dc80626b..34d1c3e3c 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -745,6 +745,9 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-e.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-p3.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 11c0eea4f..3ca18e8f0 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := plan14EWriteCensusInventory() |
| + inventory := plan14P3WriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv| new file mode 100644 |
| index 000000000..a375dbb96 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-p3.tsv |
| @@ -0,0 +1,54 @@ |
| +# 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 |
| +@coverage internal/edgesfile 46558f38ee9cef8def5a1915a2545e91795ede008b4b296f102a7ffd45dd1b82 |
| +@coverage internal/host f3a1d713d0139a5ceb8090e826e54c2a471d355b49451f9af806f645dcbedc79 |
| +@coverage internal/host/units 4ec2973162254c97bad311eb1e10536e5aea1e53f4c4614238e350e94a8e0095 |
| +@coverage internal/rootstate 058f59efad955ffeb098d79b4bda9dd9407078fb302dea501624b81297be4cf7 |
diff --git a/internal/rootstate/state.go b/internal/rootstate/state.go| index 24ca9aa22..ddecbae67 100644 |
| --- a/internal/rootstate/state.go |
| +++ b/internal/rootstate/state.go |
| @@ -18,6 +18,15 @@ type Store interface { |
| Close() error |
| } |
| +// Sync makes a checked directory's visible entries durable. A read-only |
| +// projection consumer uses it before acknowledging a newly observed record. |
| +func Sync(s Store) error { |
| + if d, ok := s.(interface{ Sync() error }); ok { |
| + return d.Sync() |
| + } |
| + return os.ErrPermission |
| +} |
| + |
| func component(s string) bool { |
| if s == "" || s == "." || s == ".." || len(s) > 255 { |
| return false |
diff --git a/internal/rootstate/state_linux.go b/internal/rootstate/state_linux.go| index aa5701f8f..d86aab80d 100644 |
| --- a/internal/rootstate/state_linux.go |
| +++ b/internal/rootstate/state_linux.go |
| @@ -117,6 +117,12 @@ func descendSync(parent *os.File, parts []string, create, public bool, owner uin |
| } |
| func (d *directory) Close() error { return d.file.Close() } |
| +func (d *directory) Sync() error { |
| + if err := d.check(); err != nil { |
| + return err |
| + } |
| + return d.file.Sync() |
| +} |
| func (d *directory) check() error { return trusted(int(d.file.Fd()), d.owner, unix.S_IFDIR) } |
| func (d *directory) Read(name string) ([]byte, error) { |
| if !component(name) { |