carlos / platform Public

Plan 14 integrated: fix a mount-substitution security regression (not for merge)

Run 25's persistent-identity change let a substituted mount pass; identity again requires persistent filesystem/handle/inode evidence with same-boot mount relationships, original security tests unchanged. Raw .instances boundary violation fixed; host suite runtime reduced to 24m.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 176ae282346a27418d82e5b91df31c95e85a8295 parent 8b9895c
23 files changed, +713 −48
  • internal/host/claimproducer_compaction_test.go +4 −3
  • internal/host/movesland_test.go +4 −0
  • internal/host/movesrecover_test.go +4 −0
  • internal/host/movesrollback_test.go +4 −0
  • internal/host/plan14_chroot_mount_linux_test.go +104 −0
  • internal/host/plan14_fix5_live_linux_test.go +4 −0
  • internal/host/plan14_lifecycle_retire_root_linux_test.go +1 −0
  • internal/host/plan14_lifecycle_root_linux_test.go +1 −0
  • internal/host/plan14_lifecycle_trace_root_linux_test.go +18 −0
  • internal/host/plan14_private_inventory_view_linux_test.go +134 −0
  • internal/host/prooffile.go +1 −0
  • internal/host/rootinventory_sibling.go +5 −30
  • internal/host/rootinventory_state_linux.go +16 −1
  • internal/host/rootpersisted_identity_linux_test.go +27 −0
  • internal/layout/census.go +52 −0
  • internal/provision/plan14_p3_census_test.go +3 −1
  • internal/provision/testdata/plan14-root-reads-activation-tools.tsv +1 −1
  • internal/provision/testdata/plan14-root-reads-p3.tsv +18 −9
  • internal/release/plan14_generation_namespace_test.go +23 −2
  • internal/release/rootgenerationproof.go +10 −1
  • internal/rootstate/persistent_lineage_linux_test.go +117 −0
  • internal/rootstate/persistent_linux.go +95 −0
  • internal/rootstate/state.go +67 −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	310.024s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.614s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.183s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.701s
