install-console --upgrade --from <sha256>: replace a guard-armed box's protected console
install-console was a first install only: an occupied slot holding another build refused, so an installed guarded console could never be upgraded (platform-d8's harness, rolling dev's console to v0.28.5). The upgrade keeps install-console's identity, published-identity, build and source checks. Under the console directory's exclusive flock, console must be a root:root 0755 one-link regular file hashing to --from; console.prev may be absent or anything of the same shape. console already the target is Already. It writes console.next (O_EXCL, 0755, fsync), renames console to console.prev, console.next to console, fsyncs the directory and reads both back through hostfloor.OpenProtected. A run cut off between the renames (console absent, console.prev hashing to --from) is completed by a rerun; a leftover regular console.next is removed. The dry run writes nothing. Without --upgrade, install-console is unchanged; --upgrade and --from are refused anywhere else, as unknown flags were. Census: overlay plan14-root-reads-install-console-upgrade.tsv, loaded last, rev3=PROPOSED-install-console-upgrade; no legacy row, seal stands; 9B8 unchanged. Root-runner acceptance pending: TestRootActivationConsoleUpgrade, TestRootActivationConsoleUpgradeIdentity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+681
−19
docs/superpowers/notes/census-proposal-install-console-upgrade.md+40 −0internal/host/activation_artifacts_linux.go+200 −15internal/host/activation_artifacts_linux_test.go+74 −1internal/host/activation_command.go+1 −1internal/host/activation_command_linux.go+20 −2internal/host/activation_console_upgrade_rootrunner_linux_test.go+326 −0internal/provision/plan14_root_reads_test.go+12 −0internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv+8 −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 328.707s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.723s
ok github.com/carlosframework/platform/cmd/moveprobe 93.542s
ok github.com/carlosframework/platform/cmd/zddprobe 0.709s
ok github.com/carlosframework/platform/internal/activator 252.132s
ok github.com/carlosframework/platform/internal/agentsock 0.018s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.023s
ok github.com/carlosframework/platform/internal/appconfig 2.416s
ok github.com/carlosframework/platform/internal/apperr 0.088s
ok github.com/carlosframework/platform/internal/applog 0.169s
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.468s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.698s
ok github.com/carlosframework/platform/internal/changelog 0.332s
ok github.com/carlosframework/platform/internal/claimactivation 0.811s
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 420.723s
? 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.756s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
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.387s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 46.458s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.131s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.017s
ok github.com/carlosframework/platform/internal/geo 0.021s
ok github.com/carlosframework/platform/internal/geoip 0.035s
ok github.com/carlosframework/platform/internal/historyreadback 0.153s
ok github.com/carlosframework/platform/internal/host 202.492s
ok github.com/carlosframework/platform/internal/hostactors 7.207s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.008s
ok github.com/carlosframework/platform/internal/hostbox 3.330s
ok github.com/carlosframework/platform/internal/hostclaim 0.894s
ok github.com/carlosframework/platform/internal/hostenv 0.014s
ok github.com/carlosframework/platform/internal/hostevidence 0.010s
ok github.com/carlosframework/platform/internal/hostexec 7.279s
ok github.com/carlosframework/platform/internal/hostfence 0.074s
ok github.com/carlosframework/platform/internal/hostfiles 10.783s
ok github.com/carlosframework/platform/internal/hostfloor 0.023s
ok github.com/carlosframework/platform/internal/hostguard 1.092s
ok github.com/carlosframework/platform/internal/hostops 2.406s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.005s
ok github.com/carlosframework/platform/internal/hostrelocate 0.803s
ok github.com/carlosframework/platform/internal/hostresolve 0.282s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 5.297s
ok github.com/carlosframework/platform/internal/instanceexec 2.838s
ok github.com/carlosframework/platform/internal/instancefs 0.010s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.015s
ok github.com/carlosframework/platform/internal/layout 2.102s
ok github.com/carlosframework/platform/internal/lbhealth 0.006s
ok github.com/carlosframework/platform/internal/lease 10.096s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.569s
ok github.com/carlosframework/platform/internal/ledger 0.043s
ok github.com/carlosframework/platform/internal/mail 0.347s
ok github.com/carlosframework/platform/internal/maintenance 0.145s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.755s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.122s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 22.298s
ok github.com/carlosframework/platform/internal/moves/archive 1.407s
ok github.com/carlosframework/platform/internal/objstore 0.308s
ok github.com/carlosframework/platform/internal/ops 0.326s
ok github.com/carlosframework/platform/internal/park 0.368s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 46.963s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 6.499s
ok github.com/carlosframework/platform/internal/relay 7.277s
ok github.com/carlosframework/platform/internal/release 26.423s
ok github.com/carlosframework/platform/internal/rollout 4.610s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
ok github.com/carlosframework/platform/internal/rootaws 0.011s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.654s
ok github.com/carlosframework/platform/internal/s3compat 0.010s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 3.008s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 13.701s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.967s
ok github.com/carlosframework/platform/internal/status 0.079s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.347s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.007s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.889s
=== 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: 49807522 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.030s
=== RUN TestT4StorageProbeMinio
2026/10/20 19:01:19 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0520FD1C6701D, 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.029s
minio contract: PASS
diff --git a/docs/superpowers/notes/census-proposal-install-console-upgrade.md b/docs/superpowers/notes/census-proposal-install-console-upgrade.md| new file mode 100644 |
| index 000000000..3fdf33082 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-install-console-upgrade.md |
| @@ -0,0 +1,40 @@ |
| +# Census proposal: install-console --upgrade |
| + |
| +Status: PROPOSED (`rev3=PROPOSED-install-console-upgrade`), asked of the census owner. Branch `install-console-upgrade`, cut from main at 54ae7fb9. |
| + |
| +## What changed |
| + |
| +`claims install-console` was a first install only: an occupied slot holding another build refused, so a guard-armed box's protected console could never be replaced. platform-d8's harness found this rolling dev's console to v0.28.5. This branch adds `--upgrade --from <sha256>`. Without `--upgrade`, install-console and its output are unchanged. |
| + |
| +- **`runSystemClaims`** (changed): two new flags, `--upgrade` and `--from`. `upgradeFlagsRefused` (new, no read site) admits them only together and only for install-console. Anywhere else they refuse with errActivation, as unknown flags did before. The upgrade output adds `upgraded`, `from` and `previous_sha`. |
| +- **`runInstallConsole`** (changed, no read site): the identity, published-identity, build and platform checks are unchanged. A non-empty `from` calls `upgradeProtectedConsole` instead of `installProtectedConsole`. |
| +- **`consoleSlotSHA`** (new): `consoleSlotHolds`'s read moved here. The shape check is the same and is now applied to the opened descriptor as well (same device and inode). It returns the slot's sha256, or "" when absent. `consoleSlotHolds` keeps its behaviour and now has no read site (`@remove`). |
| +- **`upgradeProtectedConsole`** (new): the source is verified against the identity's console sha256 before anything is opened. The directory is checked as `consoleDir` does but never created. Under the directory's exclusive flock: |
| + - `console` and `console.prev` must each be absent or a root:root 0755 regular file with one link. `console.prev`'s bytes are not checked. |
| + - `console` holding the target: Already, nothing changes. |
| + - `console` holding `--from`: upgrade. |
| + - `console` absent and `console.prev` holding `--from`: a run cut off between its two renames; this run completes it. |
| + - Anything else refuses and changes nothing. |
| + |
| + The upgrade writes `console.next` (exclusive, 0755, fsync), renames `console` to `console.prev`, renames `console.next` to `console`, fsyncs the directory, and reads both slots back through `hostfloor.OpenProtected`. A leftover `console.next` that is a regular file is removed first; anything else at that name refuses. |
| +- **`writeConsoleTemp`** (new): the exclusive no-follow create of `console.next`, chmod 0755, root:root and one link on the descriptor, fsync. |
| + |
| +## Root-read census (TestPlan14RootReadCensus) |
| + |
| +Overlay `internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv`, loaded last, after `plan14-root-reads-armed-box-updates.tsv`. No accepted-legacy row, so the seal stands. |
| + |
| +| Unit | Executing identity | Class / tasks | Basis | |
| +|---|---|---|---| |
| +| `activation_command_linux.go:runSystemClaims` (changed) | operator CLI, uid 0 | authority G,J4 (fix1's) | the two flags, admitted only together for install-console | |
| +| `activation_artifacts_linux.go:consoleSlotHolds` (`@remove`) | | | its read moved into consoleSlotSHA | |
| +| `activation_artifacts_linux.go:consoleSlotSHA` (new) | box installer, uid 0 | authority G,J4 (consoleSlotHolds's) | absent, or root:root 0755 one-link regular file, checked on the entry and the descriptor, hashed bounded | |
| +| `activation_artifacts_linux.go:upgradeProtectedConsole` (new) | box installer, uid 0 (`claims install-console --upgrade`) | authority G,J4 (caller's) | as above | |
| +| `activation_artifacts_linux.go:writeConsoleTemp` (new) | box installer, uid 0 | authority G,J4 (caller's) | exclusive create of one fixed name in the locked directory | |
| + |
| +The `internal/host` source envelope is re-pinned. No other envelope moves. |
| + |
| +No box agent, update, recovery or edge path reaches a new or changed unit. guard-exec, `hostfloor.CheckComponent` and `installProtectedConsole` are unchanged. |
| + |
| +## Record-write census (TestPlan9B8RecordWriteCensus) |
| + |
| +Unchanged and passing. The upgrade writes local files only and adds no bucket writer. |
diff --git a/internal/host/activation_artifacts_linux.go b/internal/host/activation_artifacts_linux.go| index 2708a9d11..8cafe5e97 100644 |
| --- a/internal/host/activation_artifacts_linux.go |
| +++ b/internal/host/activation_artifacts_linux.go |
| @@ -233,10 +233,13 @@ func consoleInstallTarget(identity, published []byte, pub ed25519.PublicKey, bui |
| return p.Current.ReleaseTag, a.SHA256, nil |
| } |
| -// consoleInstalled is what one install-console run did. |
| +// consoleInstalled is what one install-console run did. An upgrade also |
| +// reports the build it replaced (From) and what console.prev holds after it |
| +// (PreviousSHA). |
| type consoleInstalled struct { |
| - Path, Previous string |
| - Already bool |
| + Path, Previous string |
| + Already bool |
| + From, PreviousSHA string |
| } |
| // installProtectedConsole copies the console at source into dir as console |
| @@ -336,30 +339,207 @@ func installProtectedConsole(dir, source, sha string, dry bool) (consoleInstalle |
| // owned regular file, one link, mode 0755, holding sha (true). Anything else |
| // is refused. |
| func consoleSlotHolds(d *os.File, name, sha string) (bool, error) { |
| + hash, err := consoleSlotSHA(d, name) |
| + if err != nil || hash == "" { |
| + return false, err |
| + } |
| + if hash != sha { |
| + return false, errActivation |
| + } |
| + return true, nil |
| +} |
| + |
| +// consoleSlotSHA returns "" when name in d is absent, and otherwise the |
| +// sha256 of a root:root regular file with one link and mode 0755, checked |
| +// on the entry, not followed, and again on the descriptor it hashes. |
| +// Anything else is refused. |
| +func consoleSlotSHA(d *os.File, name string) (string, error) { |
| var st unix.Stat_t |
| err := unix.Fstatat(int(d.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW) |
| if err == unix.ENOENT { |
| - return false, nil |
| + return "", nil |
| } |
| if err != nil { |
| - return false, err |
| + return "", err |
| } |
| - if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 || st.Mode&0o7777 != 0o755 { |
| - return false, errActivation |
| + shape := func(st *unix.Stat_t) bool { |
| + return st.Mode&unix.S_IFMT == unix.S_IFREG && st.Nlink == 1 && st.Uid == 0 && st.Gid == 0 && st.Mode&0o7777 == 0o755 |
| + } |
| + if !shape(&st) { |
| + return "", errActivation |
| } |
| f, err := safefile.OpenFileAt(int(d.Fd()), name, os.O_RDONLY, 0) |
| if err != nil { |
| - return false, err |
| + return "", err |
| } |
| defer f.Close() |
| - hash, err := hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1)) |
| + var held unix.Stat_t |
| + if err = unix.Fstat(int(f.Fd()), &held); err != nil { |
| + return "", err |
| + } |
| + if !shape(&held) || held.Dev != st.Dev || held.Ino != st.Ino { |
| + return "", errActivation |
| + } |
| + return hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1)) |
| +} |
| + |
| +// consoleUpgradeTemp is the one name an upgrade writes the new console to |
| +// before renaming it into place. Only an upgrade creates it, under the |
| +// directory's exclusive lock, and a rerun removes a leftover one. |
| +const consoleUpgradeTemp = "console.next" |
| + |
| +// upgradeProtectedConsole replaces a guarded box's protected console with |
| +// the build at source, which must hash to sha, for claims install-console |
| +// --upgrade. The source and root checks are installProtectedConsole's, and |
| +// the directory must already exist: an upgrade never creates it. |
| +// |
| +// Under the directory's exclusive lock, console and console.prev must each |
| +// be absent or a root:root regular file, mode 0755, one link; anything else |
| +// refuses. console.prev's bytes are not checked, because they are replaced. |
| +// - console holding sha: Already, and nothing changes, so a rerun after |
| +// success is a no-op. |
| +// - console holding from: the upgrade. |
| +// - console absent and console.prev holding from: a run cut off between |
| +// its two renames; this run completes it. |
| +// - anything else refuses and changes nothing. |
| +// |
| +// The upgrade writes the build to console.next (exclusive create, 0755, |
| +// fsync), renames console to console.prev, replacing the old .prev, |
| +// renames console.next to console, and fsyncs the directory. A console.next |
| +// left by a run cut off before its renames is removed first: only this |
| +// function creates that name, under the lock, in a directory only root can |
| +// write, and its bytes are never used. A console.next that is not a regular |
| +// file refuses. Both slots are then read back as guard-arm reads them |
| +// (hostfloor.OpenProtected): console must hash to sha and console.prev to |
| +// from. The dry run makes every check and writes nothing. |
| +func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleInstalled, error) { |
| + got := consoleInstalled{Path: filepath.Join(dir, "console"), Previous: filepath.Join(dir, "console.prev"), From: from} |
| + if len(sha) != 64 || !isLowerHex(from, 64) || !filepath.IsAbs(source) || filepath.Clean(source) != source { |
| + return got, errActivation |
| + } |
| + if os.Geteuid() != 0 { |
| + return got, errActivation |
| + } |
| + body, err := readConsoleSource(source, sha) |
| if err != nil { |
| - return false, err |
| + return got, err |
| } |
| - if hash != sha { |
| - return false, errActivation |
| + d, err := consoleDir(dir, false) |
| + if err != nil { |
| + return got, err |
| } |
| - return true, nil |
| + defer d.Close() |
| + if !dry { |
| + if err = unix.Flock(int(d.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { |
| + return got, err |
| + } |
| + } |
| + current, err := consoleSlotSHA(d, "console") |
| + if err != nil { |
| + return got, err |
| + } |
| + previous, err := consoleSlotSHA(d, "console.prev") |
| + if err != nil { |
| + return got, err |
| + } |
| + if current == sha { |
| + got.Already, got.PreviousSHA = true, previous |
| + return got, nil |
| + } |
| + if current != from && (current != "" || previous != from) { |
| + return got, errActivation |
| + } |
| + got.PreviousSHA = from |
| + var st unix.Stat_t |
| + switch err = unix.Fstatat(int(d.Fd()), consoleUpgradeTemp, &st, unix.AT_SYMLINK_NOFOLLOW); { |
| + case err == unix.ENOENT: |
| + case err != nil: |
| + return got, err |
| + case st.Mode&unix.S_IFMT != unix.S_IFREG: |
| + return got, errActivation |
| + case !dry: |
| + if err = unix.Unlinkat(int(d.Fd()), consoleUpgradeTemp, 0); err != nil { |
| + return got, err |
| + } |
| + } |
| + if dry { |
| + return got, nil |
| + } |
| + if err = writeConsoleTemp(d, body); err != nil { |
| + unix.Unlinkat(int(d.Fd()), consoleUpgradeTemp, 0) |
| + return got, err |
| + } |
| + if current != "" { |
| + if err = unix.Renameat(int(d.Fd()), "console", int(d.Fd()), "console.prev"); err != nil { |
| + return got, err |
| + } |
| + } |
| + if err = unix.Renameat(int(d.Fd()), consoleUpgradeTemp, int(d.Fd()), "console"); err != nil { |
| + return got, err |
| + } |
| + if err = unix.Fsync(int(d.Fd())); err != nil { |
| + return got, err |
| + } |
| + for path, want := range map[string]string{got.Path: sha, got.Previous: from} { |
| + f, err := hostfloor.OpenProtected(path) |
| + if err != nil { |
| + return got, err |
| + } |
| + err = unix.Fstat(int(f.Fd()), &st) |
| + var hash string |
| + if err == nil { |
| + hash, err = hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1)) |
| + } |
| + f.Close() |
| + if err != nil { |
| + return got, err |
| + } |
| + if hash != want || st.Mode&0o7777 != 0o755 || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 { |
| + return got, errActivation |
| + } |
| + } |
| + return got, nil |
| +} |
| + |
| +// writeConsoleTemp creates consoleUpgradeTemp in d exclusively, never |
| +// following a link, writes body, sets mode 0755 whatever the umask, checks |
| +// it is root's and fsyncs it. |
| +func writeConsoleTemp(d *os.File, body []byte) error { |
| + f, err := safefile.OpenFileAt(int(d.Fd()), consoleUpgradeTemp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + if _, err = f.Write(body); err != nil { |
| + return err |
| + } |
| + if err = f.Chmod(0o755); err != nil { |
| + return err |
| + } |
| + var st unix.Stat_t |
| + if err = unix.Fstat(int(f.Fd()), &st); err != nil { |
| + return err |
| + } |
| + if st.Uid != 0 || st.Gid != 0 || st.Nlink != 1 { |
| + return errActivation |
| + } |
| + if err = f.Sync(); err != nil { |
| + return err |
| + } |
| + return f.Close() |
| +} |
| + |
| +// isLowerHex reports whether s is exactly n lower-case hex digits. |
| +func isLowerHex(s string, n int) bool { |
| + if len(s) != n { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| + return false |
| + } |
| + } |
| + return true |
| } |
| // readConsoleSource reads the source console, no symlink followed on its |
| @@ -439,8 +619,9 @@ func consoleDir(dir string, create bool) (*os.File, error) { |
| // runInstallConsole is install-console's work after its flags: the staged |
| // identity checked against the published one in store, then the protected |
| -// copies. |
| -func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.PublicKey, identityPath, source, dir, build, platform string, dry bool) (string, string, consoleInstalled, error) { |
| +// copies. A non-empty from is --upgrade --from: the installed console must |
| +// hash to it, and it is replaced. |
| +func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.PublicKey, identityPath, source, dir, build, platform, from string, dry bool) (string, string, consoleInstalled, error) { |
| identity, err := activationRead(identityPath, false) |
| if err != nil { |
| return "", "", consoleInstalled{}, err |
| @@ -461,6 +642,10 @@ func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.Pub |
| if err != nil { |
| return "", "", consoleInstalled{}, err |
| } |
| + if from != "" { |
| + got, err := upgradeProtectedConsole(dir, source, sha, from, dry) |
| + return tag, sha, got, err |
| + } |
| got, err := installProtectedConsole(dir, source, sha, dry) |
| return tag, sha, got, err |
| } |
diff --git a/internal/host/activation_artifacts_linux_test.go b/internal/host/activation_artifacts_linux_test.go| index 2ff4b715d..5ee53f9ab 100644 |
| --- a/internal/host/activation_artifacts_linux_test.go |
| +++ b/internal/host/activation_artifacts_linux_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "flag" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -232,6 +233,78 @@ func TestInstallConsoleRefusesWithoutRoot(t *testing.T) { |
| } |
| } |
| +func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("runs as non-root") |
| + } |
| + body := []byte("console build") |
| + src := filepath.Join(t.TempDir(), "console") |
| + if err := os.WriteFile(src, body, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := filepath.Join(t.TempDir(), "protected") |
| + for _, dry := range []bool{true, false} { |
| + if _, err := upgradeProtectedConsole(dir, src, artifactSHA(body), artifactSHA([]byte("old")), dry); err == nil { |
| + t.Fatal("upgraded without root") |
| + } |
| + } |
| + if _, err := os.Lstat(dir); !os.IsNotExist(err) { |
| + t.Fatal("refusal created the directory", err) |
| + } |
| +} |
| + |
| +// --upgrade and --from are install-console's, and come together; anywhere |
| +// else they are refused before any input is read, as unknown flags were. |
| +func TestClaimsUpgradeFlagsBelongToInstallConsole(t *testing.T) { |
| + sha := artifactSHA([]byte("old")) |
| + for _, args := range [][]string{ |
| + {"install-console", "--upgrade"}, |
| + {"install-console", "--from", sha}, |
| + {"install-console", "--upgrade=false", "--from", sha}, |
| + {"install-console", "--upgrade", "--from", ""}, |
| + {"install-console", "--upgrade=false"}, |
| + {"inspect", "--upgrade", "--from", sha}, |
| + {"console-report", "--from", sha}, |
| + } { |
| + var out bytes.Buffer |
| + if err := runSystemClaims(args, &out); !errors.Is(err, errActivation) || out.Len() != 0 { |
| + t.Fatal("accepted", args, err, out.String()) |
| + } |
| + } |
| + parse := func(args ...string) *flag.FlagSet { |
| + fs := flag.NewFlagSet("claims", flag.ContinueOnError) |
| + fs.Bool("upgrade", false, "") |
| + fs.String("from", "", "") |
| + fs.String("source", "", "") |
| + if err := fs.Parse(args); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fs |
| + } |
| + for _, tc := range []struct { |
| + command string |
| + args []string |
| + upgrade bool |
| + from string |
| + refused bool |
| + }{ |
| + {"install-console", []string{"--source", "x"}, false, "", false}, |
| + {"inspect", nil, false, "", false}, |
| + {"install-console", []string{"--upgrade", "--from", sha}, true, sha, false}, |
| + {"install-console", []string{"--upgrade"}, true, "", true}, |
| + {"install-console", []string{"--from", sha}, false, sha, true}, |
| + {"install-console", []string{"--upgrade=false"}, false, "", true}, |
| + {"install-console", []string{"--upgrade", "--from", ""}, true, "", true}, |
| + {"inspect", []string{"--upgrade", "--from", sha}, true, sha, true}, |
| + {"install-control", []string{"--from", sha}, false, sha, true}, |
| + {"install-control", []string{"--upgrade"}, true, "", true}, |
| + } { |
| + if got := upgradeFlagsRefused(tc.command, parse(tc.args...), tc.upgrade, tc.from); got != tc.refused { |
| + t.Fatal(tc.command, tc.args, "refused", got) |
| + } |
| + } |
| +} |
| + |
| // claims run verifies release checksum lists with the vendor key the |
| // binary embeds, never a key from its inputs. |
| func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) { |
| @@ -243,7 +316,7 @@ func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) { |
| } |
| func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) { |
| - for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K", "[--out FILE]"} { |
| + for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]\n", "[--out FILE]"} { |
| if !strings.Contains(activationClaimsUsage, want) { |
| t.Fatal("usage lacks", want) |
| } |
diff --git a/internal/host/activation_command.go b/internal/host/activation_command.go| index 501726fdb..a6ccae421 100644 |
| --- a/internal/host/activation_command.go |
| +++ b/internal/host/activation_command.go |
| @@ -19,7 +19,7 @@ const activationClaimsUsage = `usage: carlos system claims <command> [flags] |
| fence-credentials --source FILE --bucket B --aws-region R --role ARN |
| console-config --roster FILE --expect-revision N --bucket B --console-id ID --public-key K [--enable-console-runtime] |
| console-report --bucket B --aws-region R --expect-revision N --console-id ID --public-key K |
| - install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K |
| + install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256] |
| install-control |
| For commands that write, add --dry-run to validate and print the planned operation without writing. |
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go| index dc967f819..8f2525c1d 100644 |
| --- a/internal/host/activation_command_linux.go |
| +++ b/internal/host/activation_command_linux.go |
| @@ -45,9 +45,14 @@ func runSystemClaims(args []string, out io.Writer) error { |
| role := fs.String("role", "", "") |
| outPath := fs.String("out", "", "") |
| identityPath := fs.String("identity", "", "") |
| + upgrade := fs.Bool("upgrade", false, "") |
| + from := fs.String("from", "", "") |
| if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 { |
| return errActivation |
| } |
| + if upgradeFlagsRefused(args[0], fs, *upgrade, *from) { |
| + return errActivation |
| + } |
| ctx := context.Background() |
| encode := func(value any) error { e := json.NewEncoder(out); e.SetIndent("", " "); return e.Encode(value) } |
| switch args[0] { |
| @@ -279,11 +284,15 @@ func runSystemClaims(args []string, out io.Writer) error { |
| if err != nil { |
| return err |
| } |
| - tag, sha, got, err := runInstallConsole(ctx, store, pub, *identityPath, *source, consoleInstallDir, version.Build, runtime.GOOS+"-"+runtime.GOARCH, *dry) |
| + tag, sha, got, err := runInstallConsole(ctx, store, pub, *identityPath, *source, consoleInstallDir, version.Build, runtime.GOOS+"-"+runtime.GOARCH, *from, *dry) |
| if err != nil { |
| return err |
| } |
| - return encode(map[string]any{"dry_run": *dry, "release_tag": tag, "sha256": sha, "source": *source, "path": got.Path, "previous": got.Previous, "already_installed": got.Already, "mode": "0755"}) |
| + result := map[string]any{"dry_run": *dry, "release_tag": tag, "sha256": sha, "source": *source, "path": got.Path, "previous": got.Previous, "already_installed": got.Already, "mode": "0755"} |
| + if *upgrade { |
| + result["upgraded"], result["from"], result["previous_sha"] = true, got.From, got.PreviousSHA |
| + } |
| + return encode(result) |
| case "install-control": |
| if err := verifyOfflineRootMigration(ctx); err != nil { |
| return err |
| @@ -297,6 +306,15 @@ func runSystemClaims(args []string, out io.Writer) error { |
| } |
| } |
| +// upgradeFlagsRefused reports whether parsed flags misuse --upgrade and |
| +// --from: they belong to install-console and come together, so anything |
| +// else is refused as an unknown flag was before them. |
| +func upgradeFlagsRefused(command string, fs *flag.FlagSet, upgrade bool, from string) bool { |
| + set := false |
| + fs.Visit(func(f *flag.Flag) { set = set || f.Name == "upgrade" || f.Name == "from" }) |
| + return set && (command != "install-console" || !upgrade || from == "") |
| +} |
| + |
| // activationRunner is claims run's runner. Its sums key is the vendor's |
| // release key that the carlos binary embeds (publicrelease.Default, the same |
| // key update.go's cliUpdatePubKey holds), so publish-identity accepts only a |
diff --git a/internal/host/activation_console_upgrade_rootrunner_linux_test.go b/internal/host/activation_console_upgrade_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..25fa13f0a |
| --- /dev/null |
| +++ b/internal/host/activation_console_upgrade_rootrunner_linux_test.go |
| @@ -0,0 +1,326 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// consoleUpgradeFixture is a protected console directory installed by |
| +// install-console with build A, and source files for builds A to D. |
| +type consoleUpgradeFixture struct { |
| + t *testing.T |
| + d, dir string |
| + src map[string]string // build name to source path |
| + body map[string][]byte |
| + sha map[string]string |
| +} |
| + |
| +func newConsoleUpgradeFixture(t *testing.T) *consoleUpgradeFixture { |
| + t.Helper() |
| + d := activationRootFixture(t) |
| + f := &consoleUpgradeFixture{t: t, d: d, dir: filepath.Join(d, "opt", "system", "console"), src: map[string]string{}, body: map[string][]byte{}, sha: map[string]string{}} |
| + f.check(os.Chmod(d, 0o755)) |
| + f.check(os.MkdirAll(filepath.Join(d, "src"), 0o755)) |
| + for _, name := range []string{"A", "B", "C", "D"} { |
| + f.body[name] = []byte("console build " + name) |
| + f.sha[name] = artifactSHA(f.body[name]) |
| + f.src[name] = filepath.Join(d, "src", name) |
| + f.check(os.WriteFile(f.src[name], f.body[name], 0o755)) |
| + } |
| + if got, err := installProtectedConsole(f.dir, f.src["A"], f.sha["A"], false); err != nil || got.Already { |
| + t.Fatal("first install", got, err) |
| + } |
| + f.want("A", "A") |
| + return f |
| +} |
| + |
| +func (f *consoleUpgradeFixture) check(err error) { |
| + f.t.Helper() |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *consoleUpgradeFixture) path(name string) string { return filepath.Join(f.dir, name) } |
| + |
| +// slot requires name to be a root:root 0755 one-link regular file holding |
| +// build b. |
| +func (f *consoleUpgradeFixture) slot(name, b string) { |
| + f.t.Helper() |
| + got, err := os.ReadFile(f.path(name)) |
| + st, serr := os.Lstat(f.path(name)) |
| + if err != nil || serr != nil || !bytes.Equal(got, f.body[b]) { |
| + f.t.Fatalf("%s: want build %s, got %q %v %v", name, b, got, err, serr) |
| + } |
| + sys := st.Sys().(*syscall.Stat_t) |
| + if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 { |
| + f.t.Fatalf("%s mode %v uid %d gid %d links %d", name, st.Mode(), sys.Uid, sys.Gid, sys.Nlink) |
| + } |
| +} |
| + |
| +// entries lists the directory's names. |
| +func (f *consoleUpgradeFixture) entries() string { |
| + f.t.Helper() |
| + list, err := os.ReadDir(f.dir) |
| + f.check(err) |
| + var names []string |
| + for _, e := range list { |
| + names = append(names, e.Name()) |
| + } |
| + sort.Strings(names) |
| + return strings.Join(names, ",") |
| +} |
| + |
| +// want requires console to hold current, console.prev to hold prev and |
| +// nothing else in the directory. |
| +func (f *consoleUpgradeFixture) want(current, prev string) { |
| + f.t.Helper() |
| + f.slot("console", current) |
| + f.slot("console.prev", prev) |
| + if got := f.entries(); got != "console,console.prev" { |
| + f.t.Fatal("directory holds", got) |
| + } |
| +} |
| + |
| +// upgrade runs the upgrade to build to, from the sha of build from. |
| +func (f *consoleUpgradeFixture) upgrade(to, from string, dry bool) (consoleInstalled, error) { |
| + return upgradeProtectedConsole(f.dir, f.src[to], f.sha[to], f.sha[from], dry) |
| +} |
| + |
| +// refused requires the upgrade to build to from build from to refuse, dry |
| +// and not, and the directory listing to be unchanged by either. |
| +func (f *consoleUpgradeFixture) refused(what, to, from string) { |
| + f.t.Helper() |
| + before := f.entries() |
| + for _, dry := range []bool{true, false} { |
| + if got, err := f.upgrade(to, from, dry); err == nil { |
| + f.t.Fatal("accepted", what, dry, got) |
| + } |
| + if after := f.entries(); after != before { |
| + f.t.Fatal(what, "changed the directory", before, after) |
| + } |
| + } |
| +} |
| + |
| +// TestRootActivationConsoleUpgrade is install-console --upgrade as real |
| +// root: the swap, its refusals, crash recovery, a leftover temp file, the |
| +// Already rerun and the dry run. |
| +func TestRootActivationConsoleUpgrade(t *testing.T) { |
| + f := newConsoleUpgradeFixture(t) |
| + |
| + // A wrong --from refuses and changes nothing; so does a malformed one. |
| + f.refused("wrong from", "B", "C") |
| + f.want("A", "A") |
| + for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} { |
| + for _, dry := range []bool{true, false} { |
| + if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil { |
| + t.Fatalf("accepted from %q", from) |
| + } |
| + } |
| + } |
| + // A source that does not hash to the target refuses. |
| + if _, err := upgradeProtectedConsole(f.dir, f.src["C"], f.sha["B"], f.sha["A"], false); err == nil { |
| + t.Fatal("accepted another source") |
| + } |
| + f.want("A", "A") |
| + |
| + // The dry run checks and reports, and changes nothing. |
| + got, err := f.upgrade("B", "A", true) |
| + if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] || got.Path != f.path("console") || got.Previous != f.path("console.prev") { |
| + t.Fatal("dry run", got, err) |
| + } |
| + f.want("A", "A") |
| + |
| + // Upgrade A to B: prev is A, current is B, no temp file left. |
| + got, err = f.upgrade("B", "A", false) |
| + if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] { |
| + t.Fatal("upgrade", got, err) |
| + } |
| + f.want("B", "A") |
| + |
| + // A rerun is Already and changes nothing, dry or not. |
| + for _, dry := range []bool{true, false} { |
| + got, err = f.upgrade("B", "A", dry) |
| + if err != nil || !got.Already || got.PreviousSHA != f.sha["A"] { |
| + t.Fatal("rerun", dry, got, err) |
| + } |
| + f.want("B", "A") |
| + } |
| + |
| + // A malformed --from refuses even when the console is already current. |
| + for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} { |
| + for _, dry := range []bool{true, false} { |
| + if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil { |
| + t.Fatalf("Already with from %q", from) |
| + } |
| + } |
| + } |
| + |
| + // Without --upgrade an occupied slot still refuses, as before. |
| + for _, b := range []string{"B", "C"} { |
| + for _, dry := range []bool{true, false} { |
| + if _, err := installProtectedConsole(f.dir, f.src[b], f.sha[b], dry); err == nil { |
| + t.Fatal("first install over an installed console", b, dry) |
| + } |
| + } |
| + } |
| + f.want("B", "A") |
| + |
| + // The current console's shape: each refuses, nothing changes. |
| + cur := f.path("console") |
| + shape := func(what string, setup, undo func()) { |
| + t.Helper() |
| + setup() |
| + f.refused(what, "C", "B") |
| + undo() |
| + f.want("B", "A") |
| + } |
| + shape("current symlink", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Symlink(cur+".real", cur)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) }) |
| + shape("current not root's", func() { f.check(os.Chown(cur, 1000, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) }) |
| + shape("current owner not root", func() { f.check(os.Chown(cur, 1000, 0)) }, func() { f.check(os.Chown(cur, 0, 0)) }) |
| + shape("current group not root", func() { f.check(os.Chown(cur, 0, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) }) |
| + shape("current two links", func() { f.check(os.Link(cur, cur+".link")) }, func() { f.check(os.Remove(cur + ".link")) }) |
| + shape("current group writable", func() { f.check(os.Chmod(cur, 0o775)) }, func() { f.check(os.Chmod(cur, 0o755)) }) |
| + shape("current not executable by others", func() { f.check(os.Chmod(cur, 0o750)) }, func() { f.check(os.Chmod(cur, 0o755)) }) |
| + shape("current directory", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Mkdir(cur, 0o755)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) }) |
| + |
| + // console.prev's shape: a symlink, a directory or the wrong owner each |
| + // refuses. |
| + prev := f.path("console.prev") |
| + shape("prev symlink", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Symlink(prev+".real", prev)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) }) |
| + shape("prev directory", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Mkdir(prev, 0o755)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) }) |
| + shape("prev not root's", func() { f.check(os.Chown(prev, 1000, 1000)) }, func() { f.check(os.Chown(prev, 0, 0)) }) |
| + shape("prev owner not root", func() { f.check(os.Chown(prev, 1000, 0)) }, func() { f.check(os.Chown(prev, 0, 0)) }) |
| + shape("prev two links", func() { f.check(os.Link(prev, prev+".link")) }, func() { f.check(os.Remove(prev + ".link")) }) |
| + shape("prev group writable", func() { f.check(os.Chmod(prev, 0o775)) }, func() { f.check(os.Chmod(prev, 0o755)) }) |
| + |
| + // console.prev's bytes are not checked: it is about to be replaced. |
| + f.check(os.WriteFile(prev+".x", []byte("an older build"), 0o755)) |
| + f.check(os.Rename(prev+".x", prev)) |
| + if got, err = f.upgrade("C", "B", false); err != nil || got.Already || got.PreviousSHA != f.sha["B"] { |
| + t.Fatal("upgrade over any prev", got, err) |
| + } |
| + f.want("C", "B") |
| + |
| + // A run cut off after renaming console to console.prev: console is |
| + // absent, console.prev holds the old build and console.next the new. |
| + f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["D"], 0o755)) |
| + f.check(os.Rename(cur, prev)) |
| + if got := f.entries(); got != "console.next,console.prev" { |
| + t.Fatal("crash state", got) |
| + } |
| + f.refused("crash state, wrong from", "D", "B") |
| + f.refused("crash state, from the new build", "D", "D") |
| + if got, err = f.upgrade("D", "C", true); err != nil || got.Already || got.PreviousSHA != f.sha["C"] { |
| + t.Fatal("crash state dry run", got, err) |
| + } |
| + if got := f.entries(); got != "console.next,console.prev" { |
| + t.Fatal("crash state dry run changed", got) |
| + } |
| + if got, err = f.upgrade("D", "C", false); err != nil || got.Already || got.From != f.sha["C"] || got.PreviousSHA != f.sha["C"] { |
| + t.Fatal("crash recovery", got, err) |
| + } |
| + f.want("D", "C") |
| + |
| + // With both slots gone there is nothing to upgrade from. |
| + f.check(os.Rename(cur, cur+".keep")) |
| + f.check(os.Rename(prev, prev+".keep")) |
| + f.refused("no console", "A", "D") |
| + f.refused("no console, from prev", "A", "C") |
| + f.check(os.Rename(cur+".keep", cur)) |
| + f.check(os.Rename(prev+".keep", prev)) |
| + |
| + // A run cut off before its renames leaves a partial console.next: the |
| + // dry run leaves it, a rerun removes it and completes. |
| + f.check(os.WriteFile(f.path(consoleUpgradeTemp), []byte("partial"), 0o600)) |
| + if _, err = f.upgrade("A", "D", true); err != nil { |
| + t.Fatal("dry run over a leftover", err) |
| + } |
| + if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); string(b) != "partial" { |
| + t.Fatal("dry run touched the leftover") |
| + } |
| + if got, err = f.upgrade("A", "D", false); err != nil || got.Already || got.PreviousSHA != f.sha["D"] { |
| + t.Fatal("upgrade over a leftover", got, err) |
| + } |
| + f.want("A", "D") |
| + |
| + // A console.next that is not a regular file refuses and is left. |
| + f.check(os.Mkdir(f.path(consoleUpgradeTemp), 0o700)) |
| + f.refused("console.next directory", "B", "A") |
| + f.check(os.Remove(f.path(consoleUpgradeTemp))) |
| + f.check(os.Symlink(f.src["B"], f.path(consoleUpgradeTemp))) |
| + f.refused("console.next symlink", "B", "A") |
| + f.check(os.Remove(f.path(consoleUpgradeTemp))) |
| + f.want("A", "D") |
| + |
| + // An upgrade never creates the directory. |
| + missing := filepath.Join(f.d, "opt", "system", "other") |
| + for _, dry := range []bool{true, false} { |
| + if _, err := upgradeProtectedConsole(missing, f.src["B"], f.sha["B"], f.sha["A"], dry); err == nil { |
| + t.Fatal("upgraded a missing directory", dry) |
| + } |
| + } |
| + if _, err := os.Lstat(missing); !os.IsNotExist(err) { |
| + t.Fatal("upgrade created the directory", err) |
| + } |
| + |
| + // A group-writable directory refuses, as for the first install. |
| + f.check(os.Chmod(f.dir, 0o775)) |
| + f.refused("group-writable directory", "B", "A") |
| + f.check(os.Chmod(f.dir, 0o755)) |
| + f.want("A", "D") |
| +} |
| + |
| +// TestRootActivationConsoleUpgradeIdentity runs the upgrade through |
| +// runInstallConsole: the staged identity must be the published one and |
| +// name the running build, or nothing changes. |
| +func TestRootActivationConsoleUpgradeIdentity(t *testing.T) { |
| + f := newConsoleUpgradeFixture(t) |
| + w := newArtifactWorld(t) |
| + identity := w.run(t, w.identityPlan(t)).Output |
| + idPath := filepath.Join(f.d, "identity.json") |
| + f.check(os.WriteFile(idPath, identity, 0o644)) |
| + f.body["I"] = []byte("console build") |
| + f.sha["I"] = artifactSHA(f.body["I"]) |
| + f.src["I"] = filepath.Join(f.d, "src", "I") |
| + f.check(os.WriteFile(f.src["I"], f.body["I"], 0o755)) |
| + |
| + for _, build := range []string{"v0.28.1", "v0.28.3", ""} { |
| + for _, dry := range []bool{true, false} { |
| + if _, _, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, build, "linux-arm64", f.sha["A"], dry); err == nil { |
| + t.Fatal("accepted build", build, got) |
| + } |
| + } |
| + } |
| + for _, dry := range []bool{true, false} { |
| + if _, _, _, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-amd64", f.sha["A"], dry); err == nil { |
| + t.Fatal("accepted another platform") |
| + } |
| + } |
| + f.want("A", "A") |
| + |
| + tag, sha, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], true) |
| + if err != nil || tag != "v0.28.2" || sha != f.sha["I"] || got.Already || got.PreviousSHA != f.sha["A"] { |
| + t.Fatal("dry run", tag, sha, got, err) |
| + } |
| + f.want("A", "A") |
| + if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || got.Already { |
| + t.Fatal("upgrade", got, err) |
| + } |
| + f.want("I", "A") |
| + if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || !got.Already { |
| + t.Fatal("rerun", got, err) |
| + } |
| + // Without --upgrade the installed console is still refused. |
| + if _, _, _, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", "", false); err == nil { |
| + t.Fatal("first install over an upgraded console") |
| + } |
| + f.want("I", "A") |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 95983645d..da7ee3a9a 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -981,6 +981,18 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-armed-box-updates.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay install-console-upgrade (claims install-console |
| + // --upgrade --from: a guard-armed box's protected console can be |
| + // replaced, not only installed once), loaded last. |
| + // PROPOSED-install-console-upgrade, asked of the census owner |
| + // (docs/superpowers/notes/census-proposal-install-console-upgrade.md). |
| + // runSystemClaims is re-pinned at its class, consoleSlotHolds's read |
| + // moves into consoleSlotSHA, and upgradeProtectedConsole and |
| + // writeConsoleTemp are new, all authority G,J4. No legacy row; the seal |
| + // stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv| new file mode 100644 |
| index 000000000..a7de60d9d |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv |
| @@ -0,0 +1,8 @@ |
| +# Census overlay install-console-upgrade (claims install-console --upgrade --from <sha256>: replace a guard-armed box's protected console, found by platform-d8's harness rolling dev's console to v0.28.5), loaded LAST, after armed-box-updates. PROPOSED-install-console-upgrade, asked of the census owner; the proposal is docs/superpowers/notes/census-proposal-install-console-upgrade.md. |
| +# Every row keeps its latest existing class and task (authority G,J4, as census-ruling-claim-step6-artifacts and -fix1 ruled for the installer and runSystemClaims); new units take their caller's. No accepted-legacy row, so the seal stands. No box agent, update, recovery or edge path reaches any changed unit. The 9B8 inventory is unchanged. |
| +@coverage internal/host d6cd5cfd57f4d54df83bbf42c9463d959e80f430ffabfc3160c258c30828cabf |
| +@remove internal/host/activation_artifacts_linux.go consoleSlotHolds |
| +internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-install-console-upgrade;identity=explicit operator CLI uid 0;basis=as census-ruling-claim-step6-artifacts-fix1; install-console also takes --upgrade --from <sha256>, which upgradeFlagsRefused (no read site) admits only together and only for install-console, refusing them elsewhere as unknown flags were; without --upgrade the install path and its output are unchanged file:Read@1761 28f797267906832947199d7a1620345f036b6c4cb405b3b42ffab94c209a5e01 |
| +internal/host/activation_artifacts_linux.go consoleSlotSHA authority G,J4 rev3=PROPOSED-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=consoleSlotHolds's read moved here unchanged and tightened: a slot is absent, or a root:root 0755 regular file with one link, checked on the entry not followed and again on the descriptor (same device and inode) before hashing at most 512 MiB; anything else refuses. consoleSlotHolds (now no read site) compares the hash as before file:Fstat@597,file:Fstatat@91 8dfabaabac448322dc022b1fc9fbe76c24b471c10137f604d2eb3239a3006fc7 |
| +internal/host/activation_artifacts_linux.go upgradeProtectedConsole authority G,J4 rev3=PROPOSED-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=source verified against the published identity's console sha256 before the directory is opened; directory checked as consoleDir and never created; under its exclusive flock console must hash to --from (or be absent with console.prev hashing to --from, a run cut off between its renames), console.prev may hold anything of the same shape; console already the target is Already; a leftover console.next is removed only if a regular file; writes console.next, renames console to console.prev and console.next to console, fsyncs the directory, reads both back through hostfloor.OpenProtected; dry run writes nothing file:Fstat@2086,file:Fstatat@1112 4bb5d3805222b8e18bd98ca616fa5909a673d2b56f5ec858b5da3f02c567c09f |
| +internal/host/activation_artifacts_linux.go writeConsoleTemp authority G,J4 rev3=PROPOSED-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=exclusive no-follow create of console.next in the locked directory, writes the verified body, chmod 0755, requires root:root and one link on the descriptor, fsyncs file:Fstat@353 227c4d8eb65491d4ba0bbd1d91c90bfd82962b2c15f20e59d04f920f36e5d001 |