WIP claim Plan 14 track E checkpoint 31 (not for merge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+573
−13
internal/host/plan14_generation_registry_root_linux_test.go+69 −0internal/host/plan14_generation_requirements_root_linux_test.go+123 −8internal/host/plan14_generation_rows_test.go+248 −0internal/host/registry_privilege_test.go+1 −0internal/host/rootgeneration_inventory.go+5 −1internal/host/rootgeneration_rows.go+100 −0internal/provision/testdata/plan14-root-reads-e.tsv+5 −2internal/registry/readonly_test.go+21 −1internal/registry/remote.go+1 −1
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 352.414s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.237s
ok github.com/carlosframework/platform/cmd/moveprobe 93.338s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 234.004s
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.391s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.134s
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.854s
? 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.013s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.707s
ok github.com/carlosframework/platform/internal/changelog 0.303s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 367.134s
? 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.450s
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.003s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.169s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.043s
? 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.124s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.017s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.155s
ok github.com/carlosframework/platform/internal/host 135.285s
ok github.com/carlosframework/platform/internal/hostactors 0.379s
ok github.com/carlosframework/platform/internal/hostbox 2.354s
ok github.com/carlosframework/platform/internal/hostclaim 0.525s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.862s
ok github.com/carlosframework/platform/internal/hostfence 0.031s
ok github.com/carlosframework/platform/internal/hostfiles 10.447s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.981s
ok github.com/carlosframework/platform/internal/hostops 1.481s
? 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.455s
ok github.com/carlosframework/platform/internal/hostresolve 0.142s
ok github.com/carlosframework/platform/internal/hostwrite 0.005s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.820s
ok github.com/carlosframework/platform/internal/instanceexec 2.741s
ok github.com/carlosframework/platform/internal/instancefs 0.021s
ok github.com/carlosframework/platform/internal/instancekey 0.019s
ok github.com/carlosframework/platform/internal/instanceupdates 0.030s
ok github.com/carlosframework/platform/internal/layout 1.439s
ok github.com/carlosframework/platform/internal/lease 9.443s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.009s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.165s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.532s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.092s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.177s
ok github.com/carlosframework/platform/internal/moves/archive 0.765s
ok github.com/carlosframework/platform/internal/objstore 0.172s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.193s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 14.067s
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 4.029s
ok github.com/carlosframework/platform/internal/relay 7.231s
ok github.com/carlosframework/platform/internal/release 21.763s
ok github.com/carlosframework/platform/internal/rollout 3.722s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.008s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
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.299s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.689s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.837s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.192s
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.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
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.562s
=== 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
--- 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.025s
=== RUN TestT4StorageProbeMinio
2026/10/14 18:55:27 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE7A426072CA88, 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.022s
minio contract: PASS
diff --git a/internal/host/plan14_generation_registry_root_linux_test.go b/internal/host/plan14_generation_registry_root_linux_test.go| new file mode 100644 |
| index 000000000..6829112e4 |
| --- /dev/null |
| +++ b/internal/host/plan14_generation_registry_root_linux_test.go |
| @@ -0,0 +1,69 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +// The test binary must take the same private registry entrypoint as carlos. |
| +func TestMain(m *testing.M) { |
| + if len(os.Args) > 1 && os.Args[1] == registryChildCommand { |
| + if err := RunRegistryChild(os.Args[2:]); err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + os.Exit(0) |
| + } |
| + os.Exit(m.Run()) |
| +} |
| + |
| +func generationRunnerRegistryUser(t *testing.T) (int, int) { |
| + t.Helper() |
| + if os.Geteuid() != 0 { |
| + t.Fatal("designated runner must execute as root") |
| + } |
| + service, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + if _, ok := err.(user.UnknownUserError); !ok { |
| + t.Fatal(err) |
| + } |
| + if _, err := user.LookupGroup(serviceUser); err == nil { |
| + t.Fatal("service group exists without its user") |
| + } else if _, ok := err.(user.UnknownGroupError); !ok { |
| + t.Fatal(err) |
| + } |
| + if out, err := exec.Command("useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput(); err != nil { |
| + t.Fatalf("create runner service user: %v %s", err, out) |
| + } |
| + t.Cleanup(func() { |
| + if out, err := exec.Command("userdel", serviceUser).CombinedOutput(); err != nil { |
| + t.Errorf("remove runner service user: %v %s", err, out) |
| + return |
| + } |
| + if _, err := user.LookupGroup(serviceUser); err == nil { |
| + if out, err := exec.Command("groupdel", serviceUser).CombinedOutput(); err != nil { |
| + t.Errorf("remove runner service group: %v %s", err, out) |
| + } |
| + } |
| + }) |
| + service, err = user.Lookup(serviceUser) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil || uid <= 0 { |
| + t.Fatal("runner service UID must be non-root", err) |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil || gid < 0 { |
| + t.Fatal("invalid runner service GID", err) |
| + } |
| + return uid, gid |
| +} |
diff --git a/internal/host/plan14_generation_requirements_root_linux_test.go b/internal/host/plan14_generation_requirements_root_linux_test.go| index 69d195bf0..6ba8c5163 100644 |
| --- a/internal/host/plan14_generation_requirements_root_linux_test.go |
| +++ b/internal/host/plan14_generation_requirements_root_linux_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/rootparticipation" |
| "github.com/carlosframework/platform/internal/rootstate" |
| @@ -24,7 +25,20 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| const childKey = "PLAN14_E30_REQUIRED_CHILD" |
| dir := os.Getenv(childKey) |
| if dir == "" { |
| - dir = generationRootRunnerDir(t) |
| + generationRunnerRegistryUser(t) |
| + var err error |
| + dir, err = os.MkdirTemp("/run", "plan14-generation-rows-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := os.RemoveAll(dir); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if err := os.Chmod(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| binary, err := os.Executable() |
| if err != nil { |
| t.Fatal(err) |
| @@ -57,6 +71,7 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| t.Error(err) |
| } |
| }) |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| t.Setenv("CARLOS_OPT_DIR", dir) |
| t.Setenv("CARLOS_DATA_DIR", filepath.Join(dir, "data")) |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| @@ -66,6 +81,18 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + uid, gid := generationRunnerRegistryUser(t) |
| + if err := os.Chown(dataDir(), uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg, err := openRegistryChild(filepath.Join(dataDir(), "registry.db"), uid, gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Join the fixture writer before the production kernel scan. |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| for _, path := range []string{rootparticipation.Namespace, "/var/lib/carlos-claims/authority/fences", release.RootGenerationsDir} { |
| state, err := rootstate.Open(path, true, false) |
| if err != nil { |
| @@ -140,7 +167,32 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + payload.Entries = append(payload.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: in.Host + "/backup", RowIdentity: "timer", Ownership: "owner", Materialization: "materialized"}) |
| put(payload) |
| + owner := registry.ClaimOwnership{ClaimIncarnation: inc, ClaimStamp: 2, ClaimOwner: "owner", ClaimRowID: "row"} |
| + route := registry.Route{Host: in.Host, ClaimOwnership: owner} |
| + timer := registry.Timer{Host: in.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner} |
| + timer.ClaimRowID = "timer" |
| + rows := func(rt registry.Route, timers []registry.Timer) { |
| + t.Helper() |
| + writer, err := openRegistryChild(filepath.Join(dataDir(), "registry.db"), uid, gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer writer.Close() |
| + if err := writer.Delete(in.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writer.ReplaceTimers(in.Host, timers); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt.Host != "" { |
| + if err := writer.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + rows(route, []registry.Timer{timer}) |
| certificate := func() release.GenerationCompleteness { |
| t.Helper() |
| state, err := rootstate.Open(release.RootGenerationsDir, false, false) |
| @@ -155,24 +207,34 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| return c |
| } |
| source := productionRootGenerationPins() |
| + pinsUnderRelease := func() (release.StagedPaths, error) { |
| + var pins release.StagedPaths |
| + err := withClaimRootRelease(t.Context(), true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, false, false) }, func() error { |
| + var err error |
| + pins, err = source.StagedPins(t.Context(), dir) |
| + return err |
| + }) |
| + return pins, err |
| + } |
| accept := func() { |
| t.Helper() |
| - if pins, err := source.StagedPins(t.Context(), dir); err != nil || len(pins) != 0 || !certificate().Complete { |
| + if pins, err := pinsUnderRelease(); err != nil || len(pins) != 0 || !certificate().Complete { |
| t.Fatalf("production required artifacts: %v %v", pins, err) |
| } |
| } |
| accept() |
| - for _, omit := range []string{"route", "state"} { |
| + for _, omit := range []string{"route", "state", "timer"} { |
| t.Run(omit, func(t *testing.T) { |
| initial := certificate() |
| missing := payload |
| - if omit == "route" { |
| - missing.Entries = missing.Entries[1:] |
| - } else { |
| - missing.Entries = missing.Entries[:1] |
| + missing.Entries = nil |
| + for _, entry := range payload.Entries { |
| + if entry.ArtifactID != omit { |
| + missing.Entries = append(missing.Entries, entry) |
| + } |
| } |
| put(missing) |
| - if pins, err := source.StagedPins(t.Context(), dir); err == nil || pins != nil { |
| + if pins, err := pinsUnderRelease(); err == nil || pins != nil { |
| t.Fatalf("production omitted %s certified: %v %v", omit, pins, err) |
| } |
| if c := certificate(); c.Complete || c.Revision <= initial.Revision { |
| @@ -185,4 +247,57 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) { |
| accept() |
| }) |
| } |
| + for _, attack := range []string{"route-missing", "route-owner", "timer-missing", "timer-registration", "extra-timer", "malformed-database"} { |
| + t.Run(attack, func(t *testing.T) { |
| + initial := certificate() |
| + rt, tm := route, timer |
| + switch attack { |
| + case "route-missing": |
| + rt = registry.Route{} |
| + case "route-owner": |
| + rt.ClaimOwner = "forged" |
| + case "timer-registration": |
| + tm.Registration = "forged" |
| + } |
| + timers := []registry.Timer{tm} |
| + if attack == "timer-missing" { |
| + timers = nil |
| + } |
| + if attack == "extra-timer" { |
| + extra := timer |
| + extra.Name, extra.Registration, extra.ClaimRowID = "extra", "extra", "extra" |
| + timers = append(timers, extra) |
| + } |
| + rows(rt, timers) |
| + path := filepath.Join(dataDir(), "registry.db") |
| + if attack == "malformed-database" { |
| + if err := os.Rename(path, path+".saved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("not sqlite"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(path, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if pins, err := pinsUnderRelease(); err == nil || pins != nil { |
| + t.Fatalf("production %s certified: %v %v", attack, pins, err) |
| + } |
| + if c := certificate(); c.Complete || c.Revision <= initial.Revision { |
| + t.Fatal("row mismatch retained certificate") |
| + } |
| + if attack == "malformed-database" { |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(path+".saved", path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rows(route, []registry.Timer{timer}) |
| + accept() |
| + }) |
| + } |
| + |
| } |
diff --git a/internal/host/plan14_generation_rows_test.go b/internal/host/plan14_generation_rows_test.go| new file mode 100644 |
| index 000000000..d50bf29af |
| --- /dev/null |
| +++ b/internal/host/plan14_generation_rows_test.go |
| @@ -0,0 +1,248 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14GenerationRegistryRows(t *testing.T) { |
| + for _, attack := range []string{"none", "predecessor", "route-missing", "route-incarnation", "route-stamp", "route-owner", "route-id", "timer-missing", "timer-incarnation", "timer-stamp", "timer-owner", "timer-id", "timer-registration", "timer-extra", "route-entry-missing", "timer-entry-missing", "duplicate-entry", "timer-other-host", "wrong-root", "empty-installation", "unreadable", "changed-during-kernel", "row-next-pass", "timer-delivery-progress", "closed-registry"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, disk, _ := generationLaunchFixture(t) |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| + t.Fatal(err) |
| + } |
| + reservation := readGenerationLaunch(t, disk) |
| + views, err := f.root.Guard.Root.GenerationInstallations(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + launch, err := f.journal.Read(t.Context(), f.target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "predecessor" { |
| + views[0].Retired = []hostguard.Captured{{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp}} |
| + views[0].Installation.Stamp++ |
| + views[0].Installation.Empty = true |
| + } |
| + payloads := generationPayloadFixture(views) |
| + index := 0 |
| + if attack == "predecessor" { |
| + index = 1 |
| + payloads[index].Inventory.Entries = []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: f.target.Host, RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}} |
| + } |
| + payloads[index].Inventory.Entries = append(payloads[index].Inventory.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: f.target.Host + "/backup", RowIdentity: "timer", Ownership: "owner", Materialization: "materialized"}) |
| + snapshot := generationInventory{Census: rootparticipation.Census{Schema: 1, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}, Installations: views, Payloads: payloads, Launches: []hostbox.LaunchInventory{launch}} |
| + registryPath := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := registry.Open(registryPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| + owner := registry.ClaimOwnership{ClaimIncarnation: f.target.Incarnation, ClaimStamp: f.target.Stamp, ClaimOwner: "owner", ClaimRowID: "row"} |
| + route := registry.Route{Host: f.target.Host, ClaimOwnership: owner} |
| + timer := registry.Timer{Host: f.target.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner} |
| + timer.ClaimRowID = "timer" |
| + put := func(rt registry.Route, timers []registry.Timer) { |
| + t.Helper() |
| + if err := reg.Delete(route.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.ReplaceTimers(route.Host, timers); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt.Host != "" { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + put(route, []registry.Timer{timer}) |
| + active := false |
| + reader := reg |
| + v := generationReconciler{read: func(ctx context.Context) (generationInventory, error) { |
| + return generationRegistryInventory(ctx, reader, snapshot) |
| + }, kernel: func(context.Context) (string, string, error) { |
| + if active && attack == "changed-during-kernel" { |
| + // The registry changes after the first read while root inventory stays fixed. |
| + next := route |
| + next.ClaimRowID = "replacement" |
| + put(next, []registry.Timer{timer}) |
| + } |
| + return "boot", reservation.Binding.Digest, nil |
| + }} |
| + source := generationPinSource{open: func() (rootstate.Store, error) { return disk, nil }, reconcile: v.reconcile} |
| + ledger := &release.RootGenerations{Store: disk} |
| + accept := func() { |
| + t.Helper() |
| + pins, err := source.StagedPins(t.Context(), f.root.generations.optDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if pinned, err := pins.Refers(reservation.Binding.Path); err != nil || !pinned { |
| + t.Fatalf("reservation lost: %v", err) |
| + } |
| + if c, err := ledger.Completeness(); err != nil || !c.Complete { |
| + t.Fatalf("row census not complete: %+v %v", c, err) |
| + } |
| + } |
| + accept() |
| + if attack == "none" || attack == "predecessor" { |
| + return |
| + } |
| + initial, _ := ledger.Completeness() |
| + saved, err := json.Marshal(snapshot) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, tm := route, timer |
| + switch attack { |
| + case "route-missing": |
| + rt = registry.Route{} |
| + case "route-incarnation": |
| + rt.ClaimIncarnation = "other" |
| + case "route-stamp": |
| + rt.ClaimStamp++ |
| + case "route-owner": |
| + rt.ClaimOwner = "other" |
| + case "route-id": |
| + rt.ClaimRowID = "other" |
| + case "timer-incarnation": |
| + tm.ClaimIncarnation = "other" |
| + case "timer-stamp": |
| + tm.ClaimStamp++ |
| + case "timer-owner": |
| + tm.ClaimOwner = "other" |
| + case "timer-id": |
| + tm.ClaimRowID = "other" |
| + case "timer-registration": |
| + tm.Registration = "other" |
| + case "route-entry-missing": |
| + snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[1:] |
| + case "timer-entry-missing": |
| + snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[:1] |
| + case "duplicate-entry": |
| + snapshot.Payloads[index].Inventory.Entries = append(snapshot.Payloads[index].Inventory.Entries, snapshot.Payloads[index].Inventory.Entries[1]) |
| + case "timer-other-host": |
| + snapshot.Payloads[index].Inventory.Entries[1].RelativePath = "other.example/backup" |
| + case "wrong-root": |
| + snapshot.Payloads[index].Inventory.Entries[1].ManagedRoot = "data" |
| + case "empty-installation": |
| + snapshot.Installations[0].Installation.Empty = true |
| + snapshot.Payloads[0].Inventory.Entries = nil |
| + snapshot.Launches = nil |
| + case "unreadable": |
| + reader = nil |
| + case "row-next-pass": |
| + tm.Registration, tm.ClaimRowID = "replacement", "replacement" |
| + snapshot.Payloads[index].Inventory.Entries[1].RowIdentity = "replacement" |
| + case "timer-delivery-progress": |
| + tm.NextAt++ |
| + tm.Attempts++ |
| + tm.LastStatus = "running" |
| + } |
| + timers := []registry.Timer{tm} |
| + if attack == "timer-missing" { |
| + timers = nil |
| + } |
| + if attack == "timer-extra" { |
| + extra := timer |
| + extra.Name, extra.Registration, extra.ClaimRowID = "extra", "extra", "extra" |
| + timers = append(timers, extra) |
| + } |
| + put(rt, timers) |
| + if attack == "closed-registry" { |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if attack == "row-next-pass" || attack == "timer-delivery-progress" { |
| + accept() |
| + next, _ := ledger.Completeness() |
| + changed := next.Evidence.Installations != initial.Evidence.Installations |
| + if changed != (attack == "row-next-pass") { |
| + t.Fatalf("row identity digest changed=%v for %s", changed, attack) |
| + } |
| + return |
| + } |
| + active = true |
| + if pins, err := source.StagedPins(t.Context(), f.root.generations.optDir); err == nil || pins != nil { |
| + t.Fatalf("registry mismatch certified: %v %v", pins, err) |
| + } |
| + if c, err := ledger.Completeness(); err != nil || c.Complete || c.Revision <= initial.Revision { |
| + t.Fatalf("invalid rows retained certificate: %+v %v", c, err) |
| + } |
| + if got, err := ledger.Read(reservation.Binding); err != nil || got.Binding != reservation.Binding || got.Closed { |
| + t.Fatal("row refusal changed reservation") |
| + } |
| + if attack == "closed-registry" { |
| + reg, err = registry.Open(registryPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + } |
| + active, reader = false, reg |
| + if err := json.Unmarshal(saved, &snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + put(route, []registry.Timer{timer}) |
| + accept() |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationRegistryReadOnly(t *testing.T) { |
| + t.Setenv("CARLOS_DATA_DIR", t.TempDir()) |
| + if _, err := productionGenerationRegistryInventory(t.Context(), generationInventory{}); err == nil { |
| + t.Fatalf("missing registry: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(dataDir(), "registry.db")); !os.IsNotExist(err) { |
| + t.Fatalf("read created registry: %v", err) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationRegistryProductionReader(t *testing.T) { |
| + t.Setenv("CARLOS_DATA_DIR", t.TempDir()) |
| + path := filepath.Join(dataDir(), "registry.db") |
| + reg, err := registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| + if err := reg.Upsert(registry.Route{Host: "tenant.example", ClaimOwnership: owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + snapshot := generationInventory{Payloads: []hostbox.GenerationPayload{{Inventory: hostbox.StopInventory{Host: "tenant.example", Incarnation: "inc", InstallationStamp: 1, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ManagedRoot: "registry", RelativePath: "tenant.example", RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}}}}}} |
| + if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + snapshot.Payloads[0].Inventory.Entries = nil |
| + if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err == nil { |
| + t.Fatal("empty retained inventory erased a present row") |
| + } |
| + after, err := os.ReadFile(path) |
| + if err != nil || !bytes.Equal(raw, after) { |
| + t.Fatal("read-only source changed registry", err) |
| + } |
| +} |
diff --git a/internal/host/registry_privilege_test.go b/internal/host/registry_privilege_test.go| index a6dffe563..818a1755b 100644 |
| --- a/internal/host/registry_privilege_test.go |
| +++ b/internal/host/registry_privilege_test.go |
| @@ -100,6 +100,7 @@ func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| {"systemapply.go", "openSystemRegistry", "openServiceRegistry"}, |
| {"claimstop_run.go", "RunInstanceControl", "openServiceRegistry"}, |
| {"claimstop_run.go", "instanceRegistryHostApp", "openServiceRegistryReadOnly"}, |
| + {"rootgeneration_rows.go", "productionGenerationRegistryInventory", "openServiceRegistryReadOnly"}, |
| {"claimjournal_migration.go", "RunSystemJournalMigrate", "openServiceRegistry"}, |
| } { |
| t.Run(tc.function, func(t *testing.T) { |
diff --git a/internal/host/rootgeneration_inventory.go b/internal/host/rootgeneration_inventory.go| index d873e930f..4fd3c9224 100644 |
| --- a/internal/host/rootgeneration_inventory.go |
| +++ b/internal/host/rootgeneration_inventory.go |
| @@ -238,7 +238,11 @@ func productionGenerationInventory(ctx context.Context) (generationInventory, er |
| if err != nil { |
| return generationInventory{}, err |
| } |
| - return generationRequiredInventory(ctx, store, snapshot) |
| + snapshot, err = generationRequiredInventory(ctx, store, snapshot) |
| + if err != nil { |
| + return generationInventory{}, err |
| + } |
| + return productionGenerationRegistryInventory(ctx, snapshot) |
| } |
| func generationInventoryAt(ctx context.Context, censusPath, fencesPath, journalPath string) (generationInventory, error) { |
diff --git a/internal/host/rootgeneration_rows.go b/internal/host/rootgeneration_rows.go| new file mode 100644 |
| index 000000000..93996acd3 |
| --- /dev/null |
| +++ b/internal/host/rootgeneration_rows.go |
| @@ -0,0 +1,100 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Registry rows can contradict retained obligations, but cannot remove them. |
| +// Read them through the service-user parser, including on the final recheck. |
| +func productionGenerationRegistryInventory(ctx context.Context, snapshot generationInventory) (generationInventory, error) { |
| + reg, err := openServiceRegistryReadOnly(filepath.Join(dataDir(), "registry.db")) |
| + if err != nil { |
| + return generationInventory{}, err |
| + } |
| + defer reg.Close() |
| + return generationRegistryInventory(ctx, reg, snapshot) |
| +} |
| + |
| +func generationRegistryInventory(ctx context.Context, reg *registry.Registry, snapshot generationInventory) (generationInventory, error) { |
| + var zero generationInventory |
| + if reg == nil { |
| + return zero, os.ErrPermission |
| + } |
| + type key struct{ kind, host, name string } |
| + expected := map[key]registry.ClaimOwnership{} |
| + hosts := map[string]bool{} |
| + for _, p := range snapshot.Payloads { |
| + in := p.Inventory |
| + hosts[in.Host] = true |
| + for _, entry := range in.Entries { |
| + if entry.Kind != "route" && entry.Kind != "timer" { |
| + continue |
| + } |
| + name := "" |
| + if entry.Kind == "timer" { |
| + name = strings.TrimPrefix(entry.RelativePath, in.Host+"/") |
| + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") || entry.RelativePath != in.Host+"/"+name { |
| + return zero, os.ErrPermission |
| + } |
| + } else if entry.RelativePath != in.Host { |
| + return zero, os.ErrPermission |
| + } |
| + own := registry.ClaimOwnership{ClaimIncarnation: in.Incarnation, ClaimStamp: in.InstallationStamp, ClaimOwner: entry.Ownership, ClaimRowID: entry.RowIdentity} |
| + k := key{entry.Kind, in.Host, name} |
| + if !own.Valid() || entry.ManagedRoot != "registry" || entry.Materialization != "materialized" { |
| + return zero, os.ErrPermission |
| + } |
| + if _, exists := expected[k]; exists { |
| + return zero, os.ErrPermission |
| + } |
| + expected[k] = own |
| + if len(expected) > 8192 { |
| + return zero, os.ErrPermission |
| + } |
| + } |
| + } |
| + for host := range hosts { |
| + if err := ctx.Err(); err != nil { |
| + return zero, err |
| + } |
| + route, err := reg.Get(host) |
| + if err != nil { |
| + return zero, err |
| + } |
| + k := key{"route", host, ""} |
| + own, exists := expected[k] |
| + if (route != nil) != exists { |
| + return zero, os.ErrPermission |
| + } |
| + if exists { |
| + if route.Host != host || route.ClaimOwnership != own { |
| + return zero, os.ErrPermission |
| + } |
| + delete(expected, k) |
| + } |
| + timers, err := reg.TimersFor(host) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if len(timers) > 8192 { |
| + return zero, os.ErrPermission |
| + } |
| + for _, timer := range timers { |
| + k := key{"timer", host, timer.Name} |
| + own, exists := expected[k] |
| + if !exists || timer.Host != host || timer.ClaimOwnership != own || timer.Registration != own.ClaimRowID { |
| + return zero, os.ErrPermission |
| + } |
| + delete(expected, k) |
| + } |
| + } |
| + if len(expected) != 0 { |
| + return zero, os.ErrPermission |
| + } |
| + return snapshot, ctx.Err() |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index b88ae72bc..0b891395e 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -54,7 +54,7 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S |
| internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a |
| internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab |
| internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7 |
| -@coverage internal/host 5feaa03212de11e8c12148f2d490885e2a5559e803691d3fb03a1763ea858ebb |
| +@coverage internal/host 31f9cc2f83dbad8a45e0bc453b80bb33d85f2e4187ce155c7a56afa69b7ce5cd |
| @coverage internal/release 2bb546a86c1ab49b02e424eb0b366b46776857a1249baa2d6fb61ead4209d6bd |
| @coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05 |
| internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154 |
| @@ -303,7 +303,7 @@ internal/host/rootgeneration_inventory.go generationInventoryDigest authority E2 |
| internal/host/rootgeneration_inventory.go generationOwner authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 69f89153837761f13116937b330ffbf406891299d5580296ea4aade905aa02ce |
| internal/host/rootgeneration_inventory.go generationReconciler.reconcile authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. d928288910227cc14f158595e44d052436a23c407e89682dd0930ee1011d7e57 |
| internal/host/rootgeneration_inventory.go openGenerationLaunchInventory authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 522445e6d8acbe083d9d8f001719c4dbd3af57c56429e6b73b05c55a3826cb8b |
| -internal/host/rootgeneration_inventory.go productionGenerationInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. 5312e59ec04dc9ce0b19a6072b95c212f6e3dd0419e9118d78c6e915171c8c65 |
| +internal/host/rootgeneration_inventory.go productionGenerationInventory authority E2 E31: census-only generation completeness compares retained route/timer obligations with actual ownership, row identity and timer registration through the existing read-only service-user parser on both source reads. Missing, replaced, extra or unreadable rows invalidate completeness without releasing pins. Row agreement does not establish omitted artifact classes or empty-installation authority. Fixtures: TestPlan14GenerationRegistryRows, TestPlan14GenerationRegistryReadOnly, TestPlan14GenerationRegistryProductionReader. Extended TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. 7eda05c4021b20a3c9aa102a291adac54ca2767484a3b21da7ef471d960e94ba |
| internal/host/rootgeneration_inventory.go type generationInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. 4de1dc2a2c9261b1e7bbd811d4d78a271d2fc5a37aeca196177f3e9527d0408e |
| internal/host/rootgeneration_inventory.go type generationReconciler authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. a979cdff1cd6311cf57d4a93e4501fb8dc2f65772d0013e622175b2a1f610d91 |
| internal/hostguard/generationinventory.go RootAuthority.GenerationInstallations authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 593768e6ba937cf2cfa2635c8f796f767d6c2354737a72073cc351f514faed20 |
| @@ -317,3 +317,6 @@ internal/host/rootgeneration_payload_linux.go generationPayloadFiles authority E |
| internal/host/rootgeneration_payload_other.go generationPayloadFiles authority E2 E28: retained data payloads require exact device/inode/type through no-symlink descriptor opens before and after kernel inspection. Missing, replaced and intended payloads remain unresolved. Tests: TestPlan14GenerationPayloadPhysicalIdentity; privileged TestPlan14GenerationPayloadPhysicalRootRunner remains runner-pending. Route/timer and running-tenant containment are not accepted by this check. candidate:Kind@183,candidate:Kind@204 182189d8409113d671cdc76fc5d2d73e7056057bca230d546e65300c428b2c18 |
| internal/host/rootgeneration_requirements.go generationRequiredInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. aaa70feaad4ed0331e7d45433bb0b6476601bbed68783294215cce16eaa5a3eb |
| internal/host/rootgeneration_requirements.go type generationRecord authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. ff50bc959af62afbd73b01e9640346f9c79d6b6578e03cb3a7d6bdff4d90fc28 |
| +internal/host/rootgeneration_rows.go generationRegistryInventory authority E2 E31: census-only generation completeness compares retained route/timer obligations with actual ownership, row identity and timer registration through the existing read-only service-user parser on both source reads. Missing, replaced, extra or unreadable rows invalidate completeness without releasing pins. Row agreement does not establish omitted artifact classes or empty-installation authority. Fixtures: TestPlan14GenerationRegistryRows, TestPlan14GenerationRegistryReadOnly, TestPlan14GenerationRegistryProductionReader. Extended TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. registry:ClaimOwnership@1738,registry:ClaimOwnership@2117,registry:ClaimRowID@2170,registry:Host@1716,registry:Host@2095,registry:Name@2036,registry:Registration@2148,whole:ClaimOwnership@1252,whole:ClaimOwnership@1316,whole:ClaimOwnership@1330,whole:ClaimOwnership@1344,whole:ClaimOwnership@1615,whole:ClaimOwnership@1744,whole:ClaimOwnership@1762,whole:ClaimOwnership@1817,whole:ClaimOwnership@2066,whole:ClaimOwnership@2123,whole:ClaimOwnership@2141,whole:ClaimOwnership@2236,whole:ClaimOwnership@2264,whole:Route@1633,whole:Timer@1922,whole:Timer@2000 4f33a3cfa22218a9faecf3ad06b86ad7832a4b1f98c484cff79d948e8c7eb7b4 |
| +@coverage internal/registry c5d195f3b4e018a076aee1d2a139c407a9bdcbc43896f95660d3437667764fee |
| +internal/registry/remote.go serveRemote authority E2 E31 mixed declaration: E2 adds only TimersFor to the existing read-only child allowlist so root can compare actual timer identities with retained obligations without parsing SQLite as root. All mutations remain refused. Generic writable dispatch remains unchanged legacy behavior. Pre-flight 80, 82, 83 and 90 justify promotion while retaining the original immutable legacy fingerprint. Fixtures: TestRemoteReadOnlyWALRegistry; production credential-drop witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. file:Open@332,registry:CallbackError@1681,registry:CallbackFailed@1638,registry:Deadline@1264,registry:Deadline@1338,registry:Method@1022,registry:Method@1043,registry:Method@1573,whole:remoteRequest@1395,whole:remoteRequest@835,whole:remoteRequest@922,whole:remoteResponse@1117,whole:remoteResponse@1163,whole:remoteResponse@1457,whole:remoteResponse@1755,whole:remoteResponse@1791,whole:remoteResponse@392,whole:remoteResponse@431 0d083f04f3363b5df2aa9df4e05fe80014b2d07ec77c88fd449807eb170deb93 |
diff --git a/internal/registry/readonly_test.go b/internal/registry/readonly_test.go| index 89ac7b0a6..c0fd0477b 100644 |
| --- a/internal/registry/readonly_test.go |
| +++ b/internal/registry/readonly_test.go |
| @@ -11,6 +11,8 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) { |
| if os.Geteuid() == 0 { |
| t.Skip("read-only permission fixture requires a nonroot uid") |
| } |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| + owner := ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 3, ClaimOwner: "owner", ClaimRowID: "route"} |
| dir := t.TempDir() |
| path := filepath.Join(dir, "registry.db") |
| writer, err := Open(path) |
| @@ -18,7 +20,11 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) { |
| t.Fatal(err) |
| } |
| defer writer.Close() |
| - if err := writer.Upsert(Route{Host: "readonly", Kind: "instance", Account: "acct", App: "app"}); err != nil { |
| + if err := writer.Upsert(Route{Host: "readonly", Kind: "instance", Account: "acct", App: "app", ClaimOwnership: owner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + timer, err := writer.UpsertTimerRegistration(Timer{Host: "readonly", Name: "backup", Kind: "cron", Spec: "* * * * *"}, "") |
| + if err != nil { |
| t.Fatal(err) |
| } |
| for _, suffix := range []string{"", "-wal", "-shm"} { |
| @@ -53,6 +59,20 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) { |
| if err != nil || row == nil || row.Account != "acct" || row.App != "app" { |
| t.Fatalf("readonly WAL read: %+v %v", row, err) |
| } |
| + timers, err := reader.TimersFor("readonly") |
| + if err != nil || len(timers) != 1 || timers[0].Registration != timer.Registration || timers[0].ClaimOwnership != timer.ClaimOwnership || timers[0].ClaimOwner != owner.ClaimOwner || timers[0].ClaimStamp != owner.ClaimStamp { |
| + t.Fatalf("readonly timer identity: %+v %v", timers, err) |
| + } |
| + if err := reader.ReplaceTimers("readonly", nil); err == nil { |
| + t.Fatal("read-only worker replaced timers") |
| + } |
| + if err := reader.DeleteTimer("readonly", "backup"); err == nil { |
| + t.Fatal("read-only worker deleted timer") |
| + } |
| + after, err := reader.TimersFor("readonly") |
| + if err != nil || len(after) != 1 || after[0] != timers[0] { |
| + t.Fatalf("refused writes changed timer: %+v %v", after, err) |
| + } |
| if err := reader.Upsert(Route{Host: "forbidden", Kind: "instance"}); err == nil { |
| t.Fatal("read-only worker accepted mutation") |
| } |
diff --git a/internal/registry/remote.go b/internal/registry/remote.go| index 07707597e..ea5197302 100644 |
| --- a/internal/registry/remote.go |
| +++ b/internal/registry/remote.go |
| @@ -275,7 +275,7 @@ func serveRemote(path string, input io.Reader, output io.Writer, readOnly bool) |
| } |
| }() |
| for q := range requests { |
| - if readOnly && q.Method != "Get" { |
| + if readOnly && q.Method != "Get" && q.Method != "TimersFor" { |
| out := remoteResponse{} |
| setResponseError(&out, errRemoteMethod) |
| if err := enc.Encode(out); err != nil { |