claim-untagged-pointer: install-channel --policy takes --identity (the published release identity) so an untagged channel pointer works; the activation witness has no hand-made input on the trial's path
Squashed from claim-untagged-pointer into main.
16 files changed,
+1019
−107
approved_region_ha_2b_copy_test.go+2 −1docs/site/cli.md+2 −0docs/superpowers/notes/census-proposal-claim-untagged-pointer.md+59 −0docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md+1 −1internal/console/apisystemupdate_test.go+46 −0internal/host/activation_console_rootrunner_linux_test.go+171 −65internal/host/installchannel.go+124 −28internal/host/installchannel_identity_test.go+488 −0internal/host/installchannel_linux.go+5 −4internal/host/installchannel_policy_linux_test.go+36 −0internal/host/installchannel_policy_rootrunner_linux_test.go+1 −0internal/host/installchannel_policy_test.go+42 −8internal/provision/plan14_root_reads_test.go+29 −0internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv+3 −0internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv+3 −0internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv+7 −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 334.323s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.929s
ok github.com/carlosframework/platform/cmd/moveprobe 93.139s
ok github.com/carlosframework/platform/cmd/zddprobe 0.707s
ok github.com/carlosframework/platform/internal/activator 250.575s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.526s
ok github.com/carlosframework/platform/internal/apperr 0.071s
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.745s
? 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.002s
ok github.com/carlosframework/platform/internal/cgroup 0.705s
ok github.com/carlosframework/platform/internal/changelog 0.316s
ok github.com/carlosframework/platform/internal/claimactivation 0.783s
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 413.648s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.805s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.435s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 46.068s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.140s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.021s
ok github.com/carlosframework/platform/internal/geo 0.038s
ok github.com/carlosframework/platform/internal/geoip 0.033s
ok github.com/carlosframework/platform/internal/historyreadback 0.159s
ok github.com/carlosframework/platform/internal/host 193.320s
ok github.com/carlosframework/platform/internal/hostactors 7.103s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s
ok github.com/carlosframework/platform/internal/hostbox 3.562s
ok github.com/carlosframework/platform/internal/hostclaim 1.054s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.009s
ok github.com/carlosframework/platform/internal/hostexec 6.073s
ok github.com/carlosframework/platform/internal/hostfence 0.049s
ok github.com/carlosframework/platform/internal/hostfiles 10.374s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.088s
ok github.com/carlosframework/platform/internal/hostops 2.393s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.004s
ok github.com/carlosframework/platform/internal/hostquarantine 0.008s
ok github.com/carlosframework/platform/internal/hostrelocate 0.787s
ok github.com/carlosframework/platform/internal/hostresolve 0.367s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.413s
ok github.com/carlosframework/platform/internal/instanceexec 2.877s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.005s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 2.014s
ok github.com/carlosframework/platform/internal/lbhealth 0.004s
ok github.com/carlosframework/platform/internal/lease 9.881s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.008s
ok github.com/carlosframework/platform/internal/ledger 0.017s
ok github.com/carlosframework/platform/internal/mail 0.231s
ok github.com/carlosframework/platform/internal/maintenance 0.106s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.623s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.130s
ok github.com/carlosframework/platform/internal/metrics 0.009s
ok github.com/carlosframework/platform/internal/moves 22.073s
ok github.com/carlosframework/platform/internal/moves/archive 1.294s
ok github.com/carlosframework/platform/internal/objstore 0.342s
ok github.com/carlosframework/platform/internal/ops 0.212s
ok github.com/carlosframework/platform/internal/park 0.332s
ok github.com/carlosframework/platform/internal/privatefile 0.013s
ok github.com/carlosframework/platform/internal/provision 46.248s
ok github.com/carlosframework/platform/internal/publicrelease 0.018s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.908s
ok github.com/carlosframework/platform/internal/relay 7.251s
ok github.com/carlosframework/platform/internal/release 26.088s
ok github.com/carlosframework/platform/internal/rollout 4.049s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.009s
ok github.com/carlosframework/platform/internal/rootparticipation 0.063s
ok github.com/carlosframework/platform/internal/rootstate 0.031s
ok github.com/carlosframework/platform/internal/runs 0.550s
ok github.com/carlosframework/platform/internal/s3compat 0.008s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 3.137s
ok github.com/carlosframework/platform/internal/seeds 0.014s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.636s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.720s
ok github.com/carlosframework/platform/internal/status 0.088s
ok github.com/carlosframework/platform/internal/steering 0.009s
ok github.com/carlosframework/platform/internal/stopevidence 0.416s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.009s
ok github.com/carlosframework/platform/internal/termfence 0.006s
ok github.com/carlosframework/platform/internal/tunnelclient 0.011s
ok github.com/carlosframework/platform/internal/unixdial 0.007s
ok github.com/carlosframework/platform/internal/updatewords 0.009s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.876s
=== 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: 49414306 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/18 20:43:28 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DFBA7994BA9923, 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/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go| index d49cf5638..4fa248032 100644 |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| @@ -70,7 +70,8 @@ func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) { |
| rows := builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt) |
| // A table cell holds one line, so in this record a row's text writes a |
| // newline as \n: RHB-C19, the install-channel usage Paul re-approved on |
| - // 2026-10-18 (claim-prev-rehearsal), is two lines and a blank one. |
| + // 2026-10-18 (claim-prev-rehearsal, then claim-untagged-pointer), is two |
| + // lines and a blank one. |
| for i := range rows { |
| rows[i].Text = strings.ReplaceAll(rows[i].Text, `\n`, "\n") |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 8638a2d39..22601a4d6 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2288,6 +2288,8 @@ carlos system install-channel installs the release the box's channel names, as b |
| With --policy FILE, the armed policy carlos system claims run sign-policy wrote, it installs before the host floor is published. The policy's floor stands in for the published one, and once a floor is published the two must be the same. It also arms a box that carlos system update has already moved to the channel's release: carlos is left as it is, and carlos.prev, the release before, is replaced by it, so guard-arm finds the release in both. A box on another release, or with an armed guard, is still refused. |
| +With --policy, --identity FILE is needed too: the release identity carlos system claims run publish-identity wrote. Before a floor is published, the channel pointer carlos system update writes names no release tag, so the release is found through this file instead. It must be the identity published for its tag, and it must name the release and the artifact the channel names. |
| + |
| ```sh |
| carlos system host-env write --deployment prod --region ie |
| carlos system install-channel |
diff --git a/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md b/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md| new file mode 100644 |
| index 000000000..3c2a1fee2 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md |
| @@ -0,0 +1,59 @@ |
| +# Census proposal: claim-untagged-pointer |
| + |
| +Status: RULED. platform-1e accepted every row as proposed on 2026-10-18 (census-ruling-claim-untagged-pointer): no 9B8 entry, and the seal stands. Overlay `internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv`, loaded last (after `claim-prev-rehearsal-fix2`), every row `rev3=census-ruling-claim-untagged-pointer`. |
| + |
| +## What changed |
| + |
| +The channel pointer `carlos system update` writes before a host floor is published carries no `release_tag` and no `moves_protocol`: the console's door promotes `{Version, Source "release:<tag>", Artifacts}` and tags a release only once a floor is published (`reserveHostUpdate`). install-channel found the release identity through the pointer's tag, so on dev's v0.28.3 pointers step 6.1a would have refused. |
| + |
| +`carlos system install-channel --policy FILE` now also takes `--identity FILE` (controller ruling R1, Paul's approval of 2026-10-18). FILE is exactly what `carlos system claims run` publish-identity writes. With `--policy`: |
| + |
| +- `--identity` is required, and `--identity` without `--policy` is refused (`readChannelInputs`); |
| +- the identity file is read through the same hardened open as the policy (`openChannelPolicy`: O_NOFOLLOW, O_NONBLOCK, a regular file, at most 64 KiB), before the apply lock or any store read; |
| +- once the release key is known it must verify with `release.VerifySystemIdentity`, be byte for byte the object at `release.IdentityKey(<its tag>)` in the bucket, and name the verified pointer's version and this platform's artifact sha256 (`channelIdentity`); |
| +- a pointer that does carry a `release_tag` must name the same identity; |
| +- that identity, never one found through the pointer's tag, is what the floor is checked against and what is kept for guard-arm (`channel-identity.json`). |
| + |
| +Without `--policy` nothing changes (ruling 6): the no-policy branch of `checkFloor` reads the floor, then the identity the pointer's tag names, exactly as before. |
| + |
| +## Units |
| + |
| +| Unit | Change | Class | Executing identity | |
| +|---|---|---|---| |
| +| `internal/host/installchannel.go` `channelInstall.run` | digest, sites and class unchanged; basis corrected to name `--identity` | authority F,J4 (as ruled in census-ruling-claim-prev-rehearsal-fix1) | uid 0: a fresh box's start-up, or, with `--policy --identity`, the box's attended root shell. Its one read site opens root's apply lock. | |
| +| `internal/host/installchannel_linux.go` `openChannelPolicy` | digest, sites and class unchanged; basis corrected: it now also reads the `--identity` file | authority F,J4 (as ruled in census-ruling-claim-prev-rehearsal-fix1) | uid 0 at the box's attended root shell, running `carlos system install-channel --policy --identity`. Reads each operator-staged file once, before the lock or any store read. | |
| +| `readChannelInputs`, `channelInstall.channelIdentity`, `channelInstall.checkFloor`, `channelInstall.install`, `RunSystemInstallChannel`, `newChannelInstall`, the new flag and refusal declarations | new or changed, no read site of their own | envelope | the same root shell | |
| +| `channelInstall.refreshPrev`, `slotState`, `var openat2`, `removeBeneath` | unchanged digests | their ruled rows stand | unchanged; not restated | |
| + |
| +The `internal/host` envelope is re-pinned to `2dcf104b…3026c`. The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches any changed unit. |
| + |
| +The published identity is a bucket read (`release.Store.Get` at `control/system/router/releases/<tag>.json`), not a root read of local data, as the no-policy path's identity read already was. |
| + |
| +## 9B8 |
| + |
| +No new bucket writer. install-channel still writes only local files (`carlos.prev`, its version record, its judgement through `notePrev`, and `channel-identity.json`, now the `--identity` file's bytes). No 9B8 entry is proposed. |
| + |
| +## 2b and claim rows carried over |
| + |
| +No 2b row is re-pinned. The two re-pinned claim rows keep their class exactly: `channelInstall.run` F,J4 and `openChannelPolicy` F,J4. |
| + |
| +## Test files |
| + |
| +These are tests and are outside the census: the new `installchannel_identity_test.go`, the changes to `installchannel_policy_test.go`, `installchannel_policy_linux_test.go` and `installchannel_policy_rootrunner_linux_test.go`, the rewritten root-runner witness `activation_console_rootrunner_linux_test.go`, and the console's `apisystemupdate_test.go`. |
| + |
| +## Fix round 1 (RULED) |
| + |
| +Status: RULED. platform-1e accepted it as proposed on 2026-10-18 (census-ruling-claim-untagged-pointer-fix1): an envelope-only re-pin of internal/host, the seal unchanged, no 9B8 entry. Overlay `internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv`, loaded after the ruled overlay. Envelope only. |
| + |
| +What changed, all on the `--policy` path or in comments: |
| + |
| +- `channelInstall.channelIdentity`: when the bucket holds no identity object under the `--identity` file's tag, the refusal is now "no release identity is published for <tag>, so nothing was installed". "the --identity file is not the identity published for <tag>, ..." stays for an object that exists but differs. |
| +- `openChannelPolicy`'s doc comment now names both files it reads. Its digest, which covers the declaration and not its doc comment, is unchanged. |
| + |
| +No root-read unit's digest or sites change: `channelInstall.run` and `openChannelPolicy` stand at census-ruling-claim-untagged-pointer's rows. The `internal/host` envelope is re-pinned to `846d5194…d741a5`. The seal stands, and no 9B8 entry is proposed: nothing new writes to a bucket. |
| + |
| +## Fix round 2 (RULED) |
| + |
| +Status: RULED. platform-1e accepted it as proposed on 2026-10-18 (census-ruling-claim-untagged-pointer-fix2): an envelope-only re-pin of internal/host, the seal unchanged, no 9B8 entry. Overlay `internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv`, loaded after fix1. Envelope only. |
| + |
| +What changed: Paul approved all nine strings verbatim on 2026-10-18, so `installChannelUsage` now carries the re-approved RHB-C19 usage, `usage: carlos system install-channel [--policy FILE --identity FILE]`. That constant is the only source change in `internal/host`. No root-read unit's digest or sites change. The `internal/host` envelope is re-pinned to `76aa9108…c9bae`. The seal stands, and no 9B8 entry is proposed. |
diff --git a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md| index 7284ee8f6..92e7f5115 100644 |
| --- a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md |
| +++ b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md |
| @@ -79,7 +79,7 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference. |
| | id | file | text | status | |
| |---|---|---|---| |
| -| RHB-C19 | internal/host/installchannel.go (a fragment of the usage, re-approved by Paul 2026-10-18 (claim-prev-rehearsal)) | usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet. | approved | |
| +| RHB-C19 | internal/host/installchannel.go (a fragment of the usage, re-approved by Paul 2026-10-18 (claim-prev-rehearsal), re-approved by Paul 2026-10-18 (claim-untagged-pointer)) | usage: carlos system install-channel [--policy FILE --identity FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet. | approved | |
| | RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | approved | |
| | RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | approved | |
| | RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | approved | |
diff --git a/internal/console/apisystemupdate_test.go b/internal/console/apisystemupdate_test.go| index 9a7ddffe1..3486a7cff 100644 |
| --- a/internal/console/apisystemupdate_test.go |
| +++ b/internal/console/apisystemupdate_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "sync" |
| "testing" |
| @@ -494,3 +495,48 @@ func TestSystemFeedIsAllThreeFieldsOrNone(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor pins the channel |
| +// pointer `carlos system update --version <tag>` writes while no host floor |
| +// is published (the claim trial's dev pointers on v0.28.3, untagged-pointer |
| +// ruling R2): the release as the door resolved it, {Version, Source |
| +// "release:<tag>", Artifacts}, and nothing else. No release tag, no moves |
| +// protocol and no console artifacts, because reserveHostUpdate tags a |
| +// release only once a floor is published. internal/host's install-channel |
| +// tests and the root-runner activation witness promote exactly this shape. |
| +func TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + arm := []byte("ELF arm64") |
| + systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm}) |
| + seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.17.0") |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-untagged") |
| + st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0", "spread": "0s"}, owner) |
| + if st != http.StatusOK { |
| + t.Fatalf("update = %d %v", st, out) |
| + } |
| + if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| + t.Fatalf("job failed: %v", job) |
| + } |
| + p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := sha256.Sum256(arm) |
| + want := release.SystemRelease{Version: "v0.18.0", Source: "release:v0.18.0", ReleasedAt: p.Current.ReleasedAt, |
| + Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v0.18.0", "linux-arm64"), SHA256: hex.EncodeToString(s[:])}}} |
| + if !reflect.DeepEqual(p.Current, want) || p.Spread != "0s" { |
| + t.Fatalf("pointer current = %+v spread %s, want %+v", p.Current, p.Spread, want) |
| + } |
| + if _, err := release.VerifySystemIdentity(fx.pub, mustGetObject(t, fx, release.SystemPointerKey("router", "stable"))); err == nil { |
| + t.Fatal("an untagged pointer verified as a release identity") |
| + } |
| +} |
| + |
| +func mustGetObject(t *testing.T, fx *consoleFixture, key string) []byte { |
| + t.Helper() |
| + raw, _, err := fx.rel.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| index 39ebfe63e..39a352476 100644 |
| --- a/internal/host/activation_console_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -35,6 +35,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -94,33 +95,48 @@ type activationPhaseResult struct { |
| } |
| // The claim trial's steps 6 and 7, rehearsed on a box the platform made |
| -// (prev-rehearsal ruling R2). Nothing on the box is written by hand but what |
| -// the region-host README's four steps write (the first binary and host.env) |
| -// and the box-only console.service with its database: |
| +// (prev-rehearsal ruling R2) with no hand-made input on the trial's path |
| +// (untagged-pointer ruling R2). Every pointer, identity, floor, policy, |
| +// roster and slot comes from the platform's own commands and code paths, as |
| +// on dev: |
| // |
| // - the box is installed at v0.9.0 (README steps 1 to 4: the binary, |
| -// host.env, `carlos bootstrap`, the edge), and the real agent and |
| -// `carlos system apply`, run by carlos-system-update.service, update it |
| -// to v1.0.0, leaving v0.9.0 in carlos.prev, as on the trial's boxes; |
| +// host.env, `carlos bootstrap`, the edge); |
| +// - the operator's system promote of v1.0.0 is an in-process store.Put of |
| +// the artifact (scaffolding) and release.PromoteSystem with the untagged |
| +// release {Version, Source "release:<tag>", Artifacts}, so the pointer |
| +// carries no release tag and no moves protocol, as dev's v0.28.3 |
| +// pointers do. It is tied to `carlos system update`'s door only through |
| +// the console's TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, |
| +// which drives the real door with no floor and pins that shape. The real agent and `carlos system apply` then update the box |
| +// to v1.0.0, leaving v0.9.0 in carlos.prev; |
| // - its AWS identity record is published by `carlos system box |
| -// publish-record` as the agent user, as step 5.0 runs it; |
| -// - the roster is made by `claims run` initialize to enroll-console, and |
| -// the box and its console are enrolled with the runner's real AWS |
| -// identity; |
| -// - the trial's 6.2 stop is reproduced, then `install-channel --policy` |
| -// makes carlos.prev the release (R1), and step 6 and step 7 run as the |
| -// runbook gives them, with the real agent's box reports as evidence. |
| +// publish-record` as the agent user (step 5.0); |
| +// - the console release is step 5.0a's: `carlos channels digests on`, |
| +// `carlos ship` and `carlos promote` run by the operator's CLI, and the |
| +// box stages the promoted head through the adopt pass's staging |
| +// (release.StageSignedHead) into /opt/carlos/versions; |
| +// - the roster is made by `claims run` initialize to enroll-console, with |
| +// the runner's real AWS identity; sign-policy is the real binary; |
| +// publish-identity is claims run's own producing path |
| +// (reserveActivationOutput, runProducingPhase, activationRunner) with |
| +// only the sums key swapped, because the vendor key cannot sign a |
| +// fixture (the real binary's refusal of the fixture sums is checked); |
| +// - the trial's 6.2 stop is reproduced, then `install-channel --policy |
| +// --identity` (R1) makes carlos.prev the release over the untagged |
| +// pointer, and step 6 and step 7 run as the runbook gives them, with |
| +// the real agent's box reports as evidence. |
| // |
| // One AWS instance can be enrolled as one box only (boxid refuses a second |
| // reference for an instance), so this box is both the trial's hosting box |
| // and its console box: guard-arm runs in leo's form, then in sophie's. |
| -// Fixtures that remain: the loopback S3 bucket, the console release (shipped |
| -// and promoted in-process with the production functions), publish-identity's |
| -// sums key (the vendor key cannot sign a fixture; the real binary's refusal |
| -// of the fixture sums is checked), the console's versions tree, the console |
| -// unit and database, and the claim state behind the authenticated reset. |
| -// Network: every carlos unit gets a runtime drop-in allowing only loopback |
| -// and the instance metadata service. |
| +// What the witness still writes itself, all outside the trial's path, is |
| +// listed in the untagged-pointer report: the loopback S3 bucket, the |
| +// operator's inputs (host.env, the first binary, plan files, floor.json, the |
| +// fixture sums), the file transfers the runbook does with box_put and jq, |
| +// the console unit and its database, and the claim state behind the |
| +// authenticated reset after step 7. Network: every carlos unit gets a |
| +// runtime drop-in allowing only loopback and the instance metadata service. |
| func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 || len(activationCarlosOldImage) == 0 { |
| t.Fatal("requires root and embedded activation binaries") |
| @@ -286,13 +302,23 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| write("/run/systemd/system/"+u+".d/10-rootrunner-network.conf", []byte("[Service]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"), 0644) |
| } |
| ctl("daemon-reload") |
| - // The operator promotes v1.0.0 on the box's channel, as the console's |
| - // system update door does. |
| + // The operator's promote of v1.0.0, before any floor is published. This |
| + // is not the console door or the carlos system update CLI: it is an |
| + // in-process store.Put of the artifact (scaffolding, standing in for the |
| + // door's mirror) and release.PromoteSystem with the untagged release |
| + // {Version, Source "release:<tag>", Artifacts}. That shape is tied to the |
| + // real door only through the console's |
| + // TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, which drives the |
| + // door with no floor and pins exactly this pointer. |
| artifact := release.SystemArtifact{Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)} |
| _, err := store.Put(ctx, artifact.Key, activationCarlosImage, "") |
| check(err) |
| - _, err = release.PromoteSystem(ctx, store, signer, release.SystemComponentRouter, "stable", release.SystemRelease{Version: "v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-arm64": artifact}}, release.SystemPromoteOptions{Actor: "root-runner"}) |
| + _, err = release.PromoteSystem(ctx, store, signer, release.SystemComponentRouter, "stable", release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", Artifacts: map[string]release.SystemArtifact{"linux-arm64": artifact}}, release.SystemPromoteOptions{Actor: "operator@example.test"}) |
| check(err) |
| + // The pointer is dev's: no release tag, no moves protocol. |
| + if p, _, e := release.GetSystemPointer(ctx, store, pub, release.SystemComponentRouter, "stable"); e != nil || p.Current.ReleaseTag != "" || p.Current.MovesProtocol != 0 || p.Current.Source != "release:v1.0.0" { |
| + t.Fatal("the operator's promote is not the untagged pointer the trial found", p, e) |
| + } |
| // Step 4: the edge. Its agent finds the promote, fetches the build and |
| // asks root, and carlos-system-update.service applies it. |
| ctl("start", "carlos-edge.service") |
| @@ -438,73 +464,112 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if o := refresh(); o.Roster.Consoles[consoleID].Compute == nil || len(o.Roster.Boxes) != 1 { |
| t.Fatal("inspect", o.Roster) |
| } |
| - // The box's staged inputs (/root/claim-trial on the trial's boxes). |
| + // The box's staged inputs (/root/claim-trial on the trial's boxes). The |
| + // enrollment file is 5.1's jq over the signer's inspect output, |
| + // '{box:$box,enrollment:.roster.boxes[$box].enrollment,instance_id:.roster.boxes[$box].cloud.instance_id}', |
| + // as jq prints it, then box_put. enrollmentRaw is what guard-arm then |
| + // writes to host-actor.json. |
| bd := filepath.Join(d, "box") |
| check(os.MkdirAll(bd, 0700)) |
| + inspected := refresh().Roster.Boxes[box] |
| + enrollmentFile, err := json.MarshalIndent(struct { |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + InstanceID string `json:"instance_id"` |
| + }{box, inspected.Enrollment, inspected.Cloud.InstanceID}, "", " ") |
| + check(err) |
| + enrollmentFile = append(enrollmentFile, '\n') |
| + write(sd+"/"+box+"-enrollment.json", enrollmentFile, 0600) |
| + write(bd+"/"+box+"-enrollment.json", enrollmentFile, 0600) |
| enrollmentRaw, _ := json.Marshal(hostfloor.Enrollment{Box: box, Enrollment: enrolled.Enrollment, InstanceID: enrolled.Cloud.InstanceID}) |
| - write(bd+"/"+box+"-enrollment.json", enrollmentRaw, 0600) |
| // Step 6.1: the armed policy, through the real binary's sign-policy. |
| floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| floorRaw, _ := json.Marshal(floor) |
| - signed := applyPlan(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor}, sd+"/policy.json") |
| - policyRaw, err := os.ReadFile(sd + "/policy.json") |
| + signed := applyPlan(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor}, sd+"/policy.v1.0.0.json") |
| + policyRaw, err := os.ReadFile(sd + "/policy.v1.0.0.json") |
| check(err) |
| if len(signed.Writes) != 0 || signed.FloorSHA256 != hostfloor.Digest(floorRaw) || signed.OutputSHA256 != hostfloor.Digest(policyRaw) { |
| t.Fatal("sign-policy", signed.Result) |
| } |
| - signerCLI(false, runArgs(sd+"/policy.json")...) // --out is never replaced |
| + signerCLI(false, runArgs(sd+"/policy.v1.0.0.json")...) // --out is never replaced |
| var policy hostfloor.Policy |
| check(json.Unmarshal(policyRaw, &policy)) |
| - if st, e := os.Stat(sd + "/policy.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" { |
| + if st, e := os.Stat(sd + "/policy.v1.0.0.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" { |
| t.Fatal("policy file", st, e) |
| } |
| - // The identity. The console release, as carlos ship and carlos promote |
| - // leave it with pointer digests on (step 5.0a). |
| - check(release.WriteDigestMarker(ctx, store, signer, "bab", "root-runner")) |
| - check(release.Ship(ctx, store, "bab", release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}}, map[string][]byte{"linux-arm64": activationConsoleImage})) |
| - check(signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234")) |
| - // publish-identity over the production S3 store (create-only PUT) |
| - // against the loopback bucket. The router hash comes from a signed |
| - // checksum list; the runner's sums key is this fixture's, because the |
| - // vendor key cannot sign a fixture. The real binary's own refusal of that |
| - // list is checked below. |
| + // Step 5.0a, the console release, by the operator's CLI in direct-bucket |
| + // mode as ship-app.sh runs it: pointer digests on for the account, then |
| + // carlos ship and carlos promote of the prebuilt console. |
| + od := filepath.Join(d, "operator") |
| + check(os.MkdirAll(od+"/home", 0700)) |
| + write(od+"/console-linux-arm64", activationConsoleImage, 0755) |
| + operator := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.CommandContext(ctx, sd+"/carlos", args...) |
| + cmd.Env = append(append([]string{}, shellEnv...), "HOME="+od+"/home", "CARLOS_RELEASE_KEY="+base64.StdEncoding.EncodeToString(key.Seed())) |
| + cmd.Dir = od |
| + out, e := cmd.CombinedOutput() |
| + if e != nil { |
| + t.Fatal(args, string(out), e) |
| + } |
| + return string(out) |
| + } |
| + t.Log(operator("channels", "digests", "on", "--account-id", "bab")) |
| + t.Log(operator("ship", "--app", "console", "--account-id", "bab", "--version", "abc1234", "--kind", "binary", od+"/console-linux-arm64")) |
| + t.Log(operator("promote", "-app", "console", "--account-id", "bab", "abc1234", "canary/console")) |
| + // publish-identity through claims run's own producing path (phase_out: |
| + // --out reserved before the phase, the dry run, then the apply, the file |
| + // read back) over the production S3 store against the loopback bucket. |
| + // The runner is the CLI's (activationRunner) with only its sums key |
| + // swapped: the vendor key cannot sign a fixture checksum list. The real |
| + // binary's own refusal of that list is checked below. |
| s3, err := release.NewS3StoreIn(ctx, bucket, "eu-west-1") |
| check(err) |
| sumsPub, sumsKey, _ := ed25519.GenerateKey(nil) |
| - identityRunner := claimactivation.Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: bucket}, Signer: signer, SumsKey: base64.StdEncoding.EncodeToString(sumsPub)} |
| + identityRunner := activationRunner(s3, pub, bucket, key) |
| + identityRunner.SumsKey = base64.StdEncoding.EncodeToString(sumsPub) |
| sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n" + hostfloor.Digest([]byte("amd64 deb")) + " carlos_1.0.0_amd64.deb\n" + hostfloor.Digest([]byte("arm64 deb")) + " carlos_1.0.0_arm64.deb\n" |
| identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums, |
| SumsSignature: base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums))), |
| Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}} |
| - preview, err := identityRunner.Run(ctx, identityPlan, true) |
| + producing := func(out string, dry bool) (claimactivation.Result, error) { |
| + t.Helper() |
| + reserved, e := reserveActivationOutput(out, dry) |
| + if e != nil { |
| + return claimactivation.Result{}, e |
| + } |
| + return runProducingPhase(ctx, identityRunner, &putAttempts{Store: s3}, identityPlan, reserved, dry) |
| + } |
| + beforeIdentity := controlSnapshot() |
| + preview, err := producing(sd+"/identity.v1.0.0.json", true) |
| check(err) |
| - if _, _, e := store.Get(ctx, "control/system/router/releases/v1.0.0.json"); !errors.Is(e, os.ErrNotExist) || preview.Output != nil { |
| + if _, _, e := store.Get(ctx, "control/system/router/releases/v1.0.0.json"); !errors.Is(e, os.ErrNotExist) || preview.Output != nil || !reflect.DeepEqual(beforeIdentity, controlSnapshot()) { |
| t.Fatal("identity dry run published", e) |
| } |
| - published, err := identityRunner.Run(ctx, identityPlan, false) |
| - check(err) |
| - identityOut, err := reserveActivationOutput(sd+"/identity.json", false) |
| + absent(sd + "/identity.v1.0.0.json") |
| + published, err := producing(sd+"/identity.v1.0.0.json", false) |
| check(err) |
| - check(identityOut.finish(published.Output)) |
| - identityOut.close() |
| - identityRaw, err := os.ReadFile(sd + "/identity.json") |
| + identityRaw, err := os.ReadFile(sd + "/identity.v1.0.0.json") |
| check(err) |
| + if published.OutputSHA256 != hostfloor.Digest(identityRaw) || published.IdentityKey != "control/system/router/releases/v1.0.0.json" { |
| + t.Fatal("publish-identity's --out file", published) |
| + } |
| planRaw, _ := json.Marshal(identityPlan) |
| write(sd+"/plan.json", planRaw, 0600) |
| - if _, stderr := signerCLI(false, runArgs(sd+"/identity-vendor.json")...); !strings.Contains(stderr, sd+"/identity-vendor.json was kept. run it again with a new --out path") { |
| + if _, stderr := signerCLI(false, runArgs(sd+"/identity-vendor.v1.0.0.json")...); !strings.Contains(stderr, sd+"/identity-vendor.v1.0.0.json was kept. run it again with a new --out path") { |
| t.Fatal("vendor-key refusal", stderr) |
| } |
| - if st, e := os.Stat(sd + "/identity-vendor.json"); e != nil || st.Size() != 0 { |
| + if st, e := os.Stat(sd + "/identity-vendor.v1.0.0.json"); e != nil || st.Size() != 0 { |
| t.Fatal("the refused run's reservation", st, e) |
| } |
| // Staged on the box, byte for byte (box_put). |
| - write(bd+"/policy.json", policyRaw, 0600) |
| - write(bd+"/identity.json", identityRaw, 0600) |
| + write(bd+"/policy.v1.0.0.json", policyRaw, 0600) |
| + write(bd+"/identity.v1.0.0.json", identityRaw, 0600) |
| // Step 6.2 as the trial ran it: guard-arm refuses the updated box, whose |
| // carlos.prev is the release before, and writes nothing. |
| - armLeo := []string{"system", "guard-arm", "--policy", bd + "/policy.json", "--identity", bd + "/identity.json", "--enrollment", bd + "/" + box + "-enrollment.json"} |
| + armLeo := []string{"system", "guard-arm", "--policy", bd + "/policy.v1.0.0.json", "--identity", bd + "/identity.v1.0.0.json", "--enrollment", bd + "/" + box + "-enrollment.json"} |
| _, guardBefore := os.Lstat(systemGuardDir(opt)) |
| if _, stderr := carlos(false, armLeo...); !strings.Contains(stderr, "hostfloor: authenticated floor required") { |
| t.Fatal("the trial's stop did not reproduce", stderr) |
| @@ -519,9 +584,19 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if _, stderr := carlos(false, "system", "install-channel"); !strings.HasSuffix("\n"+strings.TrimSpace(stderr), "\nno host floor is published, so this box could not arm its guard and nothing was installed") { |
| t.Fatal("install-channel without --policy", stderr) |
| } |
| - // R1: install-channel --policy on the box. |
| - if out, _ := carlos(true, "system", "install-channel", "--policy", bd+"/policy.json"); strings.TrimSpace(out) != "replaced the rollback copy carlos.prev with v1.0.0 (v1.0.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| - t.Fatal("install-channel --policy", out) |
| + // --policy alone is refused up front (untagged-pointer R1), and changes |
| + // nothing. |
| + slotsBefore := activationSlotFiles(t, opt) |
| + if _, stderr, e := try("/opt/carlos/carlos", "system", "install-channel", "--policy", bd+"/policy.v1.0.0.json"); e == nil || !strings.HasSuffix("\n"+strings.TrimSpace(stderr), "\n--policy and --identity go together, so nothing was installed") { |
| + t.Fatal("install-channel --policy without --identity", stderr, e) |
| + } |
| + if !reflect.DeepEqual(slotsBefore, activationSlotFiles(t, opt)) { |
| + t.Fatal("the refused install-channel changed the box") |
| + } |
| + // 6.1a: install-channel --policy --identity over the untagged pointer. |
| + installChannel := []string{"system", "install-channel", "--policy", bd + "/policy.v1.0.0.json", "--identity", bd + "/identity.v1.0.0.json"} |
| + if out, _ := carlos(true, installChannel...); strings.TrimSpace(out) != "replaced the rollback copy carlos.prev with v1.0.0 (v1.0.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| + t.Fatal("install-channel --policy --identity", out) |
| } |
| cur, _ = os.ReadFile(systemBinaryPath(opt)) |
| prev, _ = os.ReadFile(systemPrevPath(opt)) |
| @@ -531,8 +606,8 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if held, e := os.ReadFile(channelIdentityPath(opt)); e != nil || !bytes.Equal(held, identityRaw) { |
| t.Fatal("the channel identity is not the published one", e) |
| } |
| - if out, _ := carlos(true, "system", "install-channel", "--policy", bd+"/policy.json"); strings.TrimSpace(out) != "v1.0.0 (v1.0.0) from the stable channel is already installed" { |
| - t.Fatal("install-channel --policy again", out) |
| + if out, _ := carlos(true, installChannel...); strings.TrimSpace(out) != "v1.0.0 (v1.0.0) from the stable channel is already installed" { |
| + t.Fatal("install-channel --policy --identity again", out) |
| } |
| // Readers (P3's box line): both slots are the trial release. |
| if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v1.0.0" { |
| @@ -553,12 +628,19 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| // carlos-owned versions tree as on the dev console box. |
| uid, gid := activationServiceIDs(t) |
| check(os.Chmod(d, 0755)) |
| - source := d + "/versions/abc1234/console" |
| - write(source, activationConsoleImage, 0755) |
| - write(d+"/versions/other/console", append([]byte("not the release "), activationConsoleImage[:64]...), 0755) |
| - for _, p := range []string{d + "/versions", d + "/versions/abc1234", source} { |
| - check(os.Chown(p, uid, gid)) |
| + // 5.0a on the console box: the adopt pass stages the promoted head into |
| + // the box's versions tree (StageSignedHead: the channel's signed |
| + // digest, the manifest that hashes to it, then the artefact by its |
| + // sha256), and install-console takes it as --source, as sophie's |
| + // /opt/carlos/versions/50f5b268/console. |
| + _, stagedManifest, err := release.StageSignedHead(ctx, s3, pub, nil, opt, registry.Route{Account: "bab", App: "console", Channel: "canary/console", Host: "platform.example.test"}) |
| + check(err) |
| + source := release.VersionPath(opt, "bab", "abc1234", "console") |
| + if body, e := os.ReadFile(source); e != nil || !bytes.Equal(body, activationConsoleImage) || stagedManifest.Version != "abc1234" { |
| + t.Fatal("the staged console is not the promoted build", source, e) |
| } |
| + // A refusal input only: a build that is not the release. |
| + write(d+"/versions/other/console", append([]byte("not the release "), activationConsoleImage[:64]...), 0755) |
| // The box-only console.service and its database. |
| dbPath := "/data/console.db" |
| st, err := console.Open(dbPath) |
| @@ -618,7 +700,7 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| } |
| rev := boxInspect() |
| install := func(src string) []string { |
| - return []string{"system", "claims", "install-console", "--identity", bd + "/identity.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg} |
| + return []string{"system", "claims", "install-console", "--identity", bd + "/identity.v1.0.0.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg} |
| } |
| carlos(false, install(d+"/versions/other/console")...) |
| carlos(false, install(systemBinaryPath(opt))...) |
| @@ -821,7 +903,7 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, consoleID, o.Roster.Revision, false); err == nil { |
| t.Fatal("reported stopped console") |
| } |
| - t.Log("PASS steps 6 and 7 on a box made by bootstrap and the real update: the 6.2 stop reproduced, install-channel --policy, guard-arm in both forms, the guarded console, floor, home, key and console runtime, with the agent's own box reports") |
| + t.Log("PASS steps 6 and 7 on a box made by bootstrap and the real update: the 6.2 stop reproduced, install-channel --policy --identity over the untagged pointer, guard-arm in both forms, the guarded console, floor, home, key and console runtime, with the agent's own box reports") |
| } |
| // activationServiceIDs is the service user bootstrap created. |
| @@ -1051,3 +1133,27 @@ func activationResetFixture(t *testing.T, s release.Store, origin, token, other, |
| } |
| t.Log("exhausted retry reset through production runtime; holder/session/stamp preserved") |
| } |
| + |
| +// activationSlotFiles is every file in the box's /opt/carlos and in root's |
| +// system/router directory, by name and content, so a refused command can be |
| +// shown to have changed nothing. |
| +func activationSlotFiles(t *testing.T, opt string) map[string]string { |
| + t.Helper() |
| + got := map[string]string{} |
| + for _, dir := range []string{opt, systemRootDir(opt)} { |
| + ents, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range ents { |
| + path := filepath.Join(dir, e.Name()) |
| + if fi, err := os.Lstat(path); err == nil && fi.Mode().IsRegular() { |
| + b, _ := os.ReadFile(path) |
| + got[path] = hostfloor.Digest(b) |
| + } else if err == nil { |
| + got[path] = fi.Mode().String() |
| + } |
| + } |
| + } |
| + return got |
| +} |
diff --git a/internal/host/installchannel.go b/internal/host/installchannel.go| index 4e0aa52d4..9410b90e4 100644 |
| --- a/internal/host/installchannel.go |
| +++ b/internal/host/installchannel.go |
| @@ -53,11 +53,24 @@ import ( |
| // carlos.prev, the release before, is replaced by that artifact |
| // (refreshPrev), because guard-arm requires both slots to be the |
| // identity's release. Without --policy nothing here changes. |
| +// |
| +// --identity FILE (claim trial, untagged-pointer ruling R1) goes with |
| +// --policy, and only with it. FILE is exactly what `carlos system claims run` |
| +// publish-identity writes. The channel pointer `carlos system update` writes |
| +// before a floor is published carries no release tag (the console tags a |
| +// release only once a floor is published), so with --policy the release's |
| +// identity is never found through the pointer: --identity must verify under |
| +// CARLOS_RELEASE_PUBKEY, be byte for byte the object published under its own |
| +// tag, and name the version and this platform's artifact sha256 the verified |
| +// pointer names (and its tag, if the pointer carries one). That identity is |
| +// the one the floor is checked against and the one kept for guard-arm. |
| -const installChannelUsage = "usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet.\n" |
| +const installChannelUsage = "usage: carlos system install-channel [--policy FILE --identity FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet.\n" |
| const installChannelPolicyFlag = "the armed policy carlos system claims run sign-policy wrote, used as the host floor until one is published" |
| +const installChannelIdentityFlag = "the release identity carlos system claims run publish-identity wrote, used with --policy in place of the channel's release tag" |
| + |
| const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary" |
| // channelIdentityPath is where the installed release's signed identity is |
| @@ -76,6 +89,9 @@ type channelInstall struct { |
| own func(path string) error |
| // policy is --policy's file, byte for byte; nil without the flag. |
| policy []byte |
| + // identity is --identity's file, byte for byte; set with policy and |
| + // only with it. |
| + identity []byte |
| } |
| // slotKind is what one protected slot holds (slotState). |
| @@ -99,12 +115,17 @@ type channelInstalled struct { |
| func RunSystemInstallChannel(args []string) { |
| fs := flag.NewFlagSet("system install-channel", flag.ExitOnError) |
| policyPath := fs.String("policy", "", installChannelPolicyFlag) |
| + identityPath := fs.String("identity", "", installChannelIdentityFlag) |
| fs.Usage = cliflag.Usage(fs, installChannelUsage) |
| _ = fs.Parse(args) |
| if fs.NArg() != 0 { |
| fs.Usage() |
| os.Exit(2) |
| } |
| + if (*policyPath == "") != (*identityPath == "") { |
| + fmt.Fprintln(os.Stderr, errChannelPolicyIdentity) |
| + os.Exit(2) |
| + } |
| if os.Geteuid() != 0 { |
| fmt.Fprintln(os.Stderr, installChannelNotRoot) |
| os.Exit(1) |
| @@ -114,16 +135,12 @@ func RunSystemInstallChannel(args []string) { |
| fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid)) |
| os.Exit(1) |
| } |
| - var policy []byte |
| - if *policyPath != "" { |
| - raw, err := readChannelPolicy(*policyPath) |
| - if err != nil { |
| - fmt.Fprintln(os.Stderr, err) |
| - os.Exit(1) |
| - } |
| - policy = raw |
| + policy, identity, err := readChannelInputs(*policyPath, *identityPath) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| } |
| - in := newChannelInstall(channel, policy) |
| + in := newChannelInstall(channel, policy, identity) |
| got, err := in.run(context.Background()) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| @@ -140,12 +157,20 @@ const channelPolicyLimit = 64 << 10 |
| // alike before and after its signature is checked. |
| var errChannelPolicy = errors.New("the --policy file is not an armed policy signed for this deployment, so nothing was installed") |
| +// errChannelPolicyIdentity refuses --policy without --identity, or |
| +// --identity without --policy. |
| +var errChannelPolicyIdentity = errors.New("--policy and --identity go together, so nothing was installed") |
| + |
| +// errChannelIdentity is --identity's refusal of a file that is not a release |
| +// identity signed under the box's release key. |
| +var errChannelIdentity = errors.New("the --identity file is not a release identity signed for this deployment, so nothing was installed") |
| + |
| // newChannelInstall is the production run. The --policy path refreshes |
| // carlos.prev as an update does, so its applier reads the root |
| // participation census as the update's does (systemapply.go): on a census |
| // box it takes the root moves floor branch, where a floor step that fails |
| // fails (fix round 1, I2). Without --policy the applier is as it was. |
| -func newChannelInstall(channel string, policy []byte) channelInstall { |
| +func newChannelInstall(channel string, policy, identity []byte) channelInstall { |
| in := channelInstall{ |
| a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(), |
| dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH}, |
| @@ -153,8 +178,9 @@ func newChannelInstall(channel string, policy []byte) channelInstall { |
| fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| }, |
| - own: ownByService, |
| - policy: policy, |
| + own: ownByService, |
| + policy: policy, |
| + identity: identity, |
| } |
| if policy != nil { |
| in.a.rootMoves = rootMovesFloorIdentity |
| @@ -162,6 +188,27 @@ func newChannelInstall(channel string, policy []byte) channelInstall { |
| return in |
| } |
| +// readChannelInputs reads --policy's and --identity's files, before the lock |
| +// or any store read: both or neither (errChannelPolicyIdentity), the policy |
| +// first (readChannelPolicy), then the identity through the same hardened |
| +// open (openChannelPolicy). The identity is verified once the release key is |
| +// known (channelIdentity). With neither flag nothing is read. |
| +func readChannelInputs(policyPath, identityPath string) (policy, identity []byte, err error) { |
| + if (policyPath == "") != (identityPath == "") { |
| + return nil, nil, errChannelPolicyIdentity |
| + } |
| + if policyPath == "" { |
| + return nil, nil, nil |
| + } |
| + if policy, err = readChannelPolicy(policyPath); err != nil { |
| + return nil, nil, err |
| + } |
| + if identity, err = openChannelPolicy(identityPath, channelPolicyLimit); err != nil { |
| + return nil, nil, fmt.Errorf("the identity %s could not be read, so nothing was installed: %w", identityPath, err) |
| + } |
| + return policy, identity, nil |
| +} |
| + |
| // readChannelPolicy reads --policy's file and refuses, before anything else |
| // is read, a file that is not an armed policy at all. It is opened without |
| // following a symlink or waiting on a FIFO, and must be a regular file of |
| @@ -238,6 +285,9 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| var policy *hostfloor.Policy |
| var policyFloor hostfloor.Verified |
| if in.policy != nil { |
| + if in.identity == nil { |
| + return got, errChannelPolicyIdentity |
| + } |
| pol, f, err := channelPolicy(in.policy, pub, in.bucket) |
| if err != nil { |
| return got, errChannelPolicy |
| @@ -262,10 +312,13 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| if applied != nil && applied.Version != p.Current.Version { |
| return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| } |
| - wire, err := in.checkFloor(ctx, p, art, pub, policy, policyFloor) |
| + wire, tag, err := in.checkFloor(ctx, p, art, pub, policy, policyFloor) |
| if err != nil { |
| return got, err |
| } |
| + // The tag is the identity's: with --policy it is --identity's, which an |
| + // untagged pointer cannot name. |
| + got.Tag = tag |
| // With --policy, a box whose update has not finished (an open journal, |
| // or an update-begun marker beside the guard) is refused, as guard-arm |
| // refuses it: recovery owns carlos.prev until then (fix round 1, I1). |
| @@ -383,44 +436,87 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| // refuses: a joining box could not arm its guard without one, and a read |
| // that failed says nothing about where the floor is. With --policy |
| // (policy non-nil) the policy's verified floor is the floor instead |
| -// (policyFloorPublished). |
| -func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey, policy *hostfloor.Policy, policyFloor hostfloor.Verified) ([]byte, error) { |
| +// (policyFloorPublished), and the identity is --identity's, never one found |
| +// through the pointer's tag (channelIdentity). The tag returned is the |
| +// identity's. |
| +func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey, policy *hostfloor.Policy, policyFloor hostfloor.Verified) ([]byte, string, error) { |
| s := in.a.store |
| var floor hostfloor.Verified |
| + var wire []byte |
| + tag := p.Current.ReleaseTag |
| if policy != nil { |
| if err := policyFloorPublished(ctx, s, *policy); err != nil { |
| - return nil, err |
| + return nil, "", err |
| } |
| floor = policyFloor |
| + var err error |
| + if wire, tag, err = in.channelIdentity(ctx, s, p, art, pub); err != nil { |
| + return nil, "", err |
| + } |
| } else { |
| raw, _, err := s.Get(ctx, hostfloor.Key) |
| if errors.Is(err, os.ErrNotExist) { |
| - return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| + return nil, "", errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| } |
| if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + return nil, "", fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| } |
| auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + return nil, "", fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| } |
| floor, err = hostfloor.Verify(pub, in.bucket, raw, auth) |
| if err != nil { |
| - return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + return nil, "", fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + } |
| + key, err := release.IdentityKey(p.Current.ReleaseTag) |
| + if err != nil { |
| + return nil, "", fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version) |
| + } |
| + wire, _, err = s.Get(ctx, key) |
| + if err != nil { |
| + return nil, "", fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err) |
| } |
| } |
| - key, err := release.IdentityKey(p.Current.ReleaseTag) |
| + if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil { |
| + return nil, "", fmt.Errorf("%s (%s) is below the host floor %s or is not the release its identity names, so nothing was installed", p.Current.Version, tag, floor.Floor.MinRelease) |
| + } |
| + return wire, tag, nil |
| +} |
| + |
| +// channelIdentity is --identity's check (untagged-pointer ruling R1), once |
| +// the release key is known: the file verifies as a release identity under |
| +// it, is byte for byte the object published at release.IdentityKey of its |
| +// own tag (none published under it is its own refusal), and names what the |
| +// verified pointer names: the same version and |
| +// the same sha256 for this platform's artifact, and, when the pointer |
| +// carries a release tag, that tag. It returns the identity's bytes and tag. |
| +// Nothing is read through the pointer's tag. |
| +func (in channelInstall) channelIdentity(ctx context.Context, s release.Store, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, string, error) { |
| + id, err := release.VerifySystemIdentity(pub, in.identity) |
| if err != nil { |
| - return nil, fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version) |
| + return nil, "", errChannelIdentity |
| } |
| - wire, _, err := s.Get(ctx, key) |
| + tag := id.Current.ReleaseTag |
| + key, err := release.IdentityKey(tag) |
| if err != nil { |
| - return nil, fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err) |
| + return nil, "", errChannelIdentity |
| } |
| - if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil { |
| - return nil, fmt.Errorf("%s (%s) is below the host floor %s or is not the release its identity names, so nothing was installed", p.Current.Version, p.Current.ReleaseTag, floor.Floor.MinRelease) |
| + published, _, err := s.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, "", fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", tag, err) |
| + } |
| + if err != nil { |
| + return nil, "", fmt.Errorf("no release identity is published for %s, so nothing was installed", tag) |
| + } |
| + if !bytes.Equal(published, in.identity) { |
| + return nil, "", fmt.Errorf("the --identity file is not the identity published for %s, so nothing was installed", tag) |
| + } |
| + router, ok := id.Current.Artifacts[in.a.platform] |
| + if !ok || router.SHA256 != art.SHA256 || id.Current.Version != p.Current.Version || p.Current.ReleaseTag != "" && p.Current.ReleaseTag != tag { |
| + return nil, "", fmt.Errorf("the --identity file names %s (%s), not the release the %s channel names, so nothing was installed", id.Current.Version, tag, in.a.channel) |
| } |
| - return wire, nil |
| + return in.identity, tag, nil |
| } |
| // policyFloorPublished checks --policy against what the bucket holds: no |
diff --git a/internal/host/installchannel_identity_test.go b/internal/host/installchannel_identity_test.go| new file mode 100644 |
| index 000000000..0387f0d78 |
| --- /dev/null |
| +++ b/internal/host/installchannel_identity_test.go |
| @@ -0,0 +1,488 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// install-channel --policy --identity (claim trial, untagged-pointer ruling |
| +// R1). The channel pointer `carlos system update` writes before a host floor |
| +// is published carries no release tag and no moves protocol: the console's |
| +// door resolves the release, mirrors its artifact and calls |
| +// release.PromoteSystem with {Version, Source "release:<tag>", Artifacts} |
| +// (reserveHostUpdate tags a release only once a floor is published, which in |
| +// the trial is step 7). install-channel then finds the release identity |
| +// through --identity, the file claims run publish-identity wrote, and never |
| +// through the pointer's tag. The console's own test, |
| +// TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, pins the pointer's |
| +// shape these fixtures promote. |
| + |
| +// promoteAsSystemUpdate moves stable to version with the pointer shape `carlos |
| +// system update` writes before a floor is published. It does not run the |
| +// door: it calls f.store.Put for the artifact (scaffolding) and |
| +// release.PromoteSystem in-process with the untagged release {Version, |
| +// Source "release:<version>", Artifacts}. The shape is tied to the console's |
| +// door only through TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor. |
| +func (f *channelFixture) promoteAsSystemUpdate(t *testing.T, version string, body []byte) { |
| + t.Helper() |
| + ctx := context.Background() |
| + key := release.SystemArtifactKey(version, "linux-arm64") |
| + if _, err := f.store.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) |
| + } |
| + rel := release.SystemRelease{Version: version, Source: "release:" + version, Artifacts: map[string]release.SystemArtifact{ |
| + "linux-arm64": {Key: key, SHA256: hostfloor.Digest(body)}}} |
| + if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), release.SystemComponentRouter, "stable", rel, release.SystemPromoteOptions{Actor: "operator@example.test"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _, err := release.GetSystemPointer(ctx, f.store, f.pub, release.SystemComponentRouter, "stable") |
| + if err != nil || p.Current.ReleaseTag != "" || p.Current.MovesProtocol != 0 { |
| + t.Fatalf("the promote is not the untagged pointer the trial found: %+v %v", p, err) |
| + } |
| +} |
| + |
| +// identityRelease is the release claims run publish-identity signs for tag |
| +// (claimactivation's identityRelease): version and tag both the tag, moves |
| +// protocol 1, the router artifact from the signed sums and a console |
| +// artifact. |
| +func identityRelease(tag string, router []byte) release.SystemRelease { |
| + return release.SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1, |
| + Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(tag, "linux-arm64"), SHA256: hostfloor.Digest(router)}}, |
| + ConsoleArtifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: "bootstrap/console/" + tag + "/carlos-linux-arm64", SHA256: hostfloor.Digest([]byte("console " + tag))}}} |
| +} |
| + |
| +// publishIdentity publishes rel as publish-identity does (SignSystemIdentity, |
| +// then PublishSystemIdentity create-only) and returns the published bytes, |
| +// which are what its --out file holds. |
| +func (f *channelFixture) publishIdentity(t *testing.T, rel release.SystemRelease) []byte { |
| + t.Helper() |
| + ctx := context.Background() |
| + wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PublishSystemIdentity(ctx, f.store, f.pub, wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + key, _ := release.IdentityKey(rel.ReleaseTag) |
| + stored, _, err := f.store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return stored |
| +} |
| + |
| +// untaggedUpdatedBox is the trial's leo on v0.28.3: installed at v1.3.0, |
| +// updated by the real apply to v1.4.0 from the untagged pointer `carlos |
| +// system update` wrote, so applied.json names v1.4.0, carlos is its artifact |
| +// and carlos.prev v1.3.0, with no channel identity. v1.4.0's identity is |
| +// published as publish-identity publishes it, and returned. |
| +func untaggedUpdatedBox(t *testing.T) (f *channelFixture, old, identity []byte) { |
| + t.Helper() |
| + f = &channelFixture{applyFixture: newApplyFixture(t, "stable", "v1.3.0")} |
| + old, err := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| + applyChannelUpdateWith(t, f, "v1.4.0", f.body, func() { f.promoteAsSystemUpdate(t, "v1.4.0", f.body) }) |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, old) { |
| + t.Fatalf("the update left %q and %q", cur, prev) |
| + } |
| + identity = f.publishIdentity(t, identityRelease("v1.4.0", f.body)) |
| + f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| + f.fetches++ |
| + return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| + }, own: func(path string) error { |
| + f.owned = append(f.owned, path) |
| + return nil |
| + }} |
| + return f, old, identity |
| +} |
| + |
| +// The trial's 6.1a on an untagged pointer: refused before R1 for want of a |
| +// release tag, armed with --identity. |
| +func TestInstallChannelPolicyIdentityArmsAnUntaggedUpdatedBox(t *testing.T) { |
| + f, old, identity := untaggedUpdatedBox(t) |
| + ctx := context.Background() |
| + // Without --policy, exactly as before (ruling 6): no floor, the loop. |
| + before := boxFiles(t, f) |
| + if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" { |
| + t.Fatalf("without --policy: %v", err) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + applied, _ := os.ReadFile(f.a.file("applied.json")) |
| + seen, _ := os.ReadFile(f.a.file("seen.json")) |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = identity |
| + got, err := f.in.run(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The tag the run names is the identity's: the pointer has none. |
| + if !got.Replaced || got.Already || got.Version != "v1.4.0" || got.Tag != "v1.4.0" { |
| + t.Fatalf("installed %+v", got) |
| + } |
| + if line := installedLine(got); line != "replaced the rollback copy carlos.prev with v1.4.0 (v1.4.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| + t.Fatalf("line %q", line) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || bytes.Equal(prev, old) { |
| + t.Fatalf("slots hold %q and %q", cur, prev) |
| + } |
| + if f.fetches != 0 || !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatalf("%d fetches, record %v", f.fetches, prevRecorded(f.a.optDir, "v1.4.0")) |
| + } |
| + if a, _ := os.ReadFile(f.a.file("applied.json")); !bytes.Equal(a, applied) { |
| + t.Fatalf("applied.json changed: %s", a) |
| + } |
| + if s, _ := os.ReadFile(f.a.file("seen.json")); !bytes.Equal(s, seen) { |
| + t.Fatalf("seen.json changed: %s", s) |
| + } |
| + // What is kept for guard-arm is the --identity file, byte for byte, and |
| + // guard-arm's checks pass with it: its inputs, and both slots. |
| + held, err := os.ReadFile(channelIdentityPath(f.a.optDir)) |
| + if err != nil || !bytes.Equal(held, identity) { |
| + t.Fatalf("kept identity %v", err) |
| + } |
| + _, floor, err := guardArmInputs(f.in.policy, held, f.pub, "bucket", "v1.4.0", "linux-arm64", hostfloor.Digest(f.body)) |
| + if err != nil { |
| + t.Fatalf("guard-arm's inputs: %v", err) |
| + } |
| + for _, slot := range []string{systemBinaryPath(f.a.optDir), systemPrevPath(f.a.optDir)} { |
| + b, _ := os.ReadFile(slot) |
| + if err := hostfloor.CheckImage(f.pub, floor, held, "v1.4.0", "linux-arm64", hostfloor.Digest(b)); err != nil { |
| + t.Fatalf("guard-arm would refuse %s: %v", slot, err) |
| + } |
| + } |
| + again, err := f.in.run(ctx) |
| + if err != nil || !again.Already || again.Replaced || f.fetches != 0 { |
| + t.Fatalf("second run %+v %v", again, err) |
| + } |
| + if line := installedLine(again); line != "v1.4.0 (v1.4.0) from the stable channel is already installed" { |
| + t.Fatalf("again %q", line) |
| + } |
| + // Once the floor is published from the same plan, the run still passes. |
| + if err := hostfloor.Publish(ctx, f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("published: %+v %v", got, err) |
| + } |
| +} |
| + |
| +// A fresh box on an untagged pointer: --identity names the release, and the |
| +// install fetches the pointer's artifact into both slots. |
| +func TestInstallChannelPolicyIdentityInstallsAFreshBoxOnAnUntaggedPointer(t *testing.T) { |
| + f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "bootstrap")} |
| + if err := os.Remove(systemBinaryPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| + f.promoteAsSystemUpdate(t, "v1.4.0", f.body) |
| + identity := f.publishIdentity(t, identityRelease("v1.4.0", f.body)) |
| + f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| + f.fetches++ |
| + return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| + }, own: func(string) error { return nil }} |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = identity |
| + got, err := f.in.run(context.Background()) |
| + if err != nil || got.Already || got.Replaced || got.Tag != "v1.4.0" { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + if line := installedLine(got); line != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" { |
| + t.Fatalf("line %q", line) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || f.fetches != 1 { |
| + t.Fatalf("slots %q %q after %d fetches", cur, prev, f.fetches) |
| + } |
| + if held, _ := os.ReadFile(channelIdentityPath(f.a.optDir)); !bytes.Equal(held, identity) { |
| + t.Fatal("the kept identity is not the --identity file") |
| + } |
| +} |
| + |
| +// Every refusal of --identity leaves the box as it was, fetches nothing and |
| +// keeps no identity. |
| +func TestInstallChannelPolicyIdentityRefusals(t *testing.T) { |
| + _, otherKey, _ := ed25519.GenerateKey(nil) |
| + const notSigned = "the --identity file is not a release identity signed for this deployment, so nothing was installed" |
| + const notPublished = "the --identity file is not the identity published for v1.4.0, so nothing was installed" |
| + for _, c := range []struct { |
| + name string |
| + setup func(t *testing.T, f *channelFixture, identity []byte) []byte |
| + want string |
| + }{ |
| + {"no --identity", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + return nil |
| + }, "--policy and --identity go together, so nothing was installed"}, |
| + {"not an identity", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + return f.in.policy |
| + }, notSigned}, |
| + {"signed by another key", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + wire, err := release.SignSystemIdentity(release.RootSigner(otherKey), identityRelease("v1.4.0", f.body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| + }, notSigned}, |
| + {"a channel pointer, not an identity", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + wire, _, err := f.store.Get(context.Background(), release.SystemPointerKey("router", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| + }, notSigned}, |
| + {"another identity under its tag", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + rel := identityRelease("v1.4.0", f.body) |
| + rel.ConsoleArtifacts["linux-arm64"] = release.SystemArtifact{Key: rel.ConsoleArtifacts["linux-arm64"].Key, SHA256: hostfloor.Digest([]byte("another console"))} |
| + wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| + }, notPublished}, |
| + {"re-formatted", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + var b bytes.Buffer |
| + if err := json.Compact(&b, identity); err != nil { |
| + t.Fatal(err) |
| + } |
| + reformatted := append([]byte(" "), b.Bytes()...) |
| + if _, err := release.VerifySystemIdentity(f.pub, reformatted); err != nil || bytes.Equal(reformatted, identity) { |
| + t.Fatalf("the re-formatted identity: %v", err) |
| + } |
| + return reformatted |
| + }, notPublished}, |
| + {"no identity published under its tag", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + key, _ := release.IdentityKey("v1.4.0") |
| + if err := f.store.Delete(context.Background(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + return identity |
| + }, "no release identity is published for v1.4.0, so nothing was installed"}, |
| + {"signed for a tag never published", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), identityRelease("v1.4.9", f.body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| + }, "no release identity is published for v1.4.9, so nothing was installed"}, |
| + {"another release's identity", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + return f.publishIdentity(t, identityRelease("v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"))) |
| + }, "the --identity file names v1.5.0 (v1.5.0), not the release the stable channel names, so nothing was installed"}, |
| + {"the same artifact under another version", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + return f.publishIdentity(t, identityRelease("v1.4.1", f.body)) |
| + }, "the --identity file names v1.4.1 (v1.4.1), not the release the stable channel names, so nothing was installed"}, |
| + {"another artifact under the release", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + key, _ := release.IdentityKey("v1.4.0") |
| + if err := f.store.Delete(context.Background(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f.publishIdentity(t, identityRelease("v1.4.0", []byte("#!/bin/sh\necho carlos v1.4.0 rebuilt\n"))) |
| + }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"}, |
| + {"no artifact for this platform", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + key, _ := release.IdentityKey("v1.4.0") |
| + if err := f.store.Delete(context.Background(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + rel := identityRelease("v1.4.0", f.body) |
| + rel.Artifacts = map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.4.0", "linux-amd64"), SHA256: hostfloor.Digest(f.body)}} |
| + return f.publishIdentity(t, rel) |
| + }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"}, |
| + {"a pointer tagged with another release", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + // The same version and artifact, but the pointer names another tag. |
| + rel := release.SystemRelease{Version: "v1.4.0", Source: "release:v1.4.0", ReleaseTag: "v1.4.1", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{ |
| + "linux-arm64": {Key: release.SystemArtifactKey("v1.4.0", "linux-arm64"), SHA256: hostfloor.Digest(f.body)}}} |
| + if _, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + return identity |
| + }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"}, |
| + {"an unreadable identity object", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + open := f.a.openStore |
| + f.a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| + s, k, err := open(ctx) |
| + return identityUnreadable{s}, k, err |
| + } |
| + return identity |
| + }, "the release identity for v1.4.0 could not be read, so nothing was installed: door restarting"}, |
| + {"below the policy's floor", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"}) |
| + return identity |
| + }, "v1.4.0 (v1.4.0) is below the host floor v2.0.0 or is not the release its identity names, so nothing was installed"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + f, _, identity := untaggedUpdatedBox(t) |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = c.setup(t, f, identity) |
| + before := boxFiles(t, f) |
| + _, err := f.in.run(context.Background()) |
| + if err == nil || err.Error() != c.want { |
| + t.Fatalf("err %v, want %q", err, c.want) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + if f.fetches != 0 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a refused run kept an identity: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A pointer that does carry a release tag (as the console writes it once a |
| +// floor is published) passes when the tag names the --identity's release. |
| +func TestInstallChannelPolicyIdentityAcceptsAPointerTaggedWithIt(t *testing.T) { |
| + f, _, identity := untaggedUpdatedBox(t) |
| + ctx := context.Background() |
| + rel := release.SystemRelease{Version: "v1.4.0", Source: "release:v1.4.0", ReleaseTag: "v1.4.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{ |
| + "linux-arm64": {Key: release.SystemArtifactKey("v1.4.0", "linux-arm64"), SHA256: hostfloor.Digest(f.body)}}} |
| + if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = identity |
| + if got, err := f.in.run(ctx); err != nil || !got.Replaced || got.Tag != "v1.4.0" { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + if held, _ := os.ReadFile(channelIdentityPath(f.a.optDir)); !bytes.Equal(held, identity) { |
| + t.Fatal("the kept identity is not the --identity file") |
| + } |
| +} |
| + |
| +// The flags: --policy and --identity together or not at all, both read up |
| +// front through the hardened open; without either nothing is read. |
| +func TestInstallChannelInputsAreReadUpFront(t *testing.T) { |
| + dir := t.TempDir() |
| + policyPath, identityPath := filepath.Join(dir, "policy.json"), filepath.Join(dir, "identity.json") |
| + if p, i, err := readChannelInputs("", ""); p != nil || i != nil || err != nil { |
| + t.Fatalf("no flags: %v %v %v", p, i, err) |
| + } |
| + for _, c := range [][2]string{{policyPath, ""}, {"", identityPath}} { |
| + if _, _, err := readChannelInputs(c[0], c[1]); err == nil || err.Error() != "--policy and --identity go together, so nothing was installed" { |
| + t.Fatalf("%q: %v", c, err) |
| + } |
| + } |
| + raw := signedPolicyBytes(t) |
| + if err := os.WriteFile(policyPath, raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := readChannelInputs(policyPath, identityPath); err == nil || !strings.HasPrefix(err.Error(), "the identity "+identityPath+" could not be read, so nothing was installed: ") { |
| + t.Fatalf("missing identity: %v", err) |
| + } |
| + if err := os.WriteFile(identityPath, []byte("identity bytes"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, i, err := readChannelInputs(policyPath, identityPath) |
| + if err != nil || !bytes.Equal(p, raw) || string(i) != "identity bytes" { |
| + t.Fatalf("both: %v", err) |
| + } |
| + // The policy is still read and checked first. |
| + if err := os.WriteFile(policyPath, []byte(`{"mode":"unpublished"}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := readChannelInputs(policyPath, filepath.Join(dir, "missing.json")); err == nil || err.Error() != "the --policy file is not an armed policy signed for this deployment, so nothing was installed" { |
| + t.Fatalf("a bad policy: %v", err) |
| + } |
| +} |
| + |
| +// identityUnreadable fails every read of a release identity object. |
| +type identityUnreadable struct{ release.Store } |
| + |
| +func (s identityUnreadable) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasPrefix(key, "control/system/router/releases/") { |
| + return nil, "", errors.New("door restarting") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// identityOf is the release identity published for tag: what claims run |
| +// publish-identity's --out file holds. |
| +func (f *channelFixture) identityOf(t *testing.T, tag string) []byte { |
| + t.Helper() |
| + key, err := release.IdentityKey(tag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := f.store.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +// installChannelCLIEnv makes this test binary run RunSystemInstallChannel |
| +// with the arguments it holds (one per line), so its exits can be observed. |
| +const installChannelCLIEnv = "CARLOS_TEST_INSTALL_CHANNEL_ARGS" |
| + |
| +// TestInstallChannelCLIChild is the child runInstallChannelCLI starts; run |
| +// without installChannelCLIEnv it does nothing. |
| +func TestInstallChannelCLIChild(t *testing.T) { |
| + raw, ok := os.LookupEnv(installChannelCLIEnv) |
| + if !ok { |
| + t.Skip("only as runInstallChannelCLI's child") |
| + } |
| + var args []string |
| + if raw != "" { |
| + args = strings.Split(raw, "\n") |
| + } |
| + RunSystemInstallChannel(args) |
| + os.Exit(0) |
| +} |
| + |
| +// runInstallChannelCLI runs `carlos system install-channel args...` in a |
| +// child of this test binary, not as root, and returns its combined output |
| +// and exit code. |
| +func runInstallChannelCLI(t *testing.T, args ...string) (string, int) { |
| + t.Helper() |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestInstallChannelCLIChild$") |
| + cmd.Env = append(os.Environ(), installChannelCLIEnv+"="+strings.Join(args, "\n"), "CARLOS_SYSTEM_CHANNEL=") |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + code := 0 |
| + if ee, ok := err.(*exec.ExitError); ok { |
| + code = ee.ExitCode() |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(out), code |
| +} |
| + |
| +// The command's both-or-neither check (untagged-pointer R1, platform-00): |
| +// --policy alone and --identity alone are refused with exit 2 and the pair |
| +// line, before the root check and before any file is read; -h lists both |
| +// flags. |
| +func TestRunSystemInstallChannelTakesPolicyAndIdentityTogether(t *testing.T) { |
| + missing := filepath.Join(t.TempDir(), "absent.json") |
| + for _, args := range [][]string{{"--policy", missing}, {"--identity", missing}} { |
| + out, code := runInstallChannelCLI(t, args...) |
| + if code != 2 || strings.TrimSpace(out) != "--policy and --identity go together, so nothing was installed" { |
| + t.Fatalf("%v: exit %d, %q", args, code, out) |
| + } |
| + } |
| + out, code := runInstallChannelCLI(t, "-h") |
| + if code != 0 || !strings.HasPrefix(out, installChannelUsage) || !strings.Contains(out, " --identity <string> "+installChannelIdentityFlag+"\n") || !strings.Contains(out, " --policy <string> "+installChannelPolicyFlag+"\n") { |
| + t.Fatalf("-h: exit %d\n%s", code, out) |
| + } |
| + if os.Geteuid() != 0 { |
| + // With neither flag, unchanged: the root check is the first refusal. |
| + if out, code := runInstallChannelCLI(t); code != 1 || strings.TrimSpace(out) != installChannelNotRoot { |
| + t.Fatalf("no flags: exit %d, %q", code, out) |
| + } |
| + } |
| +} |
diff --git a/internal/host/installchannel_linux.go b/internal/host/installchannel_linux.go| index a4d468b6d..4912789df 100644 |
| --- a/internal/host/installchannel_linux.go |
| +++ b/internal/host/installchannel_linux.go |
| @@ -38,10 +38,11 @@ func ownDir(path string, uid, gid int) error { |
| return unix.Fchown(fd, uid, gid) |
| } |
| -// openChannelPolicy reads --policy's file: opened with O_NOFOLLOW (a |
| -// symlink is refused, never followed) and O_NONBLOCK (a FIFO never blocks |
| -// the open), fstat'd to be a regular file of at most limit bytes, and only |
| -// then read, from the descriptor, up to limit. |
| +// openChannelPolicy reads each operator-staged file install-channel --policy |
| +// takes, the --policy file and the --identity file (readChannelInputs): |
| +// opened with O_NOFOLLOW (a symlink is refused, never followed) and |
| +// O_NONBLOCK (a FIFO never blocks the open), fstat'd to be a regular file of |
| +// at most limit bytes, and only then read, from the descriptor, up to limit. |
| func openChannelPolicy(path string, limit int64) ([]byte, error) { |
| fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| if err != nil { |
diff --git a/internal/host/installchannel_policy_linux_test.go b/internal/host/installchannel_policy_linux_test.go| index a81b329e2..e79d5bef0 100644 |
| --- a/internal/host/installchannel_policy_linux_test.go |
| +++ b/internal/host/installchannel_policy_linux_test.go |
| @@ -44,3 +44,39 @@ func TestInstallChannelPolicyOpenRefusesWhatIsNotAPolicyFile(t *testing.T) { |
| t.Fatalf("the real file: %v", err) |
| } |
| } |
| + |
| +// R1, Linux: --identity's file goes through --policy's hardened open, so a |
| +// symlink, a FIFO, a directory and an oversized file are refused before |
| +// anything is read. |
| +func TestInstallChannelIdentityOpenRefusesWhatIsNotAnIdentityFile(t *testing.T) { |
| + dir := t.TempDir() |
| + policy := filepath.Join(dir, "policy.json") |
| + if err := os.WriteFile(policy, signedPolicyBytes(t), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw0 := []byte(`{"payload":"","signature":""}`) |
| + real := filepath.Join(dir, "real.json") |
| + if err := os.WriteFile(real, raw0, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(dir, "link.json") |
| + if err := os.Symlink(real, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + fifo := filepath.Join(dir, "fifo.json") |
| + if err := syscall.Mkfifo(fifo, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + big := filepath.Join(dir, "big.json") |
| + if err := os.WriteFile(big, append(raw0, bytes.Repeat([]byte(" "), channelPolicyLimit)...), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{link, fifo, dir, big} { |
| + if _, _, err := readChannelInputs(policy, path); err == nil || !strings.HasPrefix(err.Error(), "the identity "+path+" could not be read, so nothing was installed: ") { |
| + t.Fatalf("%s: %v", path, err) |
| + } |
| + } |
| + if _, got, err := readChannelInputs(policy, real); err != nil || !bytes.Equal(got, raw0) { |
| + t.Fatalf("the real file: %v", err) |
| + } |
| +} |
diff --git a/internal/host/installchannel_policy_rootrunner_linux_test.go b/internal/host/installchannel_policy_rootrunner_linux_test.go| index 719b7cd4b..c758a7972 100644 |
| --- a/internal/host/installchannel_policy_rootrunner_linux_test.go |
| +++ b/internal/host/installchannel_policy_rootrunner_linux_test.go |
| @@ -48,6 +48,7 @@ func TestRootInstallChannelPolicyCensusBox(t *testing.T) { |
| return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| }, own: func(string) error { return nil }} |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| got, err := f.in.run(context.Background()) |
| if err != nil || !got.Replaced { |
| t.Fatalf("install-channel --policy on a census box: %+v %v", got, err) |
diff --git a/internal/host/installchannel_policy_test.go b/internal/host/installchannel_policy_test.go| index 3cad35a6f..9256ea5cf 100644 |
| --- a/internal/host/installchannel_policy_test.go |
| +++ b/internal/host/installchannel_policy_test.go |
| @@ -55,16 +55,24 @@ func updatedChannelBox(t *testing.T) (f *channelFixture, old []byte) { |
| // fetch and request (systemLeg), then the real apply. |
| func applyChannelUpdate(t *testing.T, f *channelFixture, version string, body []byte, identity bool) { |
| t.Helper() |
| - ctx := context.Background() |
| - if identity { |
| - f.publish(t, version, body) |
| - } else { |
| + applyChannelUpdateWith(t, f, version, body, func() { |
| + if identity { |
| + f.publish(t, version, body) |
| + return |
| + } |
| rel := release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{ |
| "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hostfloor.Digest(body)}}} |
| - if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + if _, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| t.Fatal(err) |
| } |
| - } |
| + }) |
| +} |
| + |
| +// applyChannelUpdateWith is applyChannelUpdate with the promote given. |
| +func applyChannelUpdateWith(t *testing.T, f *channelFixture, version string, body []byte, promote func()) { |
| + t.Helper() |
| + ctx := context.Background() |
| + promote() |
| p, _, err := release.GetSystemPointer(ctx, f.store, f.pub, release.SystemComponentRouter, "stable") |
| if err != nil { |
| t.Fatal(err) |
| @@ -154,6 +162,7 @@ func TestInstallChannelPolicyArmsAnUpdatedBoxAtTheChannelHead(t *testing.T) { |
| applied, _ := os.ReadFile(f.a.file("applied.json")) |
| seen, _ := os.ReadFile(f.a.file("seen.json")) |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| got, err := f.in.run(ctx) |
| if err != nil { |
| t.Fatal(err) |
| @@ -241,9 +250,11 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }{ |
| {"signed by another key", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, otherKey, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| {"for another bucket", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "another-bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| {"re-indented", func(t *testing.T, f *channelFixture) { |
| var b bytes.Buffer |
| @@ -251,12 +262,15 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| t.Fatal(err) |
| } |
| f.in.policy = b.Bytes() |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| {"unpublished mode", func(t *testing.T, f *channelFixture) { |
| f.in.policy = []byte(`{"schema":1,"deployment_bucket":"bucket","mode":"unpublished"}`) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| {"an armed guard", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| @@ -266,19 +280,23 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "this box's host lifecycle guard is armed, so carlos system update installs its releases"}, |
| {"another release", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| f.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n")) |
| }, "this box already runs v1.4.0, so carlos system update installs its releases"}, |
| {"below the floor", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| }, "v1.4.0 (v1.4.0) is below the host floor v2.0.0 or is not the release its identity names, so nothing was installed"}, |
| {"another floor published", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}); err != nil { |
| t.Fatal(err) |
| } |
| }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| {"another floor half published", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| _, auth, err := hostfloor.Authorize(release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}) |
| if err != nil { |
| t.Fatal(err) |
| @@ -289,6 +307,7 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| {"an unreadable floor", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| open := f.a.openStore |
| f.a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| s, k, err := open(ctx) |
| @@ -297,12 +316,14 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "the host floor could not be read, so nothing was installed: door restarting"}, |
| {"a foreign carlos", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1.4.0 rebuilt\n"), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| }, "%opt%/carlos holds a build this command did not install, so nothing was installed"}, |
| {"a symlinked carlos.prev", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| elsewhere := filepath.Join(t.TempDir(), "carlos.prev") |
| if err := os.Rename(systemPrevPath(f.a.optDir), elsewhere); err != nil { |
| t.Fatal(err) |
| @@ -313,12 +334,14 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "%opt%/carlos.prev holds a build this command did not install, so nothing was installed"}, |
| {"an unreadable install record", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.WriteFile(f.a.file("applied.json"), []byte("{"), 0o644); err != nil { |
| t.Fatal(err) |
| } |
| }, "this box's install record could not be read, so nothing was installed: unexpected end of JSON input"}, |
| {"a carlos.prev that is a directory", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { |
| t.Fatal(err) |
| } |
| @@ -328,6 +351,7 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "%opt%/carlos.prev holds a build this command did not install, so nothing was installed"}, |
| {"an unreadable guard", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| @@ -337,12 +361,14 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "this box's host lifecycle guard could not be read, so nothing was installed: hostfloor: authenticated floor required"}, |
| {"an open update journal", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.WriteFile(f.a.file("journal.json"), []byte("{}"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| }, "an update on this box has not finished, so nothing was installed: %opt%/system/router/journal.json is open; let the update finish or recover first"}, |
| {"an update-begun marker", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| @@ -352,6 +378,7 @@ func TestInstallChannelPolicyRefusals(t *testing.T) { |
| }, "an update on this box has not finished, so nothing was installed: %opt%/system/guard/" + updateBegunFile + " is present; let the update finish or recover first"}, |
| {"a census that cannot be read", func(t *testing.T, f *channelFixture) { |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| f.a.rootMoves = func() (rootFloorIdentity, bool, error) { |
| return rootFloorIdentity{}, true, errors.New("census unreadable") |
| } |
| @@ -388,6 +415,7 @@ func TestInstallChannelPolicyInstallsAFreshBoxBeforeTheFloor(t *testing.T) { |
| t.Fatalf("without --policy: %v", err) |
| } |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| got, err := f.in.run(ctx) |
| if err != nil || got.Already || got.Replaced { |
| t.Fatalf("%+v %v", got, err) |
| @@ -408,6 +436,7 @@ func TestInstallChannelPolicyInstallsAFreshBoxBeforeTheFloor(t *testing.T) { |
| // A hand-rolled slot is still refused with --policy. |
| g := newChannelFixture(t, "") |
| g.in.policy = signedPolicy(t, g.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + g.in.identity = g.identityOf(t, "v1.4.0") |
| if err := os.WriteFile(systemPrevPath(g.a.optDir), []byte("#!/bin/sh\necho hand rolled\n"), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| @@ -437,6 +466,7 @@ func TestInstallChannelPolicyFailsWhenThePrevRecordCannotBeWritten(t *testing.T) |
| f, _ := updatedChannelBox(t) |
| ctx := context.Background() |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| restore := failPrevRecord(t) |
| got, err := f.in.run(ctx) |
| if err == nil || err.Error() != prevRecordRefusal || got.Replaced || got.Already { |
| @@ -479,6 +509,7 @@ func TestInstallChannelPolicyCompletesACutOffRefreshOverAMatchingIdentity(t *tes |
| f := newChannelFixture(t, "") |
| ctx := context.Background() |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if got, err := f.in.run(ctx); err != nil || got.Already || got.Replaced { |
| t.Fatalf("install A: %+v %v", got, err) |
| } |
| @@ -564,6 +595,7 @@ func TestInstallChannelPolicyFillsAnAbsentPrev(t *testing.T) { |
| t.Fatal(err) |
| } |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| got, err := f.in.run(context.Background()) |
| if err != nil || got.Replaced || got.Already { |
| t.Fatalf("%+v %v", got, err) |
| @@ -587,6 +619,7 @@ func TestInstallChannelPolicyTakesTheCensusBranch(t *testing.T) { |
| } |
| f, _ := updatedChannelBox(t) |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| f.a.rootMoves = func() (rootFloorIdentity, bool, error) { |
| return rootFloorIdentity{Deployment: "bucket", Box: "box", Enrollment: "en"}, true, nil |
| } |
| @@ -597,10 +630,10 @@ func TestInstallChannelPolicyTakesTheCensusBranch(t *testing.T) { |
| if after := boxFiles(t, f); !equalFiles(before, after) { |
| t.Fatalf("changed: %v", diffFiles(before, after)) |
| } |
| - if newChannelInstall("stable", nil).a.rootMoves != nil { |
| + if newChannelInstall("stable", nil, nil).a.rootMoves != nil { |
| t.Fatal("the no-policy applier reads the census") |
| } |
| - if newChannelInstall("stable", []byte("{}")).a.rootMoves == nil { |
| + if newChannelInstall("stable", []byte("{}"), []byte("{}")).a.rootMoves == nil { |
| t.Fatal("the --policy applier does not read the census") |
| } |
| } |
| @@ -613,6 +646,7 @@ func abaReturn(t *testing.T) *channelFixture { |
| t.Helper() |
| f := newChannelFixture(t, "") |
| f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.in.identity = f.identityOf(t, "v1.4.0") |
| if got, err := f.in.run(context.Background()); err != nil || got.Already || got.Replaced { |
| t.Fatalf("install A: %+v %v", got, err) |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 201d71573..13323fdb4 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -896,6 +896,35 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay claim-untagged-pointer (install-channel --policy takes |
| + // --identity, the published release identity, because the channel |
| + // pointer carlos system update writes before a floor is published |
| + // carries no release tag; controller ruling R1), loaded last, under |
| + // census-ruling-claim-untagged-pointer (platform-1e, accepted as |
| + // proposed; docs/superpowers/notes/census-proposal-claim-untagged-pointer.md): |
| + // channelInstall.run and openChannelPolicy re-pinned with corrected |
| + // bases at unchanged digests, sites and class F,J4, and the internal/host |
| + // envelope re-pinned. It adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-untagged-pointer-fix1 (review fix round 1: a |
| + // missing identity object gets its own refusal; openChannelPolicy's |
| + // comment names both files), loaded last. Envelope only. Under |
| + // census-ruling-claim-untagged-pointer-fix1 (platform-1e, accepted as |
| + // proposed; docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, |
| + // "Fix round 1"). The seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-untagged-pointer-fix2 (Paul's copy approval |
| + // applied: the re-approved RHB-C19 usage), loaded last. Envelope only. |
| + // Under census-ruling-claim-untagged-pointer-fix2 (platform-1e, accepted |
| + // as proposed; docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, |
| + // "Fix round 2"). The seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| seal = "53b3cbb6f46d657ce32f49dad84dae79316c80da949ccf37273833eef5c5b2d7" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv| new file mode 100644 |
| index 000000000..3d00023e5 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay claim-untagged-pointer-fix1 (review fix round 1: platform-00's requests, as ruled by the controller), loaded LAST, after claim-untagged-pointer. RULED by platform-1e 2026-10-18 as census-ruling-claim-untagged-pointer-fix1: ACCEPTED as proposed (envelope-only re-pin of internal/host; the seal unchanged, no 9B8 entry); docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, section "Fix round 1". |
| +# Envelope only. channelIdentity gives a missing identity object its own refusal ("no release identity is published for <tag>, so nothing was installed"); openChannelPolicy's doc comment names both files it reads. No root-read unit's digest or sites change: channelInstall.run and openChannelPolicy stand at census-ruling-claim-untagged-pointer's rows, refreshPrev at its ruled row. The seal stands; the 9B8 inventory is unchanged. |
| +@coverage internal/host 846d51942aff38963e0a87ce178024297208125a5eca39816d2c6d1d50d741a5 |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv| new file mode 100644 |
| index 000000000..d4ed05cb0 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay claim-untagged-pointer-fix2 (Paul's copy approval applied: installChannelUsage carries the re-approved RHB-C19 usage, "[--policy FILE --identity FILE]"), loaded LAST, after claim-untagged-pointer-fix1. RULED by platform-1e 2026-10-18 as census-ruling-claim-untagged-pointer-fix2: ACCEPTED as proposed (envelope-only re-pin of internal/host; the seal unchanged, no 9B8 entry); docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, section "Fix round 2". |
| +# Envelope only. The one source change in internal/host is the usage constant's text. No root-read unit's digest or sites change: channelInstall.run and openChannelPolicy stand at census-ruling-claim-untagged-pointer's rows. The seal stands; the 9B8 inventory is unchanged. |
| +@coverage internal/host 76aa9108a02c4b47cf6d2cdc7434c83e1048e45eb23406033964b4a0b93c9bae |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv| new file mode 100644 |
| index 000000000..e2106fe44 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv |
| @@ -0,0 +1,7 @@ |
| +# Census overlay claim-untagged-pointer (claim trial: install-channel --policy also takes --identity FILE, the published release identity, because the channel pointer carlos system update writes before a floor is published carries no release tag; controller ruling R1), loaded LAST, after claim-prev-rehearsal-fix2. RULED by platform-1e 2026-10-18 as census-ruling-claim-untagged-pointer: every row ACCEPTED as proposed (channelInstall.run and openChannelPolicy re-pinned with corrected bases at unchanged digests, sites and class F,J4; internal/host envelope re-pinned; the seal stands, no 9B8 entry); docs/superpowers/notes/census-proposal-claim-untagged-pointer.md. |
| +# Without --policy nothing changes (ruling 6): channelInstall.run's digest, sites and class F,J4 are unchanged and only its basis is corrected; refreshPrev, slotState, var openat2 and removeBeneath are unchanged and stay their ruled rows. readChannelInputs, channelInstall.channelIdentity and the reworked checkFloor have no read site of their own (envelope only): the identity is read once through openChannelPolicy, verified under CARLOS_RELEASE_PUBKEY, compared byte for byte with the bucket's object at release.IdentityKey of its own tag, and matched to the verified pointer's version and this platform's artifact sha256 (and its tag, if it carries one). |
| +# openChannelPolicy keeps its digest, sites and class F,J4; its basis is corrected because it now also reads the operator-staged --identity file, with the same hardened open, before the apply lock or any store read. |
| +# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches any changed unit. The 9B8 inventory is unchanged: install-channel writes only local files, and nothing here writes to a bucket. |
| +@coverage internal/host 2dcf104b5bdc76cc52834f0bb88adf2f2314ae71bd5856a0de8d7b310803026c |
| +internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-claim-untagged-pointer;identity=uid 0 at a fresh box's start-up, and with --policy --identity at an updated box's attended root shell (carlos system install-channel);basis=carlos system install-channel (root): at a fresh box's start-up, and with --policy and --identity also at an updated box's attended root shell before the host floor is published; its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage (against the published floor and the identity the pointer's release tag names, or with --policy against the verified policy floor and the --identity file, which must be the identity published under its own tag and name the pointer's version, this platform's artifact sha256 and any tag the pointer carries) and stageCandidate into both protected slots, or with --policy on an updated box at the channel head through refreshPrev; it refuses a box whose guard is armed or that already runs another release, and with --policy a box whose update has not finished file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| +internal/host/installchannel_linux.go openChannelPolicy authority F,J4 rev3=census-ruling-claim-untagged-pointer;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy --identity);basis=reads each operator-staged file install-channel --policy takes, the --policy file and the --identity file, once each, before the apply lock or any store read: opened with O_NOFOLLOW (a symlink is refused) and O_NONBLOCK (a FIFO does not block), fstat'd to be a regular file of at most 64 KiB, then read from the descriptor up to that cap; the policy's bytes are used only after channelPolicy verifies the root-signed moves-protocol authorization for this deployment's bucket under CARLOS_RELEASE_PUBKEY, and a published floor must equal them byte for byte; the identity's bytes only after channelIdentity verifies them under the same key and finds them equal to the bucket's object at release.IdentityKey of their own tag file:Open@79,file:ReadAll@485,file:Stat@263 eb5725bd24aa51f5d4f93b01a065351a278bb82cbf013a106b2f1fba36987f94 |