claim-prev-rehearsal: install-channel --policy arms an updater-installed box (ruling R1)
carlos system install-channel --policy FILE takes the armed policy claims run sign-policy wrote as the host floor before one is published. The policy is verified as guard-arm verifies it; a published floor, or either half of one, must be its bytes. On a box carlos system update has moved to the channel release (carlos already its signed artifact, guard not armed), carlos.prev is refreshed from carlos by the updater's own notePrev, so guard-arm's two-slot rule passes. Without --policy nothing changes. Census overlay PROPOSED.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+640
−18
docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md+33 −0internal/host/installchannel.go+185 −18internal/host/installchannel_policy_test.go+408 −0internal/provision/plan14_root_reads_test.go+7 −0internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv+7 −0
diff --git a/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md| new file mode 100644 |
| index 000000000..906a619e0 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md |
| @@ -0,0 +1,33 @@ |
| +# Census proposal: claim-prev-rehearsal |
| + |
| +Status: PROPOSED. Not ruled. Overlay `internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv`, loaded last (after `claim-step6-artifacts-fix2`), every row `rev3=PROPOSED-claim-prev-rehearsal`. |
| + |
| +## What changed |
| + |
| +`carlos system install-channel` takes `--policy FILE` (controller ruling R1, with platform-00's binding additions). FILE is exactly what `carlos system claims run` sign-policy writes. With it: |
| + |
| +- the policy's floor, verified as guard-arm verifies it (the root-signed moves-protocol authorization, for this deployment's bucket, under `CARLOS_RELEASE_PUBKEY`), stands in for the published floor while none is published; |
| +- a published floor, or either half of one, must be the policy's bytes, or the run is refused; |
| +- one updated box is admitted: `applied.json` names the channel release, `carlos` hashes to its signed artifact, and the guard is not armed. Its `carlos.prev` is refreshed from `carlos` by the updater's own `notePrev`, and the identity is kept beside the slots. |
| + |
| +Without `--policy` nothing changes (ruling 6). |
| + |
| +## Units |
| + |
| +| Unit | Change | Class | Executing identity | |
| +|---|---|---|---| |
| +| `internal/host/installchannel.go` `readChannelPolicy` | new; read site `file:ReadFile` | authority F,J4 | uid 0 at the box's attended root shell, running `carlos system install-channel --policy`. Reads the operator-staged policy file once, before the apply lock or any store read. | |
| +| `internal/host/installchannel.go` `channelInstall.refreshPrev` | new; read site `file:Lstat` | authority F,J4 | the same uid 0 root shell, under root's apply lock. Lstats `carlos.prev` to refuse a symlink or a non-regular file. | |
| +| `internal/host/installchannel.go` `channelInstall.run` | unchanged digest | F,J4 (region-ha-2b) | unchanged | |
| +| `internal/host/installchannel_linux.go` `slotState`, `var openat2`, `removeBeneath` | unchanged digests | F,J4 (region-ha-2b) | unchanged; 2b's rows stand and are not restated | |
| +| `channelInstall.install`, `channelInstall.checkFloor`, `policyFloorPublished`, `channelPolicy`, `RunSystemInstallChannel` | changed or new, no read site of their own | envelope | the same root shell | |
| + |
| +The `internal/host` envelope is re-pinned. The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches either new unit. |
| + |
| +## 9B8 |
| + |
| +No new bucket writer. install-channel writes only local files (`carlos.prev`, its version sidecar through `notePrev`, and `channel-identity.json`). No 9B8 entry is proposed. |
| + |
| +## Test files |
| + |
| +The rewritten root-runner witness (`activation_console_rootrunner_linux_test.go`) and the new `installchannel_policy_test.go` are tests and are outside the census. |
diff --git a/internal/host/installchannel.go b/internal/host/installchannel.go| index 6e7dbf2e1..5d661b1f6 100644 |
| --- a/internal/host/installchannel.go |
| +++ b/internal/host/installchannel.go |
| @@ -1,8 +1,10 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ed25519" |
| + "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -37,10 +39,25 @@ import ( |
| // another release this way or through an update, is refused: its releases |
| // come through `carlos system update`. Run again on a box it has already |
| // installed, it changes nothing. |
| +// |
| +// --policy FILE (claim trial, prev-rehearsal ruling R1) is for arming a box |
| +// before the host floor is published. FILE is exactly what `carlos system |
| +// claims run` sign-policy writes. Its floor is verified as guard-arm |
| +// verifies it (the root-signed moves-protocol authorization, for this |
| +// deployment's bucket, under CARLOS_RELEASE_PUBKEY) and stands in for the |
| +// published floor while there is none; a published floor, or either half |
| +// of one, must be the policy's own bytes. Every other refusal stands. It |
| +// also admits the one updated box A2-2 would otherwise leave unarmable: |
| +// one whose carlos already is the channel release's signed artifact. Its |
| +// carlos.prev, the release before, is replaced by that artifact |
| +// (refreshPrev), because guard-arm requires both slots to be the |
| +// identity's release. Without --policy nothing here changes. |
| -const installChannelUsage = "usage: carlos system install-channel\n\n" + |
| +const installChannelUsage = "usage: carlos system install-channel [--policy FILE]\n\n" + |
| "Install the release this box's channel names, as root, on a box that has none yet.\n" |
| +const installChannelPolicyFlag = "the armed policy carlos system claims run sign-policy wrote, used as the host floor until one is published" |
| + |
| const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary" |
| // channelIdentityPath is where the installed release's signed identity is |
| @@ -57,6 +74,8 @@ type channelInstall struct { |
| bucket string |
| fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error |
| own func(path string) error |
| + // policy is --policy's file, byte for byte; nil without the flag. |
| + policy []byte |
| } |
| // slotKind is what one protected slot holds (slotState). |
| @@ -72,11 +91,14 @@ const ( |
| type channelInstalled struct { |
| Version, Tag, Channel string |
| Already bool |
| + // Replaced: --policy replaced an updated box's carlos.prev (refreshPrev). |
| + Replaced bool |
| } |
| // RunSystemInstallChannel is `carlos system install-channel`. |
| func RunSystemInstallChannel(args []string) { |
| fs := flag.NewFlagSet("system install-channel", flag.ExitOnError) |
| + policyPath := fs.String("policy", "", installChannelPolicyFlag) |
| fs.Usage = cliflag.Usage(fs, installChannelUsage) |
| _ = fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -101,6 +123,14 @@ func RunSystemInstallChannel(args []string) { |
| }, |
| own: ownByService, |
| } |
| + if *policyPath != "" { |
| + raw, err := readChannelPolicy(*policyPath) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + in.policy = raw |
| + } |
| got, err := in.run(context.Background()) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| @@ -109,7 +139,40 @@ func RunSystemInstallChannel(args []string) { |
| fmt.Println(installedLine(got)) |
| } |
| +// readChannelPolicy reads --policy's file and refuses, before anything else |
| +// is read, a file that is not an armed policy at all. Its signature is |
| +// checked once the release key is known (channelPolicy). |
| +func readChannelPolicy(path string) ([]byte, error) { |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, fmt.Errorf("the policy %s could not be read, so nothing was installed: %w", path, err) |
| + } |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + return nil, fmt.Errorf("%s is not an armed policy signed for this deployment, so nothing was installed", path) |
| + } |
| + return raw, nil |
| +} |
| + |
| +// channelPolicy is guard-arm's check of --policy (guardArmInputs, first |
| +// half): an armed policy whose floor carries the root-signed |
| +// moves-protocol authorization for this deployment's bucket. |
| +func channelPolicy(raw []byte, pub ed25519.PublicKey, bucket string) (hostfloor.Policy, hostfloor.Verified, error) { |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor |
| + } |
| + f, err := p.Verify(pub, bucket) |
| + if err != nil { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, err |
| + } |
| + return p, f, nil |
| +} |
| + |
| func installedLine(got channelInstalled) string { |
| + if got.Replaced { |
| + return fmt.Sprintf("replaced the rollback copy carlos.prev with %s (%s) from the %s channel, the release carlos already runs. the release carlos.prev held before is no longer on this box", got.Version, got.Tag, got.Channel) |
| + } |
| if got.Already { |
| return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel) |
| } |
| @@ -147,6 +210,15 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err) |
| } |
| got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel} |
| + var policy *hostfloor.Policy |
| + var policyFloor hostfloor.Verified |
| + if in.policy != nil { |
| + pol, f, err := channelPolicy(in.policy, pub, in.bucket) |
| + if err != nil { |
| + return got, errors.New("the --policy file is not an armed policy signed for this deployment, so nothing was installed") |
| + } |
| + policy, policyFloor = &pol, f |
| + } |
| art, ok := p.Current.Artifacts[a.platform] |
| if !ok { |
| return got, fmt.Errorf("the %s channel names %s with no artifact for %s, so nothing was installed", a.channel, p.Current.Version, a.platform) |
| @@ -165,7 +237,7 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| if applied != nil && applied.Version != p.Current.Version { |
| return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| } |
| - wire, err := in.checkFloor(ctx, p, art, pub) |
| + wire, err := in.checkFloor(ctx, p, art, pub, policy, policyFloor) |
| if err != nil { |
| return got, err |
| } |
| @@ -179,6 +251,9 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| got.Already = true |
| return got, nil |
| } |
| + if policy != nil { |
| + return in.refreshPrev(ctx, p, art, wire, got) |
| + } |
| return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| } |
| // With no applied.json, a slot that holds anything but the signed |
| @@ -244,23 +319,33 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| // identity, and returns the identity once the release passes the floor |
| // exactly as guard-arm will check it. No floor, or one that cannot be read, |
| // refuses: a joining box could not arm its guard without one, and a read |
| -// that failed says nothing about where the floor is. |
| -func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, error) { |
| +// that failed says nothing about where the floor is. With --policy |
| +// (policy non-nil) the policy's verified floor is the floor instead |
| +// (policyFloorPublished). |
| +func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey, policy *hostfloor.Policy, policyFloor hostfloor.Verified) ([]byte, error) { |
| s := in.a.store |
| - raw, _, err := s.Get(ctx, hostfloor.Key) |
| - if errors.Is(err, os.ErrNotExist) { |
| - return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| - } |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| - } |
| - auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| - } |
| - floor, err := hostfloor.Verify(pub, in.bucket, raw, auth) |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + var floor hostfloor.Verified |
| + if policy != nil { |
| + if err := policyFloorPublished(ctx, s, *policy); err != nil { |
| + return nil, err |
| + } |
| + floor = policyFloor |
| + } else { |
| + raw, _, err := s.Get(ctx, hostfloor.Key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + floor, err = hostfloor.Verify(pub, in.bucket, raw, auth) |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + } |
| } |
| key, err := release.IdentityKey(p.Current.ReleaseTag) |
| if err != nil { |
| @@ -276,6 +361,88 @@ func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointe |
| return wire, nil |
| } |
| +// policyFloorPublished checks --policy against what the bucket holds: no |
| +// floor published, or a published pair (or the half of one that a torn |
| +// publication leaves; hostfloor.Publish writes the authorization first) |
| +// that is the policy's floor and authorization byte for byte. Anything |
| +// else, a read that fails included, refuses. |
| +func policyFloorPublished(ctx context.Context, s release.Store, policy hostfloor.Policy) error { |
| + for _, half := range []struct { |
| + key string |
| + want []byte |
| + }{{hostfloor.AuthKey, policy.Authorization}, {hostfloor.Key, policy.Floor}} { |
| + got, _, err := s.Get(ctx, half.key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + if !bytes.Equal(got, half.want) { |
| + return errors.New("the published host floor is not the floor in the --policy file, so nothing was installed") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// refreshPrev is --policy's one admission of an updated box (applied.json |
| +// names the channel release, and the slots and the identity are not yet |
| +// install-channel's): carlos must already hold the channel release's |
| +// signed artifact, hashed from a descriptor opened with O_NOFOLLOW |
| +// (slotState). Its carlos.prev, the release the update replaced, is then |
| +// refreshed from carlos the way an update refreshes it (notePrev: the |
| +// sidecar and verdicts of the old carlos.prev removed first, the copy, the |
| +// version sidecar, the floor's judgement), and the identity is kept beside |
| +// them. Nothing is fetched, and applied.json and seen.json are the |
| +// update's and stay as they are. The caller has already refused an armed |
| +// guard, another release and a release below the policy's floor. |
| +// |
| +// Why A2-2 allows it: A2-2 puts the identity's artifact in both slots so |
| +// guard-arm's two-slot rule can pass, and on a fresh box carlos.prev is |
| +// then a copy of carlos. Here carlos already is that artifact, so this |
| +// reaches A2-2's end state exactly. What is lost is the rollback to the |
| +// release before, which is below the floor whenever the floor is the |
| +// release being armed, and which the armed guard would refuse to run. |
| +func (in channelInstall) refreshPrev(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, wire []byte, got channelInstalled) (channelInstalled, error) { |
| + a := in.a |
| + current, prev := systemBinaryPath(a.optDir), systemPrevPath(a.optDir) |
| + state, err := slotState(current, art.SHA256) |
| + if err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } |
| + if state != slotHolds { |
| + return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", current) |
| + } |
| + held, err := slotState(prev, art.SHA256) |
| + if err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } |
| + if held == slotForeign { |
| + // The release before is replaced; a symlink or anything but a |
| + // regular file is not what an update leaves, and is refused. |
| + if fi, err := os.Lstat(prev); err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } else if !fi.Mode().IsRegular() { |
| + return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", prev) |
| + } |
| + } |
| + // Always, even over a carlos.prev a cut-off run already copied, so the |
| + // version sidecar is this release's. |
| + if err := a.notePrev(ctx, true, p.Current.Version); err != nil { |
| + return got, err |
| + } |
| + for _, slot := range []string{prev, current} { |
| + if state, err := slotState(slot, art.SHA256); err != nil || state != slotHolds { |
| + return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| + } |
| + } |
| + if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| + return got, err |
| + } |
| + got.Replaced = true |
| + return got, nil |
| +} |
| + |
| // cacheDir makes the agent's cache entry for version and reconciles the |
| // ownership of every directory on its path below the data dir, on every |
| // run: system, system/router and system/router/<version> are created as |
diff --git a/internal/host/installchannel_policy_test.go b/internal/host/installchannel_policy_test.go| new file mode 100644 |
| index 000000000..6929f7f58 |
| --- /dev/null |
| +++ b/internal/host/installchannel_policy_test.go |
| @@ -0,0 +1,408 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// install-channel --policy (claim trial prev-rehearsal, ruling R1). The box |
| +// these tests start from is made by the platform's own update path, never |
| +// by hand: v1.3.0 on disk, the channel promoted to v1.4.0, the agent's |
| +// request and cache written as systemLeg writes them, then the real apply |
| +// (systemApplier.Run) moving v1.3.0 to carlos.prev. That is the trial's |
| +// leo-climbing: applied.json names the channel release, carlos is its |
| +// artifact, carlos.prev the release before, and no channel identity. |
| + |
| +// updatedChannelBox returns that box. old and body are the two releases' |
| +// bytes. |
| +func updatedChannelBox(t *testing.T) (f *channelFixture, old []byte) { |
| + t.Helper() |
| + f = &channelFixture{applyFixture: newApplyFixture(t, "stable", "v1.3.0")} |
| + old, err := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| + f.publish(t, "v1.4.0", f.body) |
| + ctx := context.Background() |
| + p, _, err := release.GetSystemPointer(ctx, f.store, f.pub, release.SystemComponentRouter, "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The agent's half of the update (systemLeg.fetch and writeRequest). |
| + leg := &systemLeg{dataDir: f.a.dataDir, fetchTo: func(ctx context.Context, key, dst, sha string) error { |
| + return release.FetchToFile(ctx, f.store, key, dst, systemArtifactLimit, sha) |
| + }} |
| + pending := &systemPending{version: "v1.4.0", promotedAt: p.PromotedAt, artifact: p.Current.Artifacts["linux-arm64"]} |
| + if err := leg.fetch(ctx, pending); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := leg.writeRequest(pending, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + staged := filepath.Join(f.a.stagingDir(), "carlos") |
| + f.answer["plan:"+staged] = f.answer["plan:"+systemBinaryPath(f.a.optDir)] |
| + f.answer["units:"+staged] = f.answer["units:"+systemBinaryPath(f.a.optDir)] |
| + prev := systemPrevPath(f.a.optDir) |
| + f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(f.a.optDir)] |
| + f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(f.a.optDir)] |
| + // Every binary answers `version` from its own bytes. |
| + delete(f.answer, "version:"+systemBinaryPath(f.a.optDir)) |
| + if err := f.a.Run(ctx, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied { |
| + t.Fatalf("the update did not apply: %+v", r) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, old) { |
| + t.Fatalf("the update left %q and %q", cur, prev) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("an update wrote a channel identity: %v", err) |
| + } |
| + f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| + f.fetches++ |
| + return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| + }, own: func(path string) error { |
| + f.owned = append(f.owned, path) |
| + return nil |
| + }} |
| + return f, old |
| +} |
| + |
| +// signedPolicy is what claims run sign-policy writes for floor. |
| +func signedPolicy(t *testing.T, key ed25519.PrivateKey, bucket string, floor hostfloor.Floor) []byte { |
| + t.Helper() |
| + p, err := hostfloor.ArmedPolicy(release.RootSigner(key), bucket, floor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := json.Marshal(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +// boxFiles is every file install-channel or the update keeps in the root |
| +// directory and the two slots, by name, so a refusal can be shown to have |
| +// changed nothing. |
| +func boxFiles(t *testing.T, f *channelFixture) map[string]string { |
| + t.Helper() |
| + got := map[string]string{} |
| + for _, dir := range []string{f.a.optDir, systemRootDir(f.a.optDir)} { |
| + ents, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range ents { |
| + path := filepath.Join(dir, e.Name()) |
| + if fi, err := os.Lstat(path); err == nil && fi.Mode().IsRegular() { |
| + b, _ := os.ReadFile(path) |
| + got[path] = string(b) |
| + } else if err == nil { |
| + got[path] = fi.Mode().String() |
| + } |
| + } |
| + } |
| + return got |
| +} |
| + |
| +func TestInstallChannelPolicyArmsAnUpdatedBoxAtTheChannelHead(t *testing.T) { |
| + f, old := updatedChannelBox(t) |
| + ctx := context.Background() |
| + // Without --policy the box is refused exactly as before, and not |
| + // changed: with no floor published, for that (the trial's loop), and |
| + // with one, as an updated box (the sibling |
| + // TestInstallChannelRefusesAnUpdatedBoxAtTheChannelHead). |
| + before := boxFiles(t, f) |
| + if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" { |
| + t.Fatalf("without --policy: %v", err) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + applied, _ := os.ReadFile(f.a.file("applied.json")) |
| + seen, _ := os.ReadFile(f.a.file("seen.json")) |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.Replaced || got.Already || got.Version != "v1.4.0" || got.Tag != "v1.4.0" { |
| + t.Fatalf("installed %+v", got) |
| + } |
| + if line := installedLine(got); line != "replaced the rollback copy carlos.prev with v1.4.0 (v1.4.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| + t.Fatalf("line %q", line) |
| + } |
| + cur, prev := f.slots(t) |
| + if !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || bytes.Equal(prev, old) { |
| + t.Fatalf("slots hold %q and %q", cur, prev) |
| + } |
| + // Nothing was fetched: carlos already is the artifact. |
| + if f.fetches != 0 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| + // The update's records stand; the version sidecar names the release |
| + // carlos.prev now holds. |
| + if a, _ := os.ReadFile(f.a.file("applied.json")); !bytes.Equal(a, applied) { |
| + t.Fatalf("applied.json changed: %s", a) |
| + } |
| + if s, _ := os.ReadFile(f.a.file("seen.json")); !bytes.Equal(s, seen) { |
| + t.Fatalf("seen.json changed: %s", s) |
| + } |
| + if v, ok := readLine(prevVersionPath(f.a.optDir)); !ok || v != "v1.4.0" { |
| + t.Fatalf("carlos.prev's version sidecar is %q %v", v, ok) |
| + } |
| + // The identity kept beside the slots is the published one, and |
| + // guard-arm's two checks pass: its inputs, and both slots. |
| + key, _ := release.IdentityKey("v1.4.0") |
| + published, _, err := f.store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + held, err := os.ReadFile(channelIdentityPath(f.a.optDir)) |
| + if err != nil || !bytes.Equal(held, published) { |
| + t.Fatalf("identity %v", err) |
| + } |
| + _, floor, err := guardArmInputs(f.in.policy, held, f.pub, "bucket", "v1.4.0", "linux-arm64", hostfloor.Digest(f.body)) |
| + if err != nil { |
| + t.Fatalf("guard-arm's inputs: %v", err) |
| + } |
| + for _, slot := range []string{systemBinaryPath(f.a.optDir), systemPrevPath(f.a.optDir)} { |
| + b, _ := os.ReadFile(slot) |
| + if err := hostfloor.CheckImage(f.pub, floor, held, "v1.4.0", "linux-arm64", hostfloor.Digest(b)); err != nil { |
| + t.Fatalf("guard-arm would refuse %s: %v", slot, err) |
| + } |
| + } |
| + // Run again: nothing to do. |
| + again, err := f.in.run(ctx) |
| + if err != nil || !again.Already || again.Replaced || f.fetches != 0 { |
| + t.Fatalf("second run %+v %v", again, err) |
| + } |
| + // The floor, once published from the same plan, is the policy's own |
| + // bytes, so the run still passes; the same with half of it published, |
| + // as a publication cut off after its first write leaves it. |
| + var policy hostfloor.Policy |
| + if err := json.Unmarshal(f.in.policy, &policy); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.CreateExact(ctx, f.store, hostfloor.AuthKey, policy.Authorization); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("half published: %+v %v", got, err) |
| + } |
| + if err := hostfloor.Publish(ctx, f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("published: %+v %v", got, err) |
| + } |
| +} |
| + |
| +// Each refusal of --policy on the updated box, and on a fresh one, leaves |
| +// the box as it was and fetches nothing. |
| +func TestInstallChannelPolicyRefusals(t *testing.T) { |
| + _, otherKey, _ := ed25519.GenerateKey(nil) |
| + for _, c := range []struct { |
| + name string |
| + setup func(t *testing.T, f *channelFixture) |
| + want string |
| + }{ |
| + {"signed by another key", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, otherKey, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"for another bucket", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "another-bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"re-indented", func(t *testing.T, f *channelFixture) { |
| + var b bytes.Buffer |
| + if err := json.Indent(&b, signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}), "", " "); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.in.policy = b.Bytes() |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"unpublished mode", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = []byte(`{"schema":1,"deployment_bucket":"bucket","mode":"unpublished"}`) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"an armed guard", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), f.in.policy, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "this box's host lifecycle guard is armed, so carlos system update installs its releases"}, |
| + {"another release", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n")) |
| + }, "this box already runs v1.4.0, so carlos system update installs its releases"}, |
| + {"below the floor", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"}) |
| + }, "v1.4.0 (v1.4.0) is below the host floor v2.0.0 or is not the release its identity names, so nothing was installed"}, |
| + {"another floor published", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| + {"another floor half published", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + _, auth, err := hostfloor.Authorize(release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.CreateExact(context.Background(), f.store, hostfloor.AuthKey, auth); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| + {"an unreadable floor", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + open := f.a.openStore |
| + f.a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| + s, k, err := open(ctx) |
| + return floorUnreadable{s}, k, err |
| + } |
| + }, "the host floor could not be read, so nothing was installed: door restarting"}, |
| + {"a foreign carlos", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1.4.0 rebuilt\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "%opt%/carlos holds a build this command did not install, so nothing was installed"}, |
| + {"a symlinked carlos.prev", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + elsewhere := filepath.Join(t.TempDir(), "carlos.prev") |
| + if err := os.Rename(systemPrevPath(f.a.optDir), elsewhere); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(elsewhere, systemPrevPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "%opt%/carlos.prev holds a build this command did not install, so nothing was installed"}, |
| + {"an unreadable install record", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(f.a.file("applied.json"), []byte("{"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "this box's install record could not be read, so nothing was installed: unexpected end of JSON input"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + f, _ := updatedChannelBox(t) |
| + c.setup(t, f) |
| + before := boxFiles(t, f) |
| + _, err := f.in.run(context.Background()) |
| + want := strings.ReplaceAll(c.want, "%opt%", f.a.optDir) |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err %v, want %q", err, want) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + if f.fetches != 0 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a refused run kept an identity: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// On a fresh box --policy stands in for the floor that is not published |
| +// yet, and the install is the one without it: both slots, .prev first. |
| +func TestInstallChannelPolicyInstallsAFreshBoxBeforeTheFloor(t *testing.T) { |
| + f := newChannelFixture(t, "") |
| + ctx := context.Background() |
| + if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" { |
| + t.Fatalf("without --policy: %v", err) |
| + } |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(ctx) |
| + if err != nil || got.Already || got.Replaced { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + if line := installedLine(got); line != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" { |
| + t.Fatalf("line %q", line) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || f.fetches != 1 { |
| + t.Fatalf("slots %q %q after %d fetches", cur, prev, f.fetches) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("again %+v %v", got, err) |
| + } |
| + // A hand-rolled slot is still refused with --policy. |
| + g := newChannelFixture(t, "") |
| + g.in.policy = signedPolicy(t, g.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(systemPrevPath(g.a.optDir), []byte("#!/bin/sh\necho hand rolled\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := g.in.run(ctx); err == nil || err.Error() != systemPrevPath(g.a.optDir)+" holds a build this command did not install, so nothing was installed" || g.fetches != 0 { |
| + t.Fatalf("hand-rolled: %v", err) |
| + } |
| +} |
| + |
| +// --policy's file is refused before anything else is read when it cannot |
| +// be read or is not an armed policy at all. |
| +func TestInstallChannelPolicyFileIsReadUpFront(t *testing.T) { |
| + dir := t.TempDir() |
| + missing := filepath.Join(dir, "policy.json") |
| + if _, err := readChannelPolicy(missing); err == nil || !strings.HasPrefix(err.Error(), "the policy "+missing+" could not be read, so nothing was installed: ") { |
| + t.Fatalf("missing: %v", err) |
| + } |
| + for _, body := range []string{"", "{", `{"mode":"unpublished"}`, `{"mode":"enforced"}`} { |
| + if err := os.WriteFile(missing, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := readChannelPolicy(missing); err == nil || err.Error() != missing+" is not an armed policy signed for this deployment, so nothing was installed" { |
| + t.Fatalf("%q: %v", body, err) |
| + } |
| + } |
| + _, key, _ := ed25519.GenerateKey(nil) |
| + raw := signedPolicy(t, key, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(missing, raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := readChannelPolicy(missing); err != nil || !bytes.Equal(got, raw) { |
| + t.Fatalf("a signed policy: %v", err) |
| + } |
| +} |
| + |
| +// floorUnreadable fails every read of the host floor's two objects. |
| +type floorUnreadable struct{ release.Store } |
| + |
| +func (s floorUnreadable) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == hostfloor.Key || key == hostfloor.AuthKey { |
| + return nil, "", errors.New("door restarting") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func equalFiles(a, b map[string]string) bool { return len(diffFiles(a, b)) == 0 } |
| + |
| +func diffFiles(a, b map[string]string) []string { |
| + var d []string |
| + for k, v := range a { |
| + if w, ok := b[k]; !ok || w != v { |
| + d = append(d, k) |
| + } |
| + } |
| + for k := range b { |
| + if _, ok := a[k]; !ok { |
| + d = append(d, k) |
| + } |
| + } |
| + return d |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 889ac9320..fbdbb6273 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -858,6 +858,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay claim-prev-rehearsal (install-channel --policy: arm an |
| + // updater-installed box, controller ruling R1), loaded last. PROPOSED |
| + // (docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md). It |
| + // adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.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-claim-prev-rehearsal.tsv b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv| new file mode 100644 |
| index 000000000..cf5c2ef0d |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv |
| @@ -0,0 +1,7 @@ |
| +# Census overlay claim-prev-rehearsal (claim trial: arm an updater-installed box; controller ruling R1, plus platform-00's binding additions), loaded LAST, after claim-step6-artifacts-fix2. PROPOSED, not ruled: docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md. |
| +# carlos system install-channel --policy FILE (internal/host installchannel.go). Without --policy nothing changes: channelInstall.run keeps its digest and class (F,J4, census-ruling-region-ha-2b), and slotState, var openat2 and removeBeneath are unchanged and stay 2b's rows. install and checkFloor have no read site of their own (envelope only): with --policy the signed armed policy's verified floor stands in for the published one, and a published floor, or either half of one, must be the policy's bytes. |
| +# Two new root-read units, both reached only through install-channel --policy, run as uid 0 at the box's root shell: readChannelPolicy reads the operator-staged policy file and refuses it before anything else unless it is an armed policy (its signature is verified once the release key is known); channelInstall.refreshPrev, the one admission of an updated box at the channel head, Lstats carlos.prev to refuse a symlink or a non-regular file before the updater's own notePrev refreshes it from carlos. |
| +# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches either new unit. The 9B8 inventory is unchanged: install-channel writes only local files. |
| +@coverage internal/host 422bed5ca44ec32c8eab923ad6b96c7a39c50829c4a3346f004fb2383b3dad9e |
| +internal/host/installchannel.go readChannelPolicy authority F,J4 rev3=PROPOSED-claim-prev-rehearsal;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=reads the operator-staged --policy file once, before the apply lock or any store read; refuses an unreadable file or one that is not an armed policy; the bytes are used only after channelPolicy verifies the root-signed moves-protocol authorization for this deployment's bucket under CARLOS_RELEASE_PUBKEY, and a published floor must equal them byte for byte file:ReadFile@67 631724a2f53e641e7b5d4d42c927e8687472000f2ad506fa9ee64f0bb146e77f |
| +internal/host/installchannel.go channelInstall.refreshPrev authority F,J4 rev3=PROPOSED-claim-prev-rehearsal;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=under the apply lock, after the armed-guard, other-release and floor refusals: carlos must hash, through slotState's O_NOFOLLOW descriptor, to the channel release's signed artifact sha256; its one read site Lstats carlos.prev to refuse a symlink or anything but a regular file; carlos.prev is then refreshed from carlos by the updater's notePrev and both slots are re-hashed before the identity is kept; nothing is fetched or executed and applied.json and seen.json are not written file:Lstat@907 d0848383f68009331536d067c1cade578186514edb351645a7a3ad2e250a7bf5 |