ok  	github.com/carlosframework/platform/internal/activator	250.091s
ok  	github.com/carlosframework/platform/internal/agentsock	0.014s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.441s
ok  	github.com/carlosframework/platform/internal/apperr	0.071s
ok  	github.com/carlosframework/platform/internal/applog	0.120s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.622s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.694s
ok  	github.com/carlosframework/platform/internal/changelog	0.307s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.023s
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	407.208s
?   	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.024s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.319s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	44.797s
?   	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.131s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.018s
ok  	github.com/carlosframework/platform/internal/geo	0.016s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.151s
ok  	github.com/carlosframework/platform/internal/host	173.248s
ok  	github.com/carlosframework/platform/internal/hostactors	0.647s
ok  	github.com/carlosframework/platform/internal/hostbox	2.568s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.562s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.012s
ok  	github.com/carlosframework/platform/internal/hostexec	4.912s
ok  	github.com/carlosframework/platform/internal/hostfence	0.033s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.489s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.042s
ok  	github.com/carlosframework/platform/internal/hostops	1.603s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.012s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.012s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.459s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.140s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.013s
ok  	github.com/carlosframework/platform/internal/infraview	0.012s
ok  	github.com/carlosframework/platform/internal/instancectl	5.235s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.735s
ok  	github.com/carlosframework/platform/internal/instancefs	0.017s
ok  	github.com/carlosframework/platform/internal/instancekey	0.014s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.560s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.014s
ok  	github.com/carlosframework/platform/internal/lease	9.798s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.156s
ok  	github.com/carlosframework/platform/internal/maintenance	0.079s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.530s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.077s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.090s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.794s
ok  	github.com/carlosframework/platform/internal/objstore	0.191s
ok  	github.com/carlosframework/platform/internal/ops	0.166s
ok  	github.com/carlosframework/platform/internal/park	0.201s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	35.364s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.403s
ok  	github.com/carlosframework/platform/internal/relay	7.270s
ok  	github.com/carlosframework/platform/internal/release	21.992s
ok  	github.com/carlosframework/platform/internal/rollout	3.724s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.006s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.058s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.580s
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.487s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.455s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.050s
ok  	github.com/carlosframework/platform/internal/status	0.039s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.206s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.005s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.004s
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.561s
=== 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: 48824482 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/17 12:47:10 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF51E7479875DC, 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.021s
minio contract: PASS
diff --git a/internal/host/claimproducer_compaction_test.go b/internal/host/claimproducer_compaction_test.go
index 4fd856e4d..d2a394f1a 100644
--- a/internal/host/claimproducer_compaction_test.go
+++ b/internal/host/claimproducer_compaction_test.go
@@ -39,7 +39,8 @@ func TestClaimCompletedProducersBoundStopAndRetry(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- const deliveries = 30000
+ const deliveries = 1024
+ producerID := func(n int) string { return fmt.Sprintf("%01024d", n) }
set := &ClaimProducerSet{}
if err := set.Add(p); err != nil {
t.Fatal(err)
@@ -54,10 +55,10 @@ func TestClaimCompletedProducersBoundStopAndRetry(t *testing.T) {
route := registry.Route{Host: req.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp}}
timer := registry.Timer{Host: req.Host, ClaimOwnership: route.ClaimOwnership}
seq := 0
- deliver := ClaimTimerDelivery(set, func() string { id := fmt.Sprintf("%032d", seq); seq++; return id })
+ deliver := ClaimTimerDelivery(set, func() string { id := producerID(seq); seq++; return id })
oldIDs := make([]string, 0, deliveries)
for n := 0; n < deliveries; n++ {
- id := fmt.Sprintf("timer-%032d", n)
+ id := "timer-" + producerID(n)
oldIDs = append(oldIDs, id)
_, finish, err := deliver(t.Context(), work, route, timer)
if err != nil {
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go
index c5e52bbc9..0eed42c61 100644
--- a/internal/host/movesland_test.go
+++ b/internal/host/movesland_test.go
@@ -1226,11 +1226,15 @@ func TestLandingDeadlineRollsBack(t *testing.T) {
// a fresh worker to restored, yields one object per generation key, one
// timers replacement, one install manifest, and the same end state.
func TestLandingReplaysArePhaseIdempotent(t *testing.T) {
+ slots := make(chan struct{}, 4)
points := []string{"after-previous", "after-activate", "after-record-parking", "after-residue-queued", "after-record-building", "after-build",
"after-record-published", "after-record-local", "after-retired", "after-staged", "after-files-rename", "after-files_placed", "after-extracted", "after-inventory", "after-timers",
"after-columns", "after-verify", "after-files_manifest", "before-files-gen", "after-files_gen", "after-record-configured", "after-release-ready", "after-db-rename"}
for _, point := range points {
t.Run(point, func(t *testing.T) {
+ t.Parallel()
+ slots <- struct{}{}
+ defer func() { <-slots }()
h := newLandingHarness(t)
h.flip(t)
h.earlierStay(t)
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go
index 9c2669289..b2e181880 100644
--- a/internal/host/movesrecover_test.go
+++ b/internal/host/movesrecover_test.go
@@ -900,12 +900,16 @@ func TestSourceJournalsParkedAfterTheClose(t *testing.T) {
// claim (and after each record step), then a restarted worker, ends opened
// with one hold, opened once.
func TestSourceRecoveryReplays(t *testing.T) {
+ slots := make(chan struct{}, 4)
for _, point := range []string{
"after-record-claimed", "after-return-claimed", "after-activate", "after-record-restoring", "after-hand",
"verify", "after-record-verified", "after-claim-probing", "after-record-probing", "after-claim-probed",
"after-record-recovered", "after-row-opened", "after-claim-opened", "after-record-opened",
} {
t.Run(point, func(t *testing.T) {
+ t.Parallel()
+ slots <- struct{}{}
+ defer func() { <-slots }()
h := newRecoverHarness(t, false)
rb := h.released(t)
h.rcAt = point
diff --git a/internal/host/movesrollback_test.go b/internal/host/movesrollback_test.go
index 7daf5b272..838334906 100644
--- a/internal/host/movesrollback_test.go
+++ b/internal/host/movesrollback_test.go
@@ -1203,12 +1203,16 @@ func TestJournalSweepWaitsForTheTargetPark(t *testing.T) {
// Fix round 1, ruling 4: a crash at each durable step of the cleaning, then a
// fresh worker, finishes it with nothing live left.
func TestTargetCleaningReplays(t *testing.T) {
+ slots := make(chan struct{}, 4)
for _, point := range []string{
"before-rb-local-retire", "after-rb-local-retire", "after-" + rbStepInstallation,
"after-" + rbStepLocal + "-plan", "after-rename:" + p1Host + ".db-wal", "after-" + rbStepLocal,
"after-rb-row-pending", "after-rb-row-delete", "after-" + rbStepRowDeleted,
} {
t.Run(point, func(t *testing.T) {
+ t.Parallel()
+ slots <- struct{}{}
+ defer func() { <-slots }()
h := newRollbackHarness(t)
if err := os.WriteFile(provision.DefaultDBPath(h.tdir, p1Host)+"-wal", []byte("a wal"), 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/host/plan14_chroot_mount_linux_test.go b/internal/host/plan14_chroot_mount_linux_test.go
new file mode 100644
index 000000000..ec9a64271
--- /dev/null
+++ b/internal/host/plan14_chroot_mount_linux_test.go
@@ -0,0 +1,104 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/rootstate"
+ "golang.org/x/sys/unix"
+ "os"
+ "testing"
+)
+
+func TestPlan14PersistentChrootMountBoundary(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, p := range []string{"/data", "/view"} {
+ if e := os.Mkdir(p, 0755); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e := unix.Mount("/data", "/view", "", unix.MS_BIND, ""); e != nil {
+ t.Fatal(e)
+ }
+ defer unix.Unmount("/view", 0)
+ f, e := os.Open("/view")
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer f.Close()
+ before, e := rootstate.ObjectIdentity(f)
+ if e != nil || before.MountLineage == "" {
+ t.Fatal(before, e)
+ }
+ if e := unix.Mount("/view", "/view", "", unix.MS_BIND, ""); e != nil {
+ t.Fatal(e)
+ }
+ second, e := os.Open("/view")
+ if e != nil {
+ t.Fatal(e)
+ }
+ after, e := rootstate.ObjectIdentity(second)
+ second.Close()
+ unmount := unix.Unmount("/view", 0)
+ if e != nil || unmount != nil || before.Same(after) {
+ t.Fatal("substituted mount", e, unmount, before, after)
+ }
+ restored, e := rootstate.ObjectIdentity(f)
+ if e != nil || !before.Same(restored) {
+ t.Fatal("restored mount", e)
+ }
+ if err := unix.Unmount("/view", unix.MNT_DETACH); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount("/data", "/view", "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ replacement, err := os.Open("/view")
+ if err != nil {
+ t.Fatal(err)
+ }
+ replaced, err := rootstate.ObjectIdentity(replacement)
+ replacement.Close()
+ if err != nil || replaced.MountUnique == before.MountUnique || !replaced.SameMountNamespace(before) || replaced.MountLineage != before.MountLineage || replaced.Filesystem != before.Filesystem || replaced.Handle != before.Handle || replaced.Inode != before.Inode {
+ t.Fatal("replacement did not preserve geometry and persistent object", before, replaced, err)
+ }
+ if before.Same(replaced) {
+ t.Fatal("replacement mount with unchanged geometry accepted")
+ }
+ replaced.Mount = before.Mount
+ if before.Same(replaced) {
+ t.Fatal("replacement mount accepted with a recycled legacy number")
+ }
+ for _, path := range []string{"/source", "/parent", "/parent/view"} {
+ if err := os.Mkdir(path, 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := unix.Mount("/source", "/parent/view", "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount("/parent/view", 0)
+ capture := func() rootstate.FileIdentity {
+ f, err := os.Open("/parent/view")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ id, err := rootstate.ObjectIdentity(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+ }
+ child := capture()
+ if err := unix.Mount("/parent", "/parent", "", unix.MS_BIND|unix.MS_REC, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount("/parent", unix.MNT_DETACH)
+ rebound := capture()
+ if child.MountRoot != rebound.MountRoot || child.MountPoint != rebound.MountPoint || child.Filesystem != rebound.Filesystem || child.Handle != rebound.Handle || child.Inode != rebound.Inode {
+ t.Fatal("recursive bind changed the child fixture", child, rebound)
+ }
+ if child.Same(rebound) || rebound.Same(child) {
+ t.Fatal("recursive ancestor substitution accepted")
+ }
+}
diff --git a/internal/host/plan14_fix5_live_linux_test.go b/internal/host/plan14_fix5_live_linux_test.go
index 748bf028e..b55be925a 100644
--- a/internal/host/plan14_fix5_live_linux_test.go
+++ b/internal/host/plan14_fix5_live_linux_test.go
@@ -263,6 +263,7 @@ func fix5Run(t *testing.T, kind, history string, main bool) []fix5Result {
}
func TestPlan14Fix5MainEquivalence(t *testing.T) {
+ slots := make(chan struct{}, 4)
if !plan14RootFixture(t) {
return
}
@@ -272,6 +273,9 @@ func TestPlan14Fix5MainEquivalence(t *testing.T) {
for _, kind := range []string{"none", "record", "list", "guard", "different stamp"} {
for _, history := range []string{"healthy", "crash", "failed-then-reset-failed before rollback", "auto-restart", "inactive", "incident reset before", "incident reset after", "TestR4ForwardResetFailedError", "TestR4ForwardTryRestartError", "interrupted repoint"} {
t.Run(kind+"/"+history, func(t *testing.T) {
+ t.Parallel()
+ slots <- struct{}{}
+ defer func() { <-slots }()
want := fix5Run(t, kind, history, true)
got := fix5Run(t, kind, history, false)
if !reflect.DeepEqual(got, want) {
diff --git a/internal/host/plan14_lifecycle_retire_root_linux_test.go b/internal/host/plan14_lifecycle_retire_root_linux_test.go
index 05356d2f9..058378800 100644
--- a/internal/host/plan14_lifecycle_retire_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_retire_root_linux_test.go
@@ -122,6 +122,7 @@ func lifecycleVerifyRetiredPins(t *testing.T, store release.Store, key ed25519.P
}
parked := filepath.Join(".parked", j.Inventory.Captured.Incarnation, lifecycleHost, j.ID, a.Root, a.Name)
id, e := hostbox.CaptureInventoryIdentity(a.Identity.MountPoint, parked)
+ t.Logf("parked mount evidence before=%+v after=%+v", a.Identity, id)
if e != nil || !a.Identity.Same(id) {
t.Fatal("parked artifact lost its recorded mount or identity", e)
}
diff --git a/internal/host/plan14_lifecycle_root_linux_test.go b/internal/host/plan14_lifecycle_root_linux_test.go
index 86e11c9d2..6e24994d9 100644
--- a/internal/host/plan14_lifecycle_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_root_linux_test.go
@@ -976,6 +976,7 @@ func TestPlan14LifecycleControlProcess(t *testing.T) {
wrapped, err := WithProductionClaimLaunch(ctx, root)
lifecycleCheck(t, "launch constructor", err)
rootHTTP := wrapped.(ClaimRootWithLaunch)
+ rootHTTP.Root.Stop.Coordinator.Kernel = &lifecycleStopTrace{claimUnitStopKernel: rootHTTP.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel), t: t}
rootHTTP.Launch.Kernel = lifecycleKernelTrace{rootHTTP.Launch.Kernel, t}
rootHTTP.Launch.Units = lifecycleUnitsTrace{rootHTTP.Launch.Units, t}
begin := rootHTTP.Launch.generations.beginUpdate
diff --git a/internal/host/plan14_lifecycle_trace_root_linux_test.go b/internal/host/plan14_lifecycle_trace_root_linux_test.go
index 679b1750a..24b28ce6c 100644
--- a/internal/host/plan14_lifecycle_trace_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_trace_root_linux_test.go
@@ -22,6 +22,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
execlease "github.com/carlosframework/platform/internal/hostexec"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/park"
)
@@ -31,6 +32,23 @@ type lifecycleControlTrace struct {
t *testing.T
}
+type lifecycleStopTrace struct {
+ claimUnitStopKernel
+ t *testing.T
+ once sync.Once
+}
+
+func (k *lifecycleStopTrace) VerifyStopped(ctx context.Context, target hostguard.FenceTarget, inv StopInventory, ack DrainAcknowledgement) (StopKernelEvidence, error) {
+ evidence, err := k.claimUnitStopKernel.VerifyStopped(ctx, target, inv, ack)
+ if err != nil {
+ k.once.Do(func() {
+ journal, readErr := k.Journal.Read(ctx, target)
+ k.t.Logf("stop kernel refusal: %v; journal=%+v read=%v acknowledgement=%+v inventory=%+v", err, journal, readErr, ack, inv)
+ })
+ }
+ return evidence, err
+}
+
type lifecycleResponse struct {
http.ResponseWriter
status int
diff --git a/internal/host/plan14_private_inventory_view_linux_test.go b/internal/host/plan14_private_inventory_view_linux_test.go
new file mode 100644
index 000000000..744467f77
--- /dev/null
+++ b/internal/host/plan14_private_inventory_view_linux_test.go
@@ -0,0 +1,134 @@
+package host
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "syscall"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "golang.org/x/sys/unix"
+)
+
+func TestPlan14PreparedInventoryPrivateView(t *testing.T) {
+ mode := os.Getenv("PLAN14_INVENTORY_VIEW")
+ binary, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode == "" {
+ root := t.TempDir()
+ cmd := exec.Command("unshare", "--user", "--map-root-user", "--mount", binary, "-test.run=^TestPlan14PreparedInventoryPrivateView$", "-test.v")
+ cmd.Env = append(os.Environ(), "PLAN14_INVENTORY_VIEW=root", "PLAN14_INVENTORY_ROOT="+root)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("private inventory fixture: %v\n%s", err, out)
+ }
+ t.Log(string(out))
+ return
+ }
+ root := os.Getenv("PLAN14_INVENTORY_ROOT")
+ view := filepath.Join(root, "view")
+ if mode == "root" {
+ for _, name := range []string{"raw", "view", "other"} {
+ if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := unix.Mount(filepath.Join(root, "raw"), view, "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount(view, 0)
+ id, err := hostbox.CaptureInventoryIdentity(root, "view")
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := hostbox.StopInventoryEntry{RelativePath: "view", Device: id.Device, Inode: id.Inode, Type: unix.S_IFDIR, Identity: id}
+ raw, err := json.Marshal(e)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "entry.json"), raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(binary, "-test.run=^TestPlan14PreparedInventoryPrivateView$", "-test.v")
+ cmd.Env = append(os.Environ(), "PLAN14_INVENTORY_VIEW=private")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: unix.CLONE_NEWNS}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("private child: %v\n%s", err, out)
+ }
+ t.Log(string(out))
+ if err := hostbox.VerifyInventoryIdentity(root, e); err != nil {
+ t.Fatal("root mount changed", err)
+ }
+ return
+ }
+ raw, err := os.ReadFile(filepath.Join(root, "entry.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var e hostbox.StopInventoryEntry
+ if err := json.Unmarshal(raw, &e); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount(root, root, "", unix.MS_BIND|unix.MS_REC, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount(root, unix.MNT_DETACH)
+ now, err := hostbox.CaptureInventoryIdentity(root, "view")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("root identity=%+v; private identity=%+v", e.Identity, now)
+ if err := hostbox.VerifyInventoryIdentity(root, e); err == nil {
+ t.Fatal("root verifier admitted added ancestor")
+ }
+ if err := verifyPreparedStateIdentity(root, e); err != nil {
+ t.Fatal("private view refused", err)
+ }
+ owner := sha256.Sum256([]byte("view.example\x00inc\x001\x00box"))
+ e.Kind, e.ManagedRoot, e.Materialization, e.Ownership = "path", "data", "materialized", hex.EncodeToString(owner[:])
+ inventory := hostbox.StopInventory{Host: "view.example", Incarnation: "inc", InstallationStamp: 1, Entries: []hostbox.StopInventoryEntry{e}}
+ saved, _ := json.Marshal(inventory)
+ a := ClaimRoleAssembly{ManagedRoots: map[string]string{"data": root}}
+ changedState, err := a.prepareStateInventory(ClaimLaunchInputs{DBPath: filepath.Join(view, "db.sqlite")}, &inventory, hostbox.Actor{Box: "box"})
+ after, _ := json.Marshal(inventory)
+ if err != nil || changedState || !reflect.DeepEqual(saved, after) {
+ t.Fatal("private preparation changed root authority", changedState, err)
+ }
+ oldBoot := e
+ oldBoot.Identity.Boot = "old"
+ if err := verifyPreparedStateIdentity(root, oldBoot); err == nil {
+ t.Fatal("private-view exception crossed reboot")
+ }
+ changed := e
+ changed.Device++
+ if err := verifyPreparedStateIdentity(root, changed); err == nil {
+ t.Fatal("changed entry accepted")
+ }
+ if err := unix.Mount(view, view, "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyPreparedStateIdentity(root, e); err == nil {
+ t.Fatal("substituted view accepted")
+ }
+ if err := unix.Unmount(view, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount(filepath.Join(root, "other"), view, "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount(view, 0)
+ if err := verifyPreparedStateIdentity(root, e); err == nil {
+ t.Fatal("substituted directory accepted")
+ }
+}
diff --git a/internal/host/prooffile.go b/internal/host/prooffile.go
index 22fa81234..ac1f35ab0 100644
--- a/internal/host/prooffile.go
+++ b/internal/host/prooffile.go
@@ -6,6 +6,7 @@ func proofFileSource(file proofUnitFile) []byte {
if file.Persistent.Boot != "" {
stable := file.Persistent
stable.Boot, stable.Device, stable.Mount, stable.MountNamespace = "", 0, 0, ""
+ stable.MountUnique, stable.NamespaceUnique = 0, 0
raw, _ := json.Marshal(struct {
Path string
Stable any
diff --git a/internal/host/rootinventory_sibling.go b/internal/host/rootinventory_sibling.go
index fac0248fc..f1b3e4de8 100644
--- a/internal/host/rootinventory_sibling.go
+++ b/internal/host/rootinventory_sibling.go
@@ -7,6 +7,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootstate"
@@ -20,7 +21,8 @@ func (h ClaimRootHTTP) rootEmptyCensus() *hostbox.EmptyInstallationCensus {
func (h ClaimRootHTTP) rootSiblingPath(ctx context.Context, selected, root, rel string) (bool, error) {
parent := filepath.ToSlash(filepath.Dir(rel))
- if !(root == "data" && (parent == "instances" || parent == ".instances") || root == "opt" && (parent == "live" || parent == "previous")) {
+ instance, instancePath := layout.InstanceCensusName(rel)
+ if !(root == "data" && instancePath || root == "opt" && (parent == "live" || parent == "previous")) {
return false, nil
}
payloads, err := h.Launches.GenerationPayloads(ctx)
@@ -32,7 +34,7 @@ func (h ClaimRootHTTP) rootSiblingPath(ctx context.Context, selected, root, rel
if in.Host == selected {
continue
}
- candidate := root == "data" && (rel == filepath.ToSlash(filepath.Join("instances", provision.InstanceID(in.Host))) || rel == filepath.ToSlash(filepath.Join(".instances", provision.InstanceID(in.Host)))) || root == "opt" && (rel == "live/"+in.Host || rel == "previous/"+in.Host)
+ candidate := root == "data" && (instance == provision.InstanceID(in.Host)) || root == "opt" && (rel == "live/"+in.Host || rel == "previous/"+in.Host)
if !candidate {
continue
}
@@ -51,7 +53,7 @@ func (h ClaimRootHTTP) rootSiblingPath(ctx context.Context, selected, root, rel
if e.Kind == "path" && e.ManagedRoot == root && e.RelativePath == filepath.ToSlash(filepath.Join("instances", provision.InstanceID(in.Host))) && e.Materialization == "materialized" {
err := generationPayloadFiles(ctx, h.Roots[root], []hostbox.GenerationPayload{{Inventory: hostbox.StopInventory{Entries: []hostbox.StopInventoryEntry{e}}}})
if err == nil && rel != e.RelativePath {
- err = rootSiblingAlias(h.Roots[root], rel, e.RelativePath)
+ err = layout.VerifyInstanceCensusAlias(h.Roots[root], rel, in.Host)
}
return err == nil, err
}
@@ -81,30 +83,3 @@ func (h ClaimRootHTTP) rootSiblingPath(ctx context.Context, selected, root, rel
}
return false, nil
}
-
-func rootSiblingAlias(root, alias, public string) error {
- a, err := hostbox.CaptureInventoryIdentity(root, alias)
- if err != nil {
- return err
- }
- b, err := hostbox.CaptureInventoryIdentity(root, public)
- if err != nil {
- return err
- }
- ar, err := filepath.Rel(a.MountPoint, filepath.Join(root, alias))
- if err != nil || !filepath.IsLocal(ar) {
- return os.ErrPermission
- }
- br, err := filepath.Rel(b.MountPoint, filepath.Join(root, public))
- if err != nil || !filepath.IsLocal(br) {
- return os.ErrPermission
- }
- if a.Boot != b.Boot || a.Device != b.Device || a.MountNamespace != b.MountNamespace || filepath.Join(a.MountRoot, ar) != filepath.Join(b.MountRoot, br) {
- return os.ErrPermission
- }
- b.MountRoot, b.MountPoint, b.Mount = a.MountRoot, a.MountPoint, a.Mount
- if !a.Same(b) {
- return os.ErrPermission
- }
- return nil
-}
diff --git a/internal/host/rootinventory_state_linux.go b/internal/host/rootinventory_state_linux.go
index e2be59b59..a01ce8698 100644
--- a/internal/host/rootinventory_state_linux.go
+++ b/internal/host/rootinventory_state_linux.go
@@ -106,7 +106,7 @@ func (a *ClaimRoleAssembly) prepareStateInventory(in ClaimLaunchInputs, inventor
return false, hostbox.ErrTypedInventory
}
e := inventory.Entries[index]
- if e.Materialization != "materialized" || stat.Mode&unix.S_IFMT != unix.S_IFDIR || hostbox.VerifyInventoryIdentity(root, e) != nil {
+ if e.Materialization != "materialized" || stat.Mode&unix.S_IFMT != unix.S_IFDIR || verifyPreparedStateIdentity(root, e) != nil {
return false, hostbox.ErrTypedInventory
}
return false, nil
@@ -142,3 +142,18 @@ func (a *ClaimRoleAssembly) prepareStateInventory(in ClaimLaunchInputs, inventor
entry.Materialization = "materialized"
return true, nil
}
+
+func verifyPreparedStateIdentity(root string, e hostbox.StopInventoryEntry) error {
+ if err := hostbox.VerifyInventoryIdentity(root, e); err == nil || !errors.Is(err, hostbox.ErrChanged) {
+ return err
+ }
+ current, err := hostbox.CaptureInventoryIdentity(root, e.RelativePath)
+ if err != nil {
+ return err
+ }
+ if e.Identity.Device != e.Device || e.Identity.Inode != e.Inode || e.Identity.Mode&unix.S_IFMT != e.Type ||
+ current.Boot != e.Identity.Boot || current.SameMountNamespace(e.Identity) || !current.Same(e.Identity) {
+ return hostbox.ErrChanged
+ }
+ return nil
+}
diff --git a/internal/host/rootpersisted_identity_linux_test.go b/internal/host/rootpersisted_identity_linux_test.go
index 2a676335a..2400438d5 100644
--- a/internal/host/rootpersisted_identity_linux_test.go
+++ b/internal/host/rootpersisted_identity_linux_test.go
@@ -3,6 +3,7 @@
package host
import (
+ "bytes"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/rootstate"
"os"
@@ -22,6 +23,32 @@ func testPersistentInventory(t *testing.T, path string) rootstate.FileIdentity {
}
return id
}
+
+func TestPlan14ProofFileSourceBootEvidence(t *testing.T) {
+ before := proofUnitFile{Path: "/opt/app", Persistent: rootstate.FileIdentity{Boot: "old", Device: 1, Inode: 2, Filesystem: "fs", Handle: "handle", MountRoot: "/", MountPoint: "/", MountNamespace: "mnt:[1]", Mount: 3, MountLineage: "/\t/\n", MountUnique: 100, NamespaceUnique: 10}}
+ after := before
+ after.Persistent.Boot = "new"
+ after.Persistent.Device++
+ after.Persistent.Mount++
+ after.Persistent.MountUnique++
+ after.Persistent.NamespaceUnique++
+ if !sameProofFiles([]proofUnitFile{before}, []proofUnitFile{after}) || !bytes.Equal(proofFileSource(before), proofFileSource(after)) {
+ t.Fatal("unchanged persistent file lost its proof across reboot")
+ }
+ after = before
+ after.Persistent.MountUnique++
+ if sameProofFiles([]proofUnitFile{before}, []proofUnitFile{after}) {
+ t.Fatal("stable proof hash admitted a replacement mount")
+ }
+ if !bytes.Equal(proofFileSource(before), proofFileSource(after)) {
+ t.Fatal("boot-local mount evidence entered the stable hash")
+ }
+ after = before
+ after.Persistent.Handle = "other"
+ if sameProofFiles([]proofUnitFile{before}, []proofUnitFile{after}) || bytes.Equal(proofFileSource(before), proofFileSource(after)) {
+ t.Fatal("changed persistent file retained its proof")
+ }
+}
func TestPlan14InventoryWithoutPersistentEvidenceRefuses(t *testing.T) {
path := t.TempDir()
f, err := os.Open(path)
diff --git a/internal/layout/census.go b/internal/layout/census.go
new file mode 100644
index 000000000..cd5f4f877
--- /dev/null
+++ b/internal/layout/census.go
@@ -0,0 +1,52 @@
+package layout
+
+import (
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/provision"
+ "os"
+ "path/filepath"
+)
+
+func InstanceCensusName(rel string) (string, bool) {
+ parent := filepath.Dir(rel)
+ if parent != "instances" && parent != ".instances" {
+ return "", false
+ }
+ name := filepath.Base(rel)
+ if rel != filepath.Join(parent, name) || name == "." || name == ".." {
+ return "", false
+ }
+ return name, true
+}
+
+func VerifyInstanceCensusAlias(root, alias, host string) error {
+ public := filepath.Join("instances", provision.InstanceID(host))
+ if alias != filepath.Join(".instances", provision.InstanceID(host)) {
+ return os.ErrPermission
+ }
+ a, err := hostbox.CaptureInventoryIdentity(root, alias)
+ if err != nil {
+ return err
+ }
+ b, err := hostbox.CaptureInventoryIdentity(root, public)
+ if err != nil {
+ return err
+ }
+ ar, err := filepath.Rel(a.MountPoint, filepath.Join(root, alias))
+ if err != nil || !filepath.IsLocal(ar) {
+ return os.ErrPermission
+ }
+ br, err := filepath.Rel(b.MountPoint, filepath.Join(root, public))
+ if err != nil || !filepath.IsLocal(br) {
+ return os.ErrPermission
+ }
+ if !a.SameMountNamespace(b) || a.Device != b.Device || filepath.Join(a.MountRoot, ar) != filepath.Join(b.MountRoot, br) {
+ return os.ErrPermission
+ }
+ b.MountRoot, b.MountPoint, b.Mount, b.MountLineage = a.MountRoot, a.MountPoint, a.Mount, a.MountLineage
+ b.MountUnique = a.MountUnique
+ if !a.Same(b) {
+ return os.ErrPermission
+ }
+ return nil
+}
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go
index f18deb211..d69cda74b 100644
--- a/internal/provision/plan14_p3_census_test.go
+++ b/internal/provision/plan14_p3_census_test.go
@@ -53,7 +53,9 @@ func plan14P3WriteCensusInventory() []censusEntry {
kept = append(kept, e)
}
return append(kept,
- censusEntry{"internal/host/rootinventory_sibling.go", "ClaimRootHTTP.rootSiblingPath", "key-site", "key:RecordKey,lit:instances", "0ead075a56726415017bbbb5db140025b54faf3af387309c4ef86b8bd0b934a4"},
+ censusEntry{"internal/layout/census.go", "VerifyInstanceCensusAlias", "reference", "lit:instances", "86ce305862058b648878f2deed2b1289c8c109b510420e61a13dc376f70300d2"},
+ censusEntry{"internal/layout/census.go", "InstanceCensusName", "reference", "lit:instances", "f1997087a9be9630ffc0c62ac6ddcfb765b1a4bd7f8a15aa215dd0e44ca7047e"},
+ censusEntry{"internal/host/rootinventory_sibling.go", "ClaimRootHTTP.rootSiblingPath", "key-site", "key:RecordKey,lit:instances", "43657b556a4c238a0fae72aad5f89c13d1f4168b7417644934474ea918cf0176"},
censusEntry{"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "ffeb10f34954f5b4c8566ee1d66a89a99eb303a8611e4671696dd312c5296c29"},
censusEntry{"internal/hostclaim/retainedloss.go", "validateRetainedAdmissionLosses", "reference", "lit:instances", "b32ddaed69411feaa06c45e84287fb1c09b0168498231a5386e3acd937d335e0"},
censusEntry{"internal/host/rootexecution_authority.go", "ClaimRootHTTP.executionAuthority", "key-site", "key:RecordKey,lit:instances", "d4c3831803029792b9bdc5d1a98049fe31a4cbca24dc3f09aac768533cd8ffee"},
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
index 4e6c3cc1d..1f0c2effb 100644
--- a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
+++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
@@ -1,7 +1,7 @@
# Activation-tools runs 1–10: proposals only; loaded after all existing overlays.
@coverage . 2145f21807743eb683387a61738d6bb16d9c41d7d69a9071dbac765ccd9ec2d6
@coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2
-@coverage internal/host 3ff77942ddde8a5cd7c4d172eb797509f1384b2f1df4c503f16d170b48ca9ec8
+@coverage internal/host f9e020f793b41ae3bc207970c12c4245ce29f956c429a240e8b09b48912e00a5
@coverage internal/hostfence 552570510fb62d2a8822a3f24fa76aeb1437c0a9516966dc5ce15ef58e3fb4c6
internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@310,file:Read@5986,file:Read@6318 683059aedf27656d4b49c88102089b2bb2f2ce7fcc108ff8545e52a86a0f8385
internal/claimactivation/runner.go checkedStore.Delete authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 6bc9103a30cfd53cea59d611ccacb7dcc0ca0ea620b403497ef543f5bdaa0217
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index c0b7b59c2..09a6a99b3 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -203,9 +203,9 @@ internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory aut
@coverage internal/edgesfile e0a7dd2795beedde4d17389b96b562f3f9438ad8505ea19a62d1e9c4b921aefb
@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461
@coverage internal/hostfloor 696e5ea4fa37b924b68e8e262235b99298ee19342d875213ba8209e5287dd3c6
-@coverage internal/release 82473b1d79cb321075c98d392fae4d6ca9e036f5c2ecd38a849235d881dda6ae
+@coverage internal/release 3ce49ae65713e165924e659dbe17f3c2263583b1db7a80754a07748c12ecf8f6
@coverage internal/rootparticipation 7798d05a815af052b9b40d4ff381834a58b2740082e8f673c173e34cb3ce4f22
-@coverage internal/rootstate 1d77c02742516922b16847943e885dda8ee218e350de039aa5f177a8c20f2d29
+@coverage internal/rootstate 65d82ca158604732171c8f7b46546f7e4d8d0a925e2608d20289e77fe4ea3725
@coverage internal/safefile 7030f312f7a60487381f0dbae2cdecaaa83625b639b6af15104f912592550816
internal/host/rootgeneration_kernel_linux.go generationKernel.absent authority E2 rev3=PROPOSED-p3;run=24;identity=root claim/adopt/update controller uid 0;basis=bounded task and persistent generation inspection retains boot and process start checks; refusal diagnostics identify the failing task file:Readlink@312 9aee837e918fbc1cc6f16bbf8e6b5652a700cf0c767d99150cac3e87bb03d2a3
internal/host/rootgenerations_launch.go productionRootGenerations authority E2,E3 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=connects the census root generation source to independent runtime failure and served settlement;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Open@386 e5a3f19159348abed074b2e2c01f9e611e47783395e5f3d063b7c1a657145a92
@@ -244,7 +244,7 @@ internal/host/claimstop_writers_linux.go installationCoverage authority C,G,J4 r
internal/host/proofbinding.go productionProofBindingSource authority B3 rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=enables signed recovery lookups and persistent executable identity only after a successful participating census check; absent census keeps legacy observation store reads file:Open@209,file:Open@379,file:Read@1107 6312660ec9d139e25a408543b56372b428144ea6538add20963a6ccc10f91fbf
internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=binds canonical unit, executable and healthy history; signed manifest recovery lookup runs only for participating sources, never absent-census observation file:EvalSymlinks@1220,file:EvalSymlinks@1529,file:EvalSymlinks@562,file:EvalSymlinks@801,registry:Unit@3225,whole:Route@265,whole:Route@3722,whole:Route@3729 20ed721cbcaccb6ab813a0d317542a43fe057544811920ba43ac6431c3e73000
internal/host/proofbinding.go proofBindingSource.unitBinding authority B3,J1,E1 rev3=PROPOSED-p3;identity=root adoption and rollback proof binder uid 0;basis=hashes persistent file identity into the canonical unit binding while preserving exact legacy source hashes without census registry:Unit@508,registry:Unit@609,registry:Unit@800,whole:Route@2878,whole:Route@2885 82e214d1cf66d29d0900f5c1ce9cfe0a2f31c070da4bf1497ef3fd5273c09aad
-internal/host/prooffile.go proofFileSource authority B3 rev3=PROPOSED-p3;identity=root adoption and rollback proof binder uid 0;basis=excludes only boot-local numbers from persistent proof hashes; filesystem, handle, ownership and mount attachment remain bound;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=keeps filesystem handle, inode, ownership and mount attachment in canonical proof while excluding boot, device, mount number and mount namespace from cross-boot hashes f3ce69cdd8a0fba648dcf173904ba278ef4856a00280fa0991846743b68ec28f
+internal/host/prooffile.go proofFileSource authority B3 rev3=PROPOSED-p3;run=28;identity=root adoption and rollback proof binder uid 0;basis=canonical proof hashes exclude boot-local device mount namespace and kernel unique IDs; persistent filesystem handle inode owner mode and mount topology remain bound; sameProofFiles separately refuses same-boot mount replacement;test=TestPlan14ProofFileSourceBootEvidence 1f27b56d784c3767d92841c51055d8f6a621ad97f13f5acb780d40e3d89b4647
internal/host/prooffile.go sameProofFiles authority B3 rev3=PROPOSED-p3;identity=root adoption and rollback proof binder uid 0;basis=compares retained and current proof files using persistent evidence, refusing an implicit upgrade of old unproven files e531e1eac189f957d9cc0151319093e0b2ab0fcd80412abc0d2f3fefcfc3421a
internal/host/proofnamed.go proofBindingSource.record authority B3 rev3=PROPOSED-p3;identity=root adoption and rollback proof binder uid 0;basis=binds named-unit provenance to boot-stable file evidence and preserves the original no-census hash shape 97f1501b9543e36c6a231170425c5c79c7c22489500aadf119e064abc1abde93
internal/host/proofnamed.go proofBindingSource.trustedBytes authority B3,J1 rev3=PROPOSED-p3;identity=root adoption and rollback proof binder uid 0;basis=captures persistent evidence from the checked root-owned unit file descriptor alongside its bytes file:ReadAll@422 aa6e98efc7a57b01ed38cd051642d8d2f0b6abf2d527d5f70533e7365746096e
@@ -256,7 +256,7 @@ internal/host/rootgenerations_launch.go type generationLaunchPermit authority E2
internal/host/rootinventory.go ClaimRootHTTP.commitRootInventoryPending authority E2 rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=new-host empty census classifies sibling state only by exact root installation and payload revision with persistent identity, and sibling live links only by matching root generation reservation; unknown and selected-host artifacts refuse file:Read@1454,file:Read@3494,registry:Account@4858,registry:Account@4873,registry:App@4894,registry:App@4905,registry:ClaimIncarnation@4364,registry:ClaimOwner@4414,registry:ClaimOwner@5544,registry:ClaimOwnership@4956,registry:ClaimOwnership@4978,registry:ClaimOwnership@5860,registry:ClaimOwnership@8063,registry:ClaimRowID@4578,registry:ClaimRowID@4655,registry:ClaimStamp@4392,registry:DBPath@4922,registry:DBPath@4936,registry:Host@4828,registry:Host@4840,registry:Host@8527,registry:Name@8550,whole:ClaimOwnership@4960,whole:ClaimOwnership@4988,whole:ClaimOwnership@5870,whole:ClaimOwnership@8074,whole:Route@10317,whole:Route@3686,whole:Route@4069,whole:Route@4085,whole:Route@4140,whole:Route@4791,whole:Route@4804,whole:Route@4813,whole:Route@5051,whole:Route@5283,whole:Route@6117,whole:Route@6253,whole:Route@6477,whole:Route@7919,whole:Route@9582,whole:Route@9643,whole:Timer@8535,whole:Timer@8558 b00707c1c5a3b1f3b51fd573fbb09a176bda8a055e64232b9044b7c78640ba51
internal/host/rootinventory.go ClaimRootHTTP.verifyInventoryPath authority C,J1 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=uses checked persistent evidence for protected installation entries; explicit legacy fixtures preserve their same-boot check;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Lstat@315 2ee5519ed6e8ff27bcfdb5db7748a71f4f331a271d84d7688835798792824a78
internal/host/rootinventory_identity.go ClaimRootHTTP.bindInventoryPaths authority C,E2,J1 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=discards all caller-supplied persistent evidence and captures or revalidates it independently against the retained root inventory;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=discards caller proof, captures new objects independently, retains immutable prior proof, and verifies cross-boot ancestry through the shared inventory verifier;rev3=PROPOSED-p3;run=11;identity=root claim, migration or adoption controller uid 0;basis=requires the proposed boot to equal the independently observed boot for new materialization, discards caller filesystem proof, captures new objects independently, retains immutable prior proof, and verifies cross-boot ancestry through the shared inventory verifier 73c94e6482dafa576ac607ba4a54a83921febf0f2092361e88a259a884db75d7
-internal/host/rootinventory_state_linux.go ClaimRoleAssembly.prepareStateInventory authority C,J1 rev3=PROPOSED-p3;identity=carlos agent claim lifecycle uid carlos; final authorization remains in root;basis=uses root-supplied persistent state identity for remote inventory reuse while keeping agent proposals non-authoritative;rev3=PROPOSED-p3;run=11;identity=carlos agent uid carlos; root validates final inventory and launch;basis=merges E FileIdentity callers and P3 authority effects; retains the recorded family checks and both tracks behavioral fixtures file:Fstatat@1313,file:Fstatat@2304,file:Open@799,file:Openat@1086 c5355a26fa146eb0ddb4cc7c1e536de5781e171d52363a0be38ffb2074651a53
+internal/host/rootinventory_state_linux.go ClaimRoleAssembly.prepareStateInventory authority C,J1 rev3=PROPOSED-p3;run=28;identity=carlos agent claim lifecycle uid carlos; root independently validates every effect;basis=existing-state preparation only reads the root-recorded object through its private systemd view; same-boot namespace projection preserves filesystem handle inode owner mode and exact child mount layers; no root authority recapture or mutation file:Fstatat@1313,file:Fstatat@2300,file:Open@799,file:Openat@1086 43e8584ce50d540f9275e6d906d4a34dfbe7c68164ad0cfd8ab8286a24467913
internal/host/rootupdates_runtime.go beginRootGenerationUpdate authority E2,E3 rev3=PROPOSED-p3;run=20;identity=root launch uid 0;basis=passes already reserved generation binding to settings resolver while holding release and host exclusion; avoids recursive host lock acquisition c67ae6552b1232728d3cdcbef857b19ff81bce04bd05e79b8a65b11c67efc7c8
internal/host/rootupdates_runtime.go publishRootRetry authority E3 rev3=PROPOSED-p3;identity=root claim/adoption controller uid 0;basis=writes root-owned public retry status only from the independently verified root book file:Open@84 e3c680d5a7d3c423dc183319bb3d0518bbea6cdc752c7bd6b540d57f884b414e
internal/host/rootupdates_runtime.go rootRetryStatus hint E3 rev3=PROPOSED-p3;identity=carlos agent status probe uid carlos;basis=agent reads root-owned public retry status; an exact legacy-book acknowledgement may clear only the corresponding local mirror, never root authority file:Lstat@89,file:Open@183,file:Read@721 7db6b16062bd095634a03c58cfdcd3dde2d49c990a34fe37d260d0908a16aa5f
@@ -291,7 +291,7 @@ internal/caps/converge.go Restart authority E4,J4 rev3=PROPOSED-p3;run=10;identi
internal/caps/rootauthority.go Ops.authorizeMutation authority E4,J4 rev3=PROPOSED-p3;run=10;identity=root adopt uid 0;basis=refuses legacy unit capability mutation on any retained census or unreadable census; root configuration stays fixed; absence preserves legacy operations file:Lstat@55 5549b2317e66dda41d73b23216bfca7b18ab1814607140794eb40124feae3618
internal/host/claimactivation_preflight.go claimActivationPreflight authority G,J4 rev3=PROPOSED-p3;run=13;identity=agent uid carlos; root migration/adopt/control uid 0;basis=service-owned registry contributes refusal only; claimActivationVerified separately checks canonical records and root retained unit state registry:Backing@280,registry:Unit@260,whole:Route@246 e5713a34359330ef311f31c47e0342f8322471243a7aba61fdecc751a53f34b4
internal/host/rootgeneration_kernel_linux.go generationCurrentFiles authority E2 rev3=PROPOSED-p3;run=25;identity=root retirement and activation readers uid 0;basis=private controller and host mounts may expose the same canonical generation path; require equal filesystem-relative source path plus persistent filesystem/handle/inode/type/owner/mode and same-boot device, never a pathname alone file:Open@219 93510e08a5530962857316b1024a169891d2406c29a80df9b8e45d2c0785c9a6
-internal/rootstate/state.go FileIdentity.Same authority E2 rev3=PROPOSED-p3;run=25;identity=root claim, adoption or recovery controller uid 0;basis=retained filesystem, handle, inode, owner, mode and mount source/path remain exact; same-boot devices remain exact; mount numbers and namespace inode names are observations rather than durable object identity, including when a restarted private namespace reports the same namespace name 750c3a84f35450314ddca65fedbb9beb622d1c96b4e833c3a7d8d4e88dbfec1e
+internal/rootstate/state.go FileIdentity.Same authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=requires persistent filesystem handle inode ownership mode and ordered topology; same-boot unique namespace and mount evidence refuses replacement; old numeric mount and namespace names are not authority; reboot keeps persistent evidence mandatory 0fcbabb15c7cd01f1aca9346bd4ea18295dbf81e9631c406af33a185c8c9a29a
@removed internal/host/rootgeneration_kernel_linux.go generationCurrentBinding A,E2,G census-ruling-J4
@remove internal/host/rootgeneration_kernel_linux.go generationRetainedFiles
@removed internal/host/rootgeneration_kernel_linux.go generationRetainedFiles A,E2,G census-ruling-J4
@@ -365,7 +365,7 @@ internal/hostguard/recoveryfence.go Guard.PendingStopFences authority C,J4 rev3=
internal/hostguard/recoveryfence.go Guard.pendingFences authority C,J2,J4,J1 rev3=PROPOSED-p3;run=19;identity=root;basis=reads root-owned records under host lock and keeps exact barrier classification separate from ordinary stop recovery; legacy behavior retained 20be9535fd54587a54e3027f5388581ae5a64e5fcfced5a6031ffc9a4fc76a3b
internal/host/claimroleassembly_linux.go verifiedClaimExecutable authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=pins root-owned executable bytes and keeps the verified descriptor while supplying its absolute path to the root launcher file:Fstat@1122,file:Fstat@579,file:OpenFile@376,file:Read@949 7a7e9af520e4157e60ae93d12c3268a629a9b0bd1e83b263cdd27103cf2e227c
internal/host/claimroleassembly_linux.go claimExecutableName authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=duplicates the existing descriptor with CLOEXEC and its validated absolute name; never reopens a potentially replaced path e887880731a16c9e7934aef33a08169b34e7236f6d3b85a195602d35f8b69107
-@coverage internal/host 3ff77942ddde8a5cd7c4d172eb797509f1384b2f1df4c503f16d170b48ca9ec8
+@coverage internal/host f9e020f793b41ae3bc207970c12c4245ce29f956c429a240e8b09b48912e00a5
@coverage internal/hostbox 0b6ddb07bc7ef62f23e402757155825e8d098eb47ec9288c09b8754bb1632e56
@coverage internal/hostguard ca664497969680d692172abda403b6a5d6664f8d7c8bc16a6440f02dd8426be6
internal/host/claimstop.go ClaimStopCoordinator.FenceAndStop authority C,J4 rev3=PROPOSED-p3;run=20;identity=root instance-control uid 0;basis=authenticated session stop retains root fence before independent unjournaled absence check; proves no journaled installation, containment or open files before bypassing nonexistent writer closure; normal drain remains for recorded launches; no-census unchanged file:Read@1610 58147f3abbf278037fc660392aeec0b6e76b667e3fe8435845ebaedc62d2ead1
@@ -471,19 +471,28 @@ internal/hostbox/inventory_identity_other.go VerifyInventoryIdentity authority E
internal/host/rootgeneration_census_linux.go generationCensusRetry authority E2 rev3=PROPOSED-p3;run=24;identity=root claim/adopt/update controller uid 0;basis=restarts the entire independent kernel census at most three times only when a process disappears; unknown tasks and permission failures refuse immediately, and no partial census is accepted 3cfd54f395ed7e77b15c01b76567de0188573603aafc4e7ec72f0c3cfb9d512f
internal/host/rootgeneration_requirements.go generationLocalObligations authority E2 rev3=PROPOSED-p3;run=24;identity=root claim/adopt/update controller uid 0;basis=requires retained route and state artifacts; a current route-only installation without predecessors or any launch is unlaunched only after exact root FreshInstallation proof and physical state-directory absence, recomputed on every read file:Lstat@3018 1a5c493c0a6383df880d4d6e730f6d01822559cab804b57ac445e36bb4535f3c
internal/host/rootgeneration_requirements.go generationUnlaunched authority E2 rev3=PROPOSED-p3;run=24;identity=root claim/adopt/update controller uid 0;basis=requires absence of every root launch for this hostname, selects the earliest retained stamp of this same incarnation, and performs one durable unconsumed-creation read; any read error refuses; empty installations keep their existing path; consumers independently validate transfers and physical state 8041de414e9b90f044066c4cad51e7e8b43dea858a4e2ae6eb6f22418466a720
-internal/host/rootinventory_sibling.go ClaimRootHTTP.rootSiblingPath authority C,G rev3=PROPOSED-p3;run=25;identity=root claim, adoption or recovery controller uid 0;basis=classifies only another root-recorded installation with an exact payload revision and persistent directory identity; private bind source must map to the same filesystem path and object; live/previous links must name a retained root generation for the same host, incarnation, box, account and app file:Open@1911,file:Readlink@2161 0ead075a56726415017bbbb5db140025b54faf3af387309c4ef86b8bd0b934a4
+internal/host/rootinventory_sibling.go ClaimRootHTTP.rootSiblingPath authority C,G rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=requires exact retained sibling installation and inventory revision; the layout package alone classifies and verifies the private instance alias; public payload identity and generation pin ownership stay mandatory file:Open@1813,file:Readlink@2063 43657b556a4c238a0fae72aad5f89c13d1f4168b7417644934474ea918cf0176
internal/host/claimservingreboot_binding.go servingParkReadBinding authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes mount-namespace rebinding; same-boot device substitution refuses; original retirement identities stay immutable; preparation is keyed by the observed mount and repeat reads precede each binding write file:Read@111 e15d6fa67724d7dc031c33cd72c0e167d36de7bd78af15954248700b44dcd3cb
internal/host/claimservingreboot_commit_linux.go servingParkLedger.commitRebootLocked authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes private-namespace rebinding; device substitution within a boot refuses; immutable source identities and repeated observations constrain namespace and record commits file:Read@246 0bcb3022797dbc3bbf3330633b55cd2b797a8e02122c7f1abcc287f7234c1922
internal/host/claimservingreboot_linux.go servingParkFS.observeRebootRecord authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes mount-namespace rebinding; same-boot device substitution refuses; original retirement identities stay immutable; preparation is keyed by the observed mount and repeat reads precede each binding write file:Readdirnames@3147 2f806025a2a308f545188aa2dbc6d8cd2f178d51711ba68beb16ef4da94aa746
internal/host/claimservingreboot_prepare_linux.go servingParkLedger.prepareRebootLocked authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes private-namespace rebinding; device substitution within a boot refuses; immutable source identities and repeated observations constrain namespace and record commits file:Read@621 ce54be50398b3a3eaede02d240e9a9c58515168f5195bd498b788041658ae6a5
internal/host/claimservingtopology_linux.go servingParkFS.inspectRebootTopology authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes mount-namespace rebinding; same-boot device substitution refuses; original retirement identities stay immutable; preparation is keyed by the observed mount and repeat reads precede each binding write file:Read@501,file:Read@936 9d4612c414ea82f4f20723bb0e2dbca8963e93ca377b159527a39ea861e59870
internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingParks authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=persistent filesystem, handle, inode, owner and topology evidence authorizes private-namespace rebinding; device substitution within a boot refuses; immutable source identities and repeated observations constrain namespace and record commits b1a49869990546ea6205c69db52c45d4f35fa747f3a1e1780c9b548c4efdd68f
-internal/host/rootinventory_sibling.go rootSiblingAlias authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=compares the fixed public and private instance paths through no-symlink capture; requires equal current boot/device/namespace, identical filesystem path under the mount sources, persistent handle, inode, mode and ownership 1f48a064a1a54fbfd5969aa48f12f3a47711070eba5e91fcc287d950121c4608
internal/host/rootinventory_sibling.go ClaimRootHTTP.rootEmptyCensus authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=copies the configured census and adds root-only sibling classification for execution admission, settlement and inventory registration; no agent-owned registry authority ca59fff522b539f198e6aa8c56cac120014c0edfb072bb6d1044760fec13cb47
internal/host/claimservingreboot_prepare_linux.go servingNamespacePreparationName authority C,G rev3=PROPOSED-p3;run=25;identity=root instance-control uid 0;basis=keys durable preparation by boot and observed mount so repeated same-boot controller namespace recovery cannot collide with a prior preparation; all authority and persistent observations remain checked by the prepare/commit callers 01de715e16cb00830f17999f497b1c85a202f236609f5306aeed5840001a44a0
internal/host/rootgeneration_kernel_linux.go generationFileProof authority E2,G rev3=PROPOSED-p3;run=25;identity=root generation activation, retirement and release readers uid 0;basis=both generation consumers require the same persistent filesystem/handle/inode/type/owner/mode and filesystem-relative source path across private and host mount layouts; same-boot device stays exact; root-owned ancestry remains independently checked by each opener 5ab08e86bf9dcaef527b0751068dbf838f869115c630ead17c0bc866b47a17d5
internal/host/rootgeneration_services_linux.go generationServiceSupervisor authority E2,G rev3=PROPOSED-p3;run=25;identity=root generation kernel reader uid 0;basis=systemd main PID and trusted unit/executable identify only the edge main process threads in its exact supervise subgroup; unknown child processes and other services remain refused; does not exempt descriptor or generation-reference checks 62fcbfa690841ecc68ed935cae404a75b0d4f3b533af871af64a9ff19586d85b
-internal/release/rootgenerationproof.go GenerationFileProof authority E2 rev3=PROPOSED-p3;run=25;identity=root generation activation, retirement and release readers uid 0;basis=both generation consumers require the same persistent filesystem/handle/inode/type/owner/mode and filesystem-relative source path across private and host mount layouts; same-boot device stays exact; root-owned ancestry remains independently checked by each opener 1f61004a6273ecd4b92d5cdb31b06d4e1cf1ff57f668131ea9b2d0c57b5b4f02
+internal/release/rootgenerationproof.go GenerationFileProof authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=requires persistent filesystem handle inode ownership mode and exact child layers; normalization may remove recorded outer private mounts only in a different namespace or boot and to a strict ancestor mountpoint with identical filesystem-relative source; added or substituted layers refuse d1a300d3ae711f83fd260fab2592ab18170c6bb5ea0fdb4ebc18559fc265eec2
internal/release/rootgenerationproof.go generationProofUnescape authority E2 rev3=PROPOSED-p3;run=25;identity=root generation activation, retirement and release readers uid 0;basis=both generation consumers require the same persistent filesystem/handle/inode/type/owner/mode and filesystem-relative source path across private and host mount layouts; same-boot device stays exact; root-owned ancestry remains independently checked by each opener 744d84125ad095053c32cdbfe83632b284d222ada237fa3dca5b5c9f5f307656
internal/host/rootgeneration_containment_linux.go generationKernel.replacedGroup authority E2 rev3=PROPOSED-p3;run=26;identity=root retirement and pin readers uid 0 under release exclusion;basis=only a closed isolated predecessor may accept a different ordinary containment at its canonical pathname; requires another root reservation for the same host/account/app, a distinct attempt and invocation, current boot, exact live kernel containment and every occupant independently checked; old process identities are still checked absent by the caller bc1e292d60f204bf470d4805d24b9b11fefe22c792b1dba2a55cfc2675670e03
internal/host/rootgeneration_retired_linux.go generationKernel.emptyGroups authority E2 rev3=PROPOSED-p3;run=26;identity=root retirement and pin readers uid 0 under release exclusion;basis=bounded cgroup tree inspection rejects symlinks and every task lacking a matching live root reservation with verified process, executable, namespace and containment identity file:ReadFile@492,file:WalkDir@100 3918f75eb9d2a483079dcd05e3c4a8b2be99d6f6a6c1692c45b9255965885723
+internal/rootstate/persistent_linux.go ObjectIdentity authority E2,J2,J4 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=captures filesystem handle inode owner mode boot and the ordered kernel mount-parent lineage; current mount numbers select and connect snapshot entries only, never establish persistent identity file:Fstat@170,file:Fstatfs@235,file:ReadFile@1036,file:ReadFile@936,file:Readlink@1128,file:Statx@302 5bc8caadf2d4b31b3ed2073aeaba0685a2244b8ce1628d7ab557082990be58c8
+internal/rootstate/persistent_linux.go persistentMountLineage authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=retains the ordered kernel mount-parent chain without recyclable identifiers; a parent hidden by chroot must match the independently opened root mount and persistent root proof 4e6c9f22b753680bc422ed905a526f9e5abceef762669814bfcdc062fcf785c1
+internal/layout/census.go InstanceCensusName authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=layout owns exact public and private instance directory classification; classification grants no exclusion without independent sibling authority and physical alias verification f1997087a9be9630ffc0c62ac6ddcfb765b1a4bd7f8a15aa215dd0e44ca7047e
+internal/layout/census.go VerifyInstanceCensusAlias authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=layout owns private raw directory reads; descriptor-relative no-symlink identity capture proves the raw path and the verified public sibling share boot device namespace persistent object and exact filesystem-relative source path 86ce305862058b648878f2deed2b1289c8c109b510420e61a13dc376f70300d2
+@coverage internal/layout 60e9c53b973c2af23757fad1f77652f0b5b46cf0857849c295071a2c0bc764d6
+internal/rootstate/state.go sameMountLayers authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=requires identical child mount layers and ordered retained ancestry; only recorded ancestor self-binds proven from their parent filesystem-relative path may disappear across views; added or substituted ancestors refuse even when mount numbers are recycled def1eba64c720215fc294ca409f50bac1658ec638af38d1eaf5e76708487ed67
+internal/rootstate/persistent_linux.go persistentMountBoundary authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=opens the process root descriptor and requires its current mount ID to equal the missing parent; retains filesystem identity handle inode ownership and mode as a boundary proof, refusing unknown or unsupported ancestry file:Fstat@495,file:Fstatfs@560,file:Open@74,file:Statx@228 f0c044e18d350701fc96b2c65e506b687d423ce4ea97d90ec64cbbacbb226b83
+internal/host/rootinventory_state_linux.go verifyPreparedStateIdentity authority C,J1 rev3=PROPOSED-p3;run=28;identity=carlos agent uid carlos;basis=read-only existing-state check; root verifier remains strict; only a same-boot different namespace may add self-bind ancestors around unchanged child mount layers and persistent object identity; changed metadata child mounts filesystem or boot refuse;test=TestPlan14PreparedInventoryPrivateView 58c378288e10f40c5e50595fe54fd992a96b906aea9863491d37947e9450bb8e
+internal/rootstate/persistent_linux.go uniqueMountIdentity authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=captures non-reused kernel mount and namespace sequence IDs through STATX_MNT_ID_UNIQUE and NS_GET_MNTNS_ID; missing or unsupported evidence refuses rather than falling back to recyclable numbers file:Open@344,file:Statx@95,file:Syscall@461 54d8759db39bb4e4255c81a56fdbf0ab52883e911a5d189acba7be4d26a7c490
+internal/rootstate/state.go FileIdentity.SameMountNamespace authority E2 rev3=PROPOSED-p3;run=28;identity=root claim generation and inventory readers uid 0;basis=same-boot namespace relation requires nonzero kernel namespace sequence IDs; recyclable proc namespace names never establish this relation 4e4a48f09b5e09d65f6e402eb17426f774e2e46ab6f7ee47406ba7d81334b80e
diff --git a/internal/release/plan14_generation_namespace_test.go b/internal/release/plan14_generation_namespace_test.go
index 3c2576bf1..7ab212d4a 100644
--- a/internal/release/plan14_generation_namespace_test.go
+++ b/internal/release/plan14_generation_namespace_test.go
@@ -9,22 +9,27 @@ import (
func TestPlan14GenerationNamespaceProof(t *testing.T) {
path := "/opt/carlos/versions/v1/lifecycle"
- before := rootstate.FileIdentity{Boot: "boot", Device: 66305, Inode: 131086, Filesystem: "ext4:volume", Handle: "file-handle", HandleType: 1, Mode: 0100755, MountRoot: "/opt/carlos", MountPoint: "/opt/carlos", MountNamespace: "private", Mount: 306}
+ before := rootstate.FileIdentity{Boot: "boot", Device: 66305, Inode: 131086, Filesystem: "ext4:volume", Handle: "file-handle", HandleType: 1, Mode: 0100755, MountRoot: "/opt/carlos", MountPoint: "/opt/carlos", MountNamespace: "private", Mount: 306, MountUnique: 306, NamespaceUnique: 1, MountLineage: "/opt/carlos\t/opt/carlos\n/\t/\n"}
after := before
after.MountRoot, after.MountPoint, after.MountNamespace, after.Mount = "/", "/", "host", 127
+ after.MountLineage = "/\t/\n"
+ after.MountUnique, after.NamespaceUnique = 127, 2
if before.Same(after) {
t.Fatal("fixture must cross mount layouts")
}
if !GenerationFileProof(path, before, after) {
t.Fatal("same file through host mount refused")
}
- for _, field := range []string{"source", "point", "device", "filesystem", "handle", "inode", "owner", "mode", "missing"} {
+ for _, field := range []string{"source", "point", "namespace", "device", "filesystem", "handle", "inode", "owner", "mode", "missing"} {
bad := after
switch field {
case "source":
bad.MountRoot = "/other"
case "point":
bad.MountPoint = "/opt"
+ case "namespace":
+ bad.MountNamespace = before.MountNamespace
+ bad.NamespaceUnique = before.NamespaceUnique
case "device":
bad.Device++
case "filesystem":
@@ -45,3 +50,19 @@ func TestPlan14GenerationNamespaceProof(t *testing.T) {
}
}
}
+
+func TestPlan14GenerationMountSubstitution(t *testing.T) {
+ path := "/opt/carlos/versions/v1/app"
+ before := rootstate.FileIdentity{Boot: "old", Device: 1, Inode: 2, Filesystem: "fs", Handle: "handle", Mount: 3, MountUnique: 3, NamespaceUnique: 1, MountNamespace: "mnt:[1]", MountRoot: "/", MountPoint: "/", MountLineage: "/\t/\n"}
+ for _, boot := range []string{before.Boot, "new"} {
+ for _, point := range []string{"/opt/carlos", "/opt/carlos/versions/v1", path, "/"} {
+ after := before
+ after.Boot = boot
+ after.MountRoot, after.MountPoint = point, point
+ after.MountLineage = point + "\t" + point + "\n" + before.MountLineage
+ if GenerationFileProof(path, before, after) {
+ t.Fatal("substituted mount accepted", boot, point)
+ }
+ }
+ }
+}
diff --git a/internal/release/rootgenerationproof.go b/internal/release/rootgenerationproof.go
index a9701475e..00d91417c 100644
--- a/internal/release/rootgenerationproof.go
+++ b/internal/release/rootgenerationproof.go
@@ -23,12 +23,21 @@ func GenerationFileProof(path string, before, after rootstate.FileIdentity) bool
}
return filepath.Join(root, rel), true
}
+ if before.SameMountNamespace(after) || before.MountLineage == "" || after.MountLineage == "" || before.MountLineage == after.MountLineage || !strings.HasSuffix(before.MountLineage, "\n"+after.MountLineage) {
+ return false
+ }
+ beforePoint, e1 := generationProofUnescape(before.MountPoint)
+ afterPoint, e2 := generationProofUnescape(after.MountPoint)
+ rel, err := filepath.Rel(afterPoint, beforePoint)
+ if e1 != nil || e2 != nil || err != nil || rel == "." || !filepath.IsLocal(rel) {
+ return false
+ }
a, ok1 := source(before)
b, ok2 := source(after)
if !ok1 || !ok2 || a != b {
return false
}
- after.MountRoot, after.MountPoint = before.MountRoot, before.MountPoint
+ after.MountRoot, after.MountPoint, after.MountLineage = before.MountRoot, before.MountPoint, before.MountLineage
return before.Same(after)
}
diff --git a/internal/rootstate/persistent_lineage_linux_test.go b/internal/rootstate/persistent_lineage_linux_test.go
new file mode 100644
index 000000000..445472163
--- /dev/null
+++ b/internal/rootstate/persistent_lineage_linux_test.go
@@ -0,0 +1,117 @@
+package rootstate
+
+import "testing"
+
+func TestPersistentMountLineage(t *testing.T) {
+ base := "10 1 8:1 / / rw - ext4 /dev/root rw\n"
+ bound := base + "20 10 8:1 /opt /opt rw - ext4 /dev/root rw\n"
+ got, err := persistentMountLineage(bound, 20)
+ if err != nil || got != "/opt\t/opt\n/\t/\n" {
+ t.Fatal(got, err)
+ }
+ renumbered := "110 1 8:7 / / rw - ext4 /dev/other rw\n120 110 8:7 /opt /opt rw - ext4 /dev/other rw\n"
+ again, err := persistentMountLineage(renumbered, 120)
+ if err != nil || again != got {
+ t.Fatal(again, err)
+ }
+ stacked, err := persistentMountLineage(bound+"30 20 8:1 /opt /opt rw - ext4 /dev/root rw\n", 30)
+ if err != nil || stacked == got {
+ t.Fatal(stacked, err)
+ }
+ for _, malformed := range []string{
+ "20 99 8:1 /opt /opt rw - ext4 /dev/root rw\n",
+ "20 20 8:1 / / rw - ext4 /dev/root rw\n",
+ } {
+ if _, err := persistentMountLineage(malformed, 20); err == nil {
+ t.Fatal("invalid ancestry accepted", malformed)
+ }
+ }
+}
+
+func TestPersistentMountSubstitutionWithRecycledNumbers(t *testing.T) {
+ before := FileIdentity{Boot: "boot", Filesystem: "fs", Handle: "handle", Inode: 2, Mount: 3, MountUnique: 3, NamespaceUnique: 1, MountNamespace: "mnt:[1]", MountRoot: "/opt", MountPoint: "/opt", MountLineage: "/opt\t/opt\n/\t/\n"}
+ after := before
+ after.MountLineage = "/opt\t/opt\n" + before.MountLineage
+ if before.Same(after) {
+ t.Fatal("stacked mount accepted with recycled mount and namespace numbers")
+ }
+ after = before
+ after.Mount++
+ if !before.Same(after) {
+ t.Fatal("recyclable mount number established identity")
+ }
+ after.MountUnique++
+ if before.Same(after) {
+ t.Fatal("replacement mount accepted within the same boot and namespace")
+ }
+ after.NamespaceUnique++
+ if !before.Same(after) {
+ t.Fatal("recreated unchanged topology refused")
+ }
+ after.Boot = "reboot"
+ after.Device++
+ if !before.Same(after) {
+ t.Fatal("reboot refused")
+ }
+ for _, missing := range []string{"mount", "namespace"} {
+ bad := before
+ if missing == "mount" {
+ bad.MountUnique = 0
+ } else {
+ bad.NamespaceUnique = 0
+ }
+ if before.Same(bad) || bad.Same(bad) {
+ t.Fatal("lineage without unique boot evidence accepted", missing)
+ }
+ }
+}
+
+func TestPersistentMountPrivateParent(t *testing.T) {
+ before := FileIdentity{Boot: "boot", Filesystem: "fs", Handle: "handle", Inode: 2, Mount: 3, MountUnique: 3, NamespaceUnique: 1, MountNamespace: "private", MountRoot: "/data/raw", MountPoint: "/data/view", MountLineage: "/data/raw\t/data/view\n/data\t/data\n/\t/\n"}
+ after := before
+ after.MountNamespace = "host"
+ after.NamespaceUnique++
+ after.MountUnique++
+ after.MountLineage = "/data/raw\t/data/view\n/\t/\n"
+ if !before.Same(after) {
+ t.Fatal("private parent mount hid the unchanged child mount")
+ }
+ sameNamespace := before
+ sameNamespace.MountNamespace = after.MountNamespace
+ sameNamespace.NamespaceUnique = after.NamespaceUnique
+ sameNamespace.MountUnique = after.MountUnique
+ if sameNamespace.Same(after) {
+ t.Fatal("removed ancestor mount accepted within the same boot and namespace")
+ }
+ if after.Same(before) {
+ t.Fatal("added ancestor mount accepted")
+ }
+ substituted := before
+ substituted.MountLineage = "/data/raw\t/data/view\n/other\t/data\n/\t/\n"
+ if before.Same(substituted) {
+ t.Fatal("substituted ancestor mount accepted")
+ }
+ after.MountLineage = "/data/raw\t/data/view\n" + after.MountLineage
+ if before.Same(after) {
+ t.Fatal("substitution at recorded mountpoint accepted")
+ }
+}
+
+func TestPersistentMountPrivateParentWithOffset(t *testing.T) {
+ before := FileIdentity{Boot: "boot", Filesystem: "fs", Handle: "handle", Inode: 2, Mount: 3, MountUnique: 3, NamespaceUnique: 1, MountNamespace: "private", MountRoot: "/disk/tmp/fixture/raw", MountPoint: "/tmp/fixture/view", MountLineage: "/disk/tmp/fixture/raw\t/tmp/fixture/view\n/disk/tmp/fixture\t/tmp/fixture\n/disk/tmp\t/tmp\n/\t/\n"}
+ after := before
+ after.MountNamespace = "host"
+ after.NamespaceUnique++
+ after.MountUnique++
+ after.MountLineage = "/disk/tmp/fixture/raw\t/tmp/fixture/view\n/disk/tmp\t/tmp\n/\t/\n"
+ if !before.Same(after) {
+ t.Fatal("private self-bind beneath an offset bind refused")
+ }
+ for _, row := range []string{"/disk/other\t/tmp/fixture", "/tmp/fixture\t/tmp/fixture", "/disk/tmp/fixture\t/elsewhere", "@root:unknown\t/tmp/fixture"} {
+ bad := before
+ bad.MountLineage = "/disk/tmp/fixture/raw\t/tmp/fixture/view\n" + row + "\n/disk/tmp\t/tmp\n/\t/\n"
+ if bad.Same(after) {
+ t.Fatal("unproven parent accepted", row)
+ }
+ }
+}
diff --git a/internal/rootstate/persistent_linux.go b/internal/rootstate/persistent_linux.go
index 4f63d5820..da623b4dc 100644
--- a/internal/rootstate/persistent_linux.go
+++ b/internal/rootstate/persistent_linux.go
@@ -7,6 +7,7 @@ import (
"os"
"strconv"
"strings"
+ "unsafe"
"golang.org/x/sys/unix"
)
@@ -60,6 +61,10 @@ func ObjectIdentity(f *os.File) (FileIdentity, error) {
return id, os.ErrPermission
}
id = FileIdentity{MountNamespace: namespace, Device: uint64(st.Dev), Inode: st.Ino, Boot: strings.TrimSpace(string(boot)), Filesystem: fmt.Sprintf("%x:%x", fs.Type, fs.Fsid.Val), Handle: hex.EncodeToString(handle.Bytes()), HandleType: handle.Type(), Mount: sx.Mnt_id, UID: st.Uid, GID: st.Gid, Mode: st.Mode}
+ id.MountUnique, id.NamespaceUnique, err = uniqueMountIdentity(f)
+ if err != nil {
+ return FileIdentity{}, err
+ }
for _, line := range strings.Split(string(mounts), "\n") {
fields := strings.Fields(line)
if len(fields) >= 10 && fields[0] == strconv.FormatUint(id.Mount, 10) {
@@ -67,8 +72,98 @@ func ObjectIdentity(f *os.File) (FileIdentity, error) {
break
}
}
+ id.MountLineage, err = persistentMountLineage(string(mounts), id.Mount)
+ if err != nil {
+ return FileIdentity{}, err
+ }
if id.Boot == "" || id.MountRoot == "" || id.MountPoint == "" {
return FileIdentity{}, os.ErrPermission
}
return id, nil
}
+
+func uniqueMountIdentity(f *os.File) (uint64, uint64, error) {
+ var sx unix.Statx_t
+ if err := unix.Statx(int(f.Fd()), "", unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW, unix.STATX_MNT_ID_UNIQUE, &sx); err != nil {
+ return 0, 0, err
+ }
+ if sx.Mask&unix.STATX_MNT_ID_UNIQUE == 0 || sx.Mnt_id == 0 {
+ return 0, 0, os.ErrPermission
+ }
+ ns, err := os.Open("/proc/self/ns/mnt")
+ if err != nil {
+ return 0, 0, err
+ }
+ defer ns.Close()
+ var id uint64
+ _, _, errno := unix.Syscall(unix.SYS_IOCTL, ns.Fd(), unix.NS_GET_MNTNS_ID, uintptr(unsafe.Pointer(&id)))
+ if errno != 0 {
+ return 0, 0, errno
+ }
+ if id == 0 {
+ return 0, 0, os.ErrPermission
+ }
+ return sx.Mnt_id, id, nil
+}
+
+func persistentMountLineage(mounts string, mount uint64) (string, error) {
+ rows := make(map[string][]string)
+ for _, line := range strings.Split(mounts, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) >= 10 {
+ rows[fields[0]] = fields
+ }
+ }
+ var lineage strings.Builder
+ seen := make(map[string]bool)
+ for next := strconv.FormatUint(mount, 10); ; {
+ row, ok := rows[next]
+ if !ok || seen[next] {
+ return "", os.ErrPermission
+ }
+ seen[next] = true
+ lineage.WriteString(row[3] + "\t" + row[4] + "\n")
+ if _, present := rows[row[1]]; !present {
+ if row[4] != "/" {
+ anchor, err := persistentMountBoundary(row[1])
+ if err != nil {
+ return "", err
+ }
+ lineage.WriteString(anchor)
+ }
+ return lineage.String(), nil
+ }
+ next = row[1]
+ }
+}
+
+func persistentMountBoundary(parent string) (string, error) {
+ fd, err := unix.Open("/", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return "", err
+ }
+ defer unix.Close(fd)
+ var sx unix.Statx_t
+ if err := unix.Statx(fd, "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &sx); err != nil {
+ return "", err
+ }
+ if sx.Mask&unix.STATX_MNT_ID == 0 || parent != strconv.FormatUint(sx.Mnt_id, 10) {
+ return "", os.ErrPermission
+ }
+ var st unix.Stat_t
+ var fs unix.Statfs_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return "", err
+ }
+ if err := unix.Fstatfs(fd, &fs); err != nil {
+ return "", err
+ }
+ handle, mount, err := unix.NameToHandleAt(fd, "", unix.AT_EMPTY_PATH)
+ if err != nil {
+ return "", err
+ }
+ if len(handle.Bytes()) == 0 || uint64(mount) != sx.Mnt_id {
+ return "", os.ErrPermission
+ }
+ return fmt.Sprintf("@root:%x:%x:%d:%d:%x:%d:%d:%o\t/\n", fs.Type, fs.Fsid.Val, st.Ino, handle.Type(), handle.Bytes(), st.Uid, st.Gid, st.Mode), nil
+}
diff --git a/internal/rootstate/state.go b/internal/rootstate/state.go
index d7ef96069..c34ba3b09 100644
--- a/internal/rootstate/state.go
+++ b/internal/rootstate/state.go
@@ -4,6 +4,8 @@ package rootstate
import (
"context"
"os"
+ "path"
+ "strings"
)
const MaxRecord = 1 << 20
@@ -50,8 +52,10 @@ type FileIdentity struct {
Device uint64
Inode uint64
Boot, Filesystem, Handle, MountRoot, MountPoint, MountNamespace string `json:",omitempty"`
+ MountLineage string `json:",omitempty"`
HandleType int32 `json:",omitempty"`
Mount uint64 `json:",omitempty"`
+ MountUnique, NamespaceUnique uint64 `json:",omitempty"`
UID, GID, Mode uint32 `json:",omitempty"`
}
@@ -65,5 +69,68 @@ func (a FileIdentity) Same(b FileIdentity) bool {
if a.Inode != b.Inode || a.Filesystem != b.Filesystem || a.Handle != b.Handle || a.HandleType != b.HandleType || a.UID != b.UID || a.GID != b.GID || a.Mode != b.Mode || a.MountRoot != b.MountRoot || a.MountPoint != b.MountPoint {
return false
}
+ if (a.MountLineage != "" || b.MountLineage != "") && (a.MountUnique == 0 || b.MountUnique == 0 || a.NamespaceUnique == 0 || b.NamespaceUnique == 0) {
+ return false
+ }
+ if a.SameMountNamespace(b) && a.MountUnique != b.MountUnique {
+ return false
+ }
+ if a.MountLineage != b.MountLineage && (a.SameMountNamespace(b) || !sameMountLayers(a, b)) {
+ return false
+ }
return a.Boot != b.Boot || a.Device == b.Device
}
+
+func (a FileIdentity) SameMountNamespace(b FileIdentity) bool {
+ return a.Boot != "" && a.Boot == b.Boot && a.NamespaceUnique != 0 && a.NamespaceUnique == b.NamespaceUnique
+}
+
+func sameMountLayers(a, b FileIdentity) bool {
+ layers := func(id FileIdentity) (string, []string, bool) {
+ if id.MountLineage == "" {
+ return "", nil, false
+ }
+ var out strings.Builder
+ rows := strings.Split(strings.TrimSuffix(id.MountLineage, "\n"), "\n")
+ for i, row := range rows {
+ fields := strings.Split(row, "\t")
+ if len(fields) != 2 {
+ return "", nil, false
+ }
+ if fields[1] != id.MountPoint {
+ return out.String(), rows[i:], out.Len() != 0
+ }
+ out.WriteString(row + "\n")
+ }
+ return out.String(), nil, out.Len() != 0
+ }
+ left, before, ok1 := layers(a)
+ right, after, ok2 := layers(b)
+ if !ok1 || !ok2 || left != right {
+ return false
+ }
+ for i, row := range before {
+ if len(after) != 0 && row == after[0] {
+ after = after[1:]
+ continue
+ }
+ fields := strings.Split(row, "\t")
+ if len(fields) != 2 || i+1 == len(before) || fields[1] == "/" {
+ return false
+ }
+ parent := strings.Split(before[i+1], "\t")
+ if len(parent) != 2 {
+ return false
+ }
+ for _, name := range append(fields, parent...) {
+ if !path.IsAbs(name) || path.Clean(name) != name {
+ return false
+ }
+ }
+ rel, below := strings.CutPrefix(fields[1], strings.TrimSuffix(parent[1], "/")+"/")
+ if !below || rel == "" || path.Join(parent[0], rel) != fields[0] {
+ return false
+ }
+ }
+ return len(after) == 0
+}