carlos / platform Public

WIP claim Plan 14 Part 3 checkpoint 2 (not for merge)

P3-R1..R3 and most of R4 fixed; F2 syscall-cut crash coverage; G2 phase engine, root census bootstrap with signed Seeds; J1-J3 matrices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3ca9a31bab14a828ee419afc05adc8e23f9d1e04 parent 097f2dd
48 files changed, +2573 −146
  • internal/console/boxselect.go +2 −1
  • internal/console/boxselect_test.go +31 −3
  • internal/console/plan14_root_floor_test.go +2 −0
  • internal/host/claimjournal_migration.go +8 −1
  • internal/host/guardexec.go +1 −1
  • internal/host/guardexec_edge_root_linux_test.go +72 −0
  • internal/host/plan14_root_dispatch_test.go +56 −0
  • internal/host/plan14_root_migration_test.go +220 −0
  • internal/host/plan14_root_payload_families_linux_test.go +18 −0
  • internal/host/rootinstallation_test.go +1 −0
  • internal/host/rootmigration.go +232 −0
  • internal/host/rootmigration_bootstrap_root_linux_test.go +161 −0
  • internal/host/rootmigration_census.go +223 −0
  • internal/host/rootmigration_census_linux_test.go +95 −0
  • internal/host/rootmigration_production_linux.go +236 −0
  • internal/host/rootmigration_production_other.go +13 −0
  • internal/host/rootmigration_root_linux_test.go +170 −0
  • internal/host/rootmovesfloor.go +62 −5
  • internal/host/rootmovesfloor_authorization.go +55 −0
  • internal/host/rootmovesfloor_boot.go +3 −6
  • internal/host/rootmovesfloor_boot_root_linux_test.go +2 −5
  • internal/host/rootmovesfloor_boot_test.go +2 −2
  • internal/host/rootmovesfloor_crash_root_linux_test.go +90 −0
  • internal/host/rootmovesfloor_recovery_root_linux_test.go +130 −0
  • internal/host/rootmovesfloor_refresh.go +13 −2
  • internal/host/rootmovesfloor_refresh_test.go +4 −4
  • internal/host/rootmovesfloor_root_linux_test.go +16 −5
  • internal/host/rootmovesfloor_source.go +13 −23
  • internal/host/rootmovesfloor_test.go +88 −4
  • internal/host/systemapply.go +23 −9
  • internal/host/units/carlos-system-recover.service +1 −1
  • internal/host/units/carlos-system-update.service +1 −1
  • internal/hostbox/migration_census_linux.go +104 −0
  • internal/hostbox/migration_census_other.go +9 −0
  • internal/hostguard/guard.go +3 −0
  • internal/hostguard/record.go +1 −0
  • internal/hostguard/root.go +13 −3
  • internal/hostguard/rootmigration.go +85 −0
  • internal/hostguard/rootmigration_inventory.go +40 −0
  • internal/hostguard/rootmigration_test.go +52 −0
  • internal/hostguard/rootview.go +14 −10
  • internal/provision/plan14_p3_census_test.go +1 −0
  • internal/provision/testdata/plan14-root-reads-p3.tsv +100 −53
  • internal/rootstate/fault_rootrunner_linux.go +27 −0
  • internal/rootstate/state_linux.go +5 −1
  • internal/safefile/fault_rootrunner_linux.go +12 −0
  • internal/safefile/file_linux.go +10 −6
  • internal/safefile/plan14_sync_linux_test.go +53 −0

CI passed — run details

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