carlos / platform Public

install-console-upgrade

Squashed from install-console-upgrade into main.
claude agent, operated by paul@keymail.dev 2185035698a1111c487651bddb1bf2500cd92ccc parent 54ae7fb
9 files changed, +975 −23
  • docs/site/cli.md +2 −0
  • docs/superpowers/notes/census-proposal-install-console-upgrade.md +50 −0
  • internal/host/activation_artifacts_linux.go +280 −19
  • internal/host/activation_artifacts_linux_test.go +124 −1
  • internal/host/activation_command.go +1 −1
  • internal/host/activation_command_linux.go +20 −2
  • internal/host/activation_console_upgrade_rootrunner_linux_test.go +471 −0
  • internal/provision/plan14_root_reads_test.go +14 −0
  • internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv +13 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	310.454s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.575s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.324s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.703s
ok  	github.com/carlosframework/platform/internal/activator	250.713s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.472s
ok  	github.com/carlosframework/platform/internal/apperr	0.075s
ok  	github.com/carlosframework/platform/internal/applog	0.144s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.464s
?   	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.691s
ok  	github.com/carlosframework/platform/internal/changelog	0.328s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.776s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	409.075s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.483s
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.345s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	44.974s
?   	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.132s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.009s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.151s
ok  	github.com/carlosframework/platform/internal/host	186.116s
ok  	github.com/carlosframework/platform/internal/hostactors	5.742s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.004s
ok  	github.com/carlosframework/platform/internal/hostbox	2.653s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.575s
ok  	github.com/carlosframework/platform/internal/hostenv	0.004s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.912s
ok  	github.com/carlosframework/platform/internal/hostfence	0.036s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.442s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	1.002s
ok  	github.com/carlosframework/platform/internal/hostops	1.599s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.479s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.146s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	5.240s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.772s
ok  	github.com/carlosframework/platform/internal/instancefs	0.021s
ok  	github.com/carlosframework/platform/internal/instancekey	0.019s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.029s
ok  	github.com/carlosframework/platform/internal/layout	1.561s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.018s
ok  	github.com/carlosframework/platform/internal/lease	9.907s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.399s
ok  	github.com/carlosframework/platform/internal/ledger	0.014s
ok  	github.com/carlosframework/platform/internal/mail	0.188s
ok  	github.com/carlosframework/platform/internal/maintenance	0.080s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.527s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.084s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	21.124s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.801s
ok  	github.com/carlosframework/platform/internal/objstore	0.203s
ok  	github.com/carlosframework/platform/internal/ops	0.180s
ok  	github.com/carlosframework/platform/internal/park	0.207s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	35.934s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.475s
ok  	github.com/carlosframework/platform/internal/relay	7.268s
ok  	github.com/carlosframework/platform/internal/release	23.899s
ok  	github.com/carlosframework/platform/internal/rollout	3.639s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.062s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.566s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.500s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.308s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.737s
ok  	github.com/carlosframework/platform/internal/status	0.072s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.062s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.006s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.559s
=== 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: 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.022s
=== RUN   TestT4StorageProbeMinio
2026/10/20 19:48:54 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E054A8A4B34514, 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.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 194ae0780..4ca942cd4 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2162,6 +2162,8 @@ Two `run` phases make the files `carlos system guard-arm` takes. `sign-policy` s
`install-console` copies the console build that the published identity names into `/opt/carlos/system/console/console` and `console.prev`, owned by root. It refuses a source whose bytes don't match the identity, and it doesn't touch `console.service`. Pass that path to `guard-arm --console`.
+To replace an installed console, add `--upgrade --from <sha256>`, where the sha256 is the console that is running now. It refuses unless that build is what the box holds, then installs the new console and keeps the old one as `console.prev`. A box upgraded this way can't be armed again with `guard-arm` until `install-console` has been run without `--upgrade`, because `guard-arm` expects both copies to be the same build.
+
After starting the guarded console, run `console-report` on its box as root. It checks the signed roster, guard, systemd process and running executable, then records that process in the existing private box report. It refuses stale box evidence and leaves the report’s timestamp unchanged. Run it again after a console restart and immediately before floor publication; the next agent report may replace the process entry.
#### carlos system journal-migrate
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..1de21633c
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-install-console-upgrade.md
@@ -0,0 +1,50 @@
+# Census proposal: install-console --upgrade
+
+Status: RULED (platform-d8, 2026-10-20): census-ruling-install-console-upgrade, accepted as proposed (U1 to U4 at da0a33b7; U5 to U7 for the delta to b913bb65). Astra approved b913bb65. 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.
+
+## Round 2 (Astra's review of da0a33b7, MERGE: block)
+
+Same overlay and marker (`rev3=PROPOSED-install-console-upgrade`). platform-d8 accepted da0a33b7; this delta needs a new ruling.
+
+- **F1, source aliasing (MEDIUM).** An upgrade refuses a `--source` inside the console directory by path, before reading anything. Under the lock, it also refuses a source whose opened descriptor has the device and inode of `console`, `console.prev` or `console.next`, which catches a bind mount or any other path to the same file. To return that descriptor's stat, `readConsoleSource`'s read moved unchanged into `readConsoleSourceFile` (new). `readConsoleSource` wraps it for the first install and now has no read site (`@remove`).
+- **F2, Already skipped the directory fsync (MEDIUM).** A non-dry Already now fsyncs the held directory and returns any failure. A run cut off after its second rename, before its fsync, is therefore made durable by the rerun. The fsync goes through `consoleUpgradeSync`, a package variable with no read site that tests replace.
+- **F3, prefix digest (LOW).** `consoleDigest` (new) hashes a held slot through EOF. It refuses a slot over 512 MiB by fstat size and by the bytes read (`consoleCounter.Read`, new), and never hashes a prefix. `consoleSlotSHA` and the upgrade's readback use it. The first install's slot checks go through `consoleSlotSHA`, so they get the same refusal. The first install's own readback is unchanged.
+
+Rows: `consoleSlotSHA` and `upgradeProtectedConsole` are re-pinned. `readConsoleSourceFile`, `consoleDigest` and `consoleCounter.Read` are new. `readConsoleSource` gets `@remove`. All are authority G,J4, the class of the rows they come from or their caller's. The `internal/host` envelope is re-pinned. There is no legacy row, so the seal stands, and 9B8 is unchanged.
diff --git a/internal/host/activation_artifacts_linux.go b/internal/host/activation_artifacts_linux.go
index 2708a9d11..800bce5a1 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,50 +339,303 @@ 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 consoleDigest(f)
+}
+
+// consoleDigest hashes a console slot through EOF. A file larger than
+// consoleMaxBytes is refused, by its size and by the bytes read, never
+// hashed by its prefix.
+func consoleDigest(f *os.File) (string, error) {
+ var st unix.Stat_t
+ if err := unix.Fstat(int(f.Fd()), &st); err != nil {
+ return "", err
+ }
+ if st.Size < 0 || st.Size > consoleMaxBytes {
+ return "", errActivation
+ }
+ r := &consoleCounter{r: io.LimitReader(f, consoleMaxBytes+1)}
+ hash, err := hostfloor.DigestReader(r)
if err != nil {
- return false, err
+ return "", err
}
- if hash != sha {
- return false, errActivation
+ if r.n > consoleMaxBytes {
+ return "", errActivation
}
- return true, nil
+ return hash, nil
+}
+
+// consoleCounter counts the bytes read through it.
+type consoleCounter struct {
+ r io.Reader
+ n int64
+}
+
+func (c *consoleCounter) Read(p []byte) (int, error) {
+ n, err := c.r.Read(p)
+ c.n += int64(n)
+ return n, err
+}
+
+// consoleUpgradeSync fsyncs the console directory at the end of an upgrade
+// and before an Already result. Tests replace it to see that it ran.
+var consoleUpgradeSync = func(fd int) error { return unix.Fsync(fd) }
+
+// 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.
+//
+// A source that is, or is inside, the console directory refuses, by its
+// path and by the device and inode of the descriptor it was read from
+// against console, console.prev and console.next: an upgrade must never
+// consume its own source.
+//
+// A non-dry Already fsyncs the directory first, so a rerun after a run cut
+// off before its final fsync makes that run's renames durable.
+//
+// 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 source == dir || strings.HasPrefix(source, dir+"/") {
+ return got, errActivation
+ }
+ if os.Geteuid() != 0 {
+ return got, errActivation
+ }
+ body, src, err := readConsoleSourceFile(source, sha)
+ if err != nil {
+ return got, err
+ }
+ d, err := consoleDir(dir, false)
+ if err != nil {
+ return got, err
+ }
+ defer d.Close()
+ if !dry {
+ if err = unix.Flock(int(d.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ return got, err
+ }
+ }
+ for _, name := range []string{"console", "console.prev", consoleUpgradeTemp} {
+ var st unix.Stat_t
+ err = unix.Fstatat(int(d.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
+ if err == nil && st.Dev == src.Dev && st.Ino == src.Ino {
+ return got, errActivation
+ }
+ if err != nil && err != unix.ENOENT {
+ 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
+ if !dry {
+ if err = consoleUpgradeSync(int(d.Fd())); err != nil {
+ return got, err
+ }
+ }
+ 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 = consoleUpgradeSync(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 = consoleDigest(f)
+ }
+ 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
// path, a regular file with one link, at most consoleMaxBytes, and refuses
// it unless it hashes to sha.
func readConsoleSource(source, sha string) ([]byte, error) {
+ body, _, err := readConsoleSourceFile(source, sha)
+ return body, err
+}
+
+// readConsoleSourceFile is readConsoleSource, also returning the stat of
+// the descriptor the bytes were read from, so an upgrade can refuse a
+// source that is one of its own slots.
+func readConsoleSourceFile(source, sha string) ([]byte, unix.Stat_t, error) {
+ var st unix.Stat_t
f, err := safefile.OpenFile(source, os.O_RDONLY, 0)
if err != nil {
- return nil, err
+ return nil, st, err
}
defer f.Close()
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil {
+ return nil, st, err
+ }
body, err := io.ReadAll(io.LimitReader(f, consoleMaxBytes+1))
if err != nil {
- return nil, err
+ return nil, st, err
}
sum := sha256.Sum256(body)
if len(body) > consoleMaxBytes || hex.EncodeToString(sum[:]) != sha {
- return nil, errActivation
+ return nil, st, errActivation
}
- return body, nil
+ return body, st, nil
}
// consoleDir opens dir component by component from /, never following a
@@ -439,8 +695,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 +718,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..1327eb338 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,128 @@ 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)
+ }
+}
+
+// consoleDigest refuses more than consoleMaxBytes, by size or by bytes read
+// (a pipe reports size 0), and an upgrade refuses a source path inside the
+// console directory before reading anything.
+func TestConsoleDigestRefusesOversized(t *testing.T) {
+ big := filepath.Join(t.TempDir(), "big")
+ if err := os.WriteFile(big, nil, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(big, consoleMaxBytes+2); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Open(big)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if _, err := consoleDigest(f); err == nil {
+ t.Fatal("hashed an oversized file")
+ }
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ go func() {
+ buf := make([]byte, 1<<20)
+ for n := int64(0); n <= consoleMaxBytes; n += int64(len(buf)) {
+ if _, err := w.Write(buf); err != nil {
+ break
+ }
+ }
+ w.Close()
+ }()
+ if _, err := consoleDigest(r); err == nil {
+ t.Fatal("hashed an oversized stream")
+ }
+ small := filepath.Join(t.TempDir(), "small")
+ if err := os.WriteFile(small, []byte("console build"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ s, err := os.Open(small)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if got, err := consoleDigest(s); err != nil || got != artifactSHA([]byte("console build")) {
+ t.Fatal(got, 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 +366,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..f1a407f29
--- /dev/null
+++ b/internal/host/activation_console_upgrade_rootrunner_linux_test.go
@@ -0,0 +1,471 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+// 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")
+}
+
+// TestRootActivationConsoleUpgradeSourceAlias: an upgrade never takes its
+// source from its own slots or temp file, whether named by a path inside
+// the directory or reached through a bind mount of it (same device and
+// inode, another path). Each refuses with nothing changed.
+func TestRootActivationConsoleUpgradeSourceAlias(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ // console = A, console.prev = B: the target B is in console.prev.
+ prev := f.path("console.prev")
+ f.check(os.WriteFile(prev+".x", f.body["B"], 0o755))
+ f.check(os.Rename(prev+".x", prev))
+ alias := filepath.Join(f.d, "alias")
+ f.check(os.Mkdir(alias, 0o755))
+ f.check(unix.Mount(f.dir, alias, "", unix.MS_BIND, ""))
+ t.Cleanup(func() { unix.Unmount(alias, unix.MNT_DETACH) })
+
+ refuse := func(what, source, to, from string) {
+ t.Helper()
+ before := f.entries()
+ for _, dry := range []bool{true, false} {
+ if got, err := upgradeProtectedConsole(f.dir, source, f.sha[to], f.sha[from], dry); err == nil {
+ t.Fatal("accepted", what, dry, got)
+ }
+ if after := f.entries(); after != before {
+ t.Fatal(what, "changed the directory", before, after)
+ }
+ }
+ }
+ refuse("console.prev by path", prev, "B", "A")
+ refuse("console.prev through a bind mount", filepath.Join(alias, "console.prev"), "B", "A")
+ refuse("console by path", f.path("console"), "A", "B")
+ refuse("console through a bind mount", filepath.Join(alias, "console"), "A", "B")
+ // Another file inside the directory, not a slot, is refused by path.
+ f.check(os.WriteFile(f.path("console.src"), f.body["C"], 0o755))
+ refuse("a file in the directory", f.path("console.src"), "C", "A")
+ f.check(os.Remove(f.path("console.src")))
+
+ // A leftover console.next holding the target.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["C"], 0o755))
+ refuse("console.next by path", f.path(consoleUpgradeTemp), "C", "A")
+ refuse("console.next through a bind mount", filepath.Join(alias, consoleUpgradeTemp), "C", "A")
+ if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); !bytes.Equal(b, f.body["C"]) {
+ t.Fatal("console.next changed")
+ }
+ f.check(os.Remove(f.path(consoleUpgradeTemp)))
+ f.slot("console", "A")
+ f.slot("console.prev", "B")
+
+ // The same target from a source outside the directory upgrades.
+ if got, err := f.upgrade("B", "A", false); err != nil || got.Already {
+ t.Fatal("upgrade from an outside source", got, err)
+ }
+ f.want("B", "A")
+}
+
+// TestRootActivationConsoleUpgradeAlreadySyncs: a run cut off after its
+// second rename, before the directory fsync, leaves console = target; the
+// rerun is Already and fsyncs the directory first, and a failed fsync is
+// the rerun's error. A dry rerun does not sync.
+func TestRootActivationConsoleUpgradeAlreadySyncs(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ var dirSt unix.Stat_t
+ f.check(unix.Stat(f.dir, &dirSt))
+ var calls int
+ var fail error
+ saved := consoleUpgradeSync
+ t.Cleanup(func() { consoleUpgradeSync = saved })
+ consoleUpgradeSync = func(fd int) error {
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil || st.Dev != dirSt.Dev || st.Ino != dirSt.Ino {
+ t.Fatal("synced something other than the directory", err)
+ }
+ calls++
+ if fail != nil {
+ return fail
+ }
+ return saved(fd)
+ }
+
+ // The state just before the final fsync.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["B"], 0o755))
+ f.check(os.Rename(f.path("console"), f.path("console.prev")))
+ f.check(os.Rename(f.path(consoleUpgradeTemp), f.path("console")))
+ f.want("B", "A")
+
+ if got, err := f.upgrade("B", "A", true); err != nil || !got.Already || calls != 0 {
+ t.Fatal("dry rerun", got, err, calls)
+ }
+ if got, err := f.upgrade("B", "A", false); err != nil || !got.Already || calls != 1 {
+ t.Fatal("rerun", got, err, calls)
+ }
+ fail = syscall.EIO
+ if got, err := f.upgrade("B", "A", false); err != syscall.EIO || calls != 2 {
+ t.Fatal("rerun with a failing fsync", got, err, calls)
+ }
+ fail = nil
+ f.want("B", "A")
+
+ // A full upgrade syncs once, after its renames.
+ if got, err := f.upgrade("C", "B", false); err != nil || got.Already || calls != 3 {
+ t.Fatal("upgrade", got, err, calls)
+ }
+ f.want("C", "B")
+}
+
+// TestRootActivationConsoleUpgradeOversizedSlot: a slot larger than
+// consoleMaxBytes is refused, not hashed by its prefix, so --from naming
+// the prefix's digest cannot match it.
+func TestRootActivationConsoleUpgradeOversizedSlot(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ cur := f.path("console")
+ big := cur + ".big"
+ f.check(os.WriteFile(big, nil, 0o755))
+ f.check(os.Truncate(big, consoleMaxBytes+2)) // sparse: zeros
+ f.check(os.Rename(big, cur))
+ prefix := zeroDigest(t, consoleMaxBytes+1) // what a LimitReader(max+1) hashes
+ f.sha["prefix"] = prefix
+ f.refused("oversized console", "B", "prefix")
+ if _, err := os.Lstat(cur); err != nil {
+ t.Fatal(err)
+ }
+ // As console.prev in the crash state it is refused too.
+ f.check(os.Rename(cur, f.path("console.prev")))
+ f.refused("oversized console.prev", "B", "prefix")
+}
+
+// zeroDigest is the sha256 of n zero bytes.
+func zeroDigest(t *testing.T, n int64) string {
+ t.Helper()
+ h := sha256.New()
+ buf := make([]byte, 1<<20)
+ for n > 0 {
+ k := int64(len(buf))
+ if n < k {
+ k = n
+ }
+ h.Write(buf[:k])
+ n -= k
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 95983645d..6357fcd50 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -981,6 +981,20 @@ 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.
+ // census-ruling-install-console-upgrade: RULED by platform-d8, accepted as proposed at da0a33b7 and b913bb65
+ // (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. Round 2 (Astra's
+ // review): readConsoleSource's read moves into readConsoleSourceFile,
+ // and consoleDigest and consoleCounter.Read are new, also 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..5287803a0
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
@@ -0,0 +1,13 @@
+# 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.
+# Round 2 (Astra's review of da0a33b7, MERGE: block): F1 refuses a source aliasing console, console.prev or console.next, by path and by device and inode (readConsoleSource's read moves into readConsoleSourceFile); F2 fsyncs the directory before a non-dry Already; F3 refuses a slot over 512 MiB instead of hashing its prefix (consoleDigest, consoleCounter.Read). Same marker; the delta is asked to be re-ruled.
+@coverage internal/host 558ba713ea71cd14d2856d8d2a0dd6cdeb44f22cd61185ca2681dd82fad38cc9
+@remove internal/host/activation_artifacts_linux.go consoleSlotHolds
+@remove internal/host/activation_artifacts_linux.go readConsoleSource
+internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=census-ruling-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=census-ruling-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) then hashed through consoleDigest, which refuses a slot over 512 MiB rather than hashing its prefix; anything else refuses. consoleSlotHolds (now no read site) compares the hash as before file:Fstat@597,file:Fstatat@91 c70ad73a0cb712b0be9d10fe32436c222983278706bde6530ffa0a2d8f34933d
+internal/host/activation_artifacts_linux.go upgradeProtectedConsole authority G,J4 rev3=census-ruling-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; a source path in the console directory refuses, and under the lock so does a source whose descriptor has the device and inode of console, console.prev or console.next (round 2, F1); 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, after fsyncing the directory on a non-dry run (round 2, F2); 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 and consoleDigest; dry run writes nothing file:Fstat@2622,file:Fstatat@1640,file:Fstatat@905 03605a615c7ee020f7deca032d2aeecde3fa43b8017c5dd91134374b701b05f4
+internal/host/activation_artifacts_linux.go writeConsoleTemp authority G,J4 rev3=census-ruling-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
+internal/host/activation_artifacts_linux.go readConsoleSourceFile authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console, with or without --upgrade);basis=readConsoleSource's read moved here unchanged (round 2, F1): the source opened with no symlink followed on its path, regular file with one link, at most 512 MiB, refused unless it hashes to the identity's console sha256; it also returns the descriptor's stat so an upgrade can refuse a source that is one of its own slots. readConsoleSource (now no read site) wraps it for the first install file:Fstat@220,file:OpenFile@109,file:ReadAll@302 353d920fac5dc3fab8d6512e5d4ecc19648258a7e580d66cb0e1321e37a8b6ff
+internal/host/activation_artifacts_linux.go consoleDigest authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade) and the first install's slot checks through consoleSlotHolds;basis=round 2, F3: hashes a held console slot through EOF; refuses a size over 512 MiB by fstat and by the bytes read (consoleCounter), never hashing a prefix file:Fstat@80 2dadc9634701dbc006ed8f32e321562a0968af0e0b8433510327c6d7d010db64
+internal/host/activation_artifacts_linux.go consoleCounter.Read authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=round 2, F3: passes reads through from consoleDigest's bounded reader of a held slot and counts them; no path, no decision of its own file:Read@25,file:Read@66 08f4c255e542f87139c5addb173b18649c7266735b85165b3e63a69e7462755f