carlos / platform Public

install-console --upgrade, round 2: refuse aliased sources, fsync before Already, refuse oversized slots

Astra's review of da0a33b7 (MERGE: block):

- F1 (MEDIUM): an upgrade could consume its own source. A --source inside
  the console directory now refuses by path, and under the lock a source
  whose opened descriptor has the device and inode of console,
  console.prev or console.next refuses too (a bind mount or any other
  path). readConsoleSource's read moves into readConsoleSourceFile, which
  also returns the descriptor's stat.
- F2 (MEDIUM): a non-dry Already now fsyncs the held directory and
  propagates a failure, so a rerun after a run cut off before its final
  fsync makes the renames durable. The fsync goes through
  consoleUpgradeSync, a seam the test replaces.
- F3 (LOW): consoleDigest hashes a slot through EOF and refuses one over
  consoleMaxBytes, by size and by bytes read, in consoleSlotSHA and the
  upgrade's readback, instead of hashing its prefix.

New root-runner tests: TestRootActivationConsoleUpgradeSourceAlias,
TestRootActivationConsoleUpgradeAlreadySyncs,
TestRootActivationConsoleUpgradeOversizedSlot; ordinary test
TestConsoleDigestRefusesOversized. Census overlay re-pinned in place,
still rev3=PROPOSED-install-console-upgrade.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b913bb657e3e31adc414163200a6c3097b80fbcf parent da0a33b
6 files changed, +301 −13
  • docs/superpowers/notes/census-proposal-install-console-upgrade.md +10 −0
  • internal/host/activation_artifacts_linux.go +84 −8
  • internal/host/activation_artifacts_linux_test.go +50 −0
  • internal/host/activation_console_upgrade_rootrunner_linux_test.go +145 −0
  • internal/provision/plan14_root_reads_test.go +4 −2
  • internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv +8 −3

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	329.808s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.689s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.305s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	250.624s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.532s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.134s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	0.702s
?   	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.706s
ok  	github.com/carlosframework/platform/internal/changelog	0.347s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.812s
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	415.143s
?   	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.077s
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.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.385s
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	45.001s
?   	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.148s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.016s
ok  	github.com/carlosframework/platform/internal/geoip	0.035s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.184s
ok  	github.com/carlosframework/platform/internal/host	189.692s
ok  	github.com/carlosframework/platform/internal/hostactors	5.546s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.015s
ok  	github.com/carlosframework/platform/internal/hostbox	2.880s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.606s
ok  	github.com/carlosframework/platform/internal/hostenv	0.015s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.015s
ok  	github.com/carlosframework/platform/internal/hostexec	5.049s
ok  	github.com/carlosframework/platform/internal/hostfence	0.030s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.493s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	1.041s
ok  	github.com/carlosframework/platform/internal/hostops	1.624s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.012s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.013s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.469s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.145s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.012s
ok  	github.com/carlosframework/platform/internal/infraview	0.012s
ok  	github.com/carlosframework/platform/internal/instancectl	5.237s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.747s
ok  	github.com/carlosframework/platform/internal/instancefs	0.018s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.019s
ok  	github.com/carlosframework/platform/internal/layout	1.599s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.004s
ok  	github.com/carlosframework/platform/internal/lease	9.953s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.385s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.180s
ok  	github.com/carlosframework/platform/internal/maintenance	0.089s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.511s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.090s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.181s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.797s
ok  	github.com/carlosframework/platform/internal/objstore	0.182s
ok  	github.com/carlosframework/platform/internal/ops	0.186s
ok  	github.com/carlosframework/platform/internal/park	0.218s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	39.444s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.445s
ok  	github.com/carlosframework/platform/internal/relay	7.234s
ok  	github.com/carlosframework/platform/internal/release	24.394s
ok  	github.com/carlosframework/platform/internal/rollout	3.766s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.009s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.058s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.572s
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.520s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.169s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.910s
ok  	github.com/carlosframework/platform/internal/status	0.064s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.340s
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.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.007s
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.584s
=== 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.024s
=== RUN   TestT4StorageProbeMinio
2026/10/20 19:12:38 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E052ADD32935EE, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
minio contract: PASS
diff --git a/docs/superpowers/notes/census-proposal-install-console-upgrade.md b/docs/superpowers/notes/census-proposal-install-console-upgrade.md
index 3fdf33082..d3198fede 100644
--- a/docs/superpowers/notes/census-proposal-install-console-upgrade.md
+++ b/docs/superpowers/notes/census-proposal-install-console-upgrade.md
@@ -38,3 +38,13 @@ No box agent, update, recovery or edge path reaches a new or changed unit. guard
## 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 8cafe5e97..800bce5a1 100644
--- a/internal/host/activation_artifacts_linux.go
+++ b/internal/host/activation_artifacts_linux.go
@@ -380,9 +380,47 @@ func consoleSlotSHA(d *os.File, name string) (string, error) {
if !shape(&held) || held.Dev != st.Dev || held.Ino != st.Ino {
return "", errActivation
}
- return hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1))
+ 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 "", err
+ }
+ if r.n > consoleMaxBytes {
+ return "", errActivation
+ }
+ 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.
@@ -403,6 +441,14 @@ const consoleUpgradeTemp = "console.next"
// 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
@@ -417,10 +463,13 @@ func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleIn
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, err := readConsoleSource(source, sha)
+ body, src, err := readConsoleSourceFile(source, sha)
if err != nil {
return got, err
}
@@ -434,6 +483,16 @@ func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleIn
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
@@ -444,6 +503,11 @@ func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleIn
}
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) {
@@ -477,7 +541,7 @@ func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleIn
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 {
+ if err = consoleUpgradeSync(int(d.Fd())); err != nil {
return got, err
}
for path, want := range map[string]string{got.Path: sha, got.Previous: from} {
@@ -488,7 +552,7 @@ func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleIn
err = unix.Fstat(int(f.Fd()), &st)
var hash string
if err == nil {
- hash, err = hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1))
+ hash, err = consoleDigest(f)
}
f.Close()
if err != nil {
@@ -546,20 +610,32 @@ func isLowerHex(s string, n int) bool {
// 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
diff --git a/internal/host/activation_artifacts_linux_test.go b/internal/host/activation_artifacts_linux_test.go
index 5ee53f9ab..1327eb338 100644
--- a/internal/host/activation_artifacts_linux_test.go
+++ b/internal/host/activation_artifacts_linux_test.go
@@ -253,6 +253,56 @@ func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) {
}
}
+// 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) {
diff --git a/internal/host/activation_console_upgrade_rootrunner_linux_test.go b/internal/host/activation_console_upgrade_rootrunner_linux_test.go
index 25fa13f0a..f1a407f29 100644
--- a/internal/host/activation_console_upgrade_rootrunner_linux_test.go
+++ b/internal/host/activation_console_upgrade_rootrunner_linux_test.go
@@ -4,12 +4,16 @@ 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
@@ -324,3 +328,144 @@ func TestRootActivationConsoleUpgradeIdentity(t *testing.T) {
}
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 da7ee3a9a..2bb581bb2 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -988,8 +988,10 @@ func TestPlan14RootReadCensus(t *testing.T) {
// (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.
+ // 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)
}
diff --git a/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
index a7de60d9d..1ffbc0e53 100644
--- a/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
+++ b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
@@ -1,8 +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.
-@coverage internal/host d6cd5cfd57f4d54df83bbf42c9463d959e80f430ffabfc3160c258c30828cabf
+# 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=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 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) 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=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; 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=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
+internal/host/activation_artifacts_linux.go readConsoleSourceFile authority G,J4 rev3=PROPOSED-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=PROPOSED-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=PROPOSED-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