release: fix-first on Task 3 review — reachable back-fill, safe fetch, sibling identity
C1 (critical): ensureStaged's back-fill only ran from the call inside adoptHost that sits behind the cur == ver short-circuit, so a version already live never triggered it again — and the migration path is entirely that case. adoptHost now calls a new backfillManifest on every pass when cur != "", before that short-circuit: a repair opportunity for the live version's sibling on every tick, one os.Stat in the steady state, never fatal to the pass. I2: made C1 safe. ensureStaged's back-fill GetManifest failure is no longer fatal when the binary is already staged — that's this function's whole promise, and a transient bucket hiccup must not hard-fail (or, on a canary route's 2-3s fast loop, journal-flood) adoption of a binary that needs no adoption. C1's per-pass repair is what makes leaving it non-fatal safe. I3: ReadStagedManifest now cross-checks the sibling's own App/Version against the path it was read from, exactly as GetManifest does for a bucket read — without it a sibling at keymail's path claiming app "console" would validate and materialize under keymail's identity. Minors: StageManifest now runs StageArtifact's three charset gates itself (it's the one write path that skips StageArtifact); doc comments spell out that the sibling is a deliberately lossy re-marshal and that WriteFileAtomic's atomicity is relied on, not exercised, by this suite; the sibling log lines in adopt.go are host-prefixed like every other line in the file. New tests: TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes and TestReadStagedManifestRefusesAnIdentityMismatch (versions_test.go); TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary, TestEnsureStagedBackfillFetchFailureIsNotFatal, and TestPassBackfillsTheLiveVersionsSiblingOnAnUnchangedPass (adopt_test.go).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+321
−18
internal/release/adopt.go+73 −10internal/release/adopt_test.go+131 −2internal/release/versions.go+40 −2internal/release/versions_test.go+77 −4
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index ae787f630..68acc5534 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -306,6 +306,18 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| fmt.Printf(" %s: read live link: %v — skipped\n", rt.Host, err) |
| return |
| } |
| + // Repair opportunity for the LIVE version's sibling, every pass, whether |
| + // or not this pass moves the symlink. C1 (2026-08-03 review): |
| + // ensureStaged's own back-fill only runs from the call below, which sits |
| + // behind the cur == ver short-circuit right after this — a version |
| + // that's already live never calls ensureStaged again, so a sibling write |
| + // that failed once (a full disk, a permissions hiccup) would otherwise |
| + // be stuck missing forever for exactly the version actually running. |
| + // This call never fails the pass: an absent or unreadable sibling is the |
| + // safe leave-alone state, so a repeated failure here is only a log line. |
| + if cur != "" { |
| + backfillManifest(ctx, s, optDir, RouteAccount(rt), rt.App, cur, rt.Host) |
| + } |
| if cur == ver { |
| return // up to date — stay quiet on the periodic tick |
| } |
| @@ -317,7 +329,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver) |
| return |
| } |
| - if err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver); err != nil { |
| + if err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host); err != nil { |
| fmt.Printf(" %s: %v\n", rt.Host, err) |
| return |
| } |
| @@ -352,17 +364,28 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| // downloading and sha256-verifying it if needed. Idempotent across hosts in |
| // one pass. The manifest supplies the sha; the bytes are refused unless they |
| // hash to it — the channel pointer's signature plus this gate is the whole |
| -// trust chain from operator to disk. |
| +// trust chain from operator to disk. host is used only to prefix this |
| +// function's own log lines, matching every other line this file prints. |
| // |
| // It also keeps the staged manifest sibling (versions.go's StageManifest) |
| // current: a fresh download writes the sibling alongside the binary, and a |
| // binary that is ALREADY staged but missing its sibling gets one written |
| // without a re-download — the back-fill for every version staged before this |
| -// sibling existed. Without the back-fill, the converge pass would be |
| -// permanently blind on exactly the versions already running: those binaries |
| -// are staged once, at the version change that put them there, and never |
| -// touch ensureStaged again on their own account. |
| -func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) error { |
| +// sibling existed. That sibling is a deliberately LOSSY re-marshal of the |
| +// bucket's manifest (see StageManifest's doc); the bucket object stays |
| +// canonical. |
| +// |
| +// The back-fill's own fetch failure is NOT fatal when the binary is already |
| +// staged (2026-08-03 review, I2): this function's whole promise is "the |
| +// binary is on disk", and a version already staged must not depend on the |
| +// bucket answering for a sibling write that can be retried. Without this, a |
| +// transient GetManifest failure would hard-fail adoption of a binary that |
| +// needs no adoption at all — and on a canary route's 2-3-second fast loop, |
| +// flood the journal every tick. adoptHost's backfillManifest (C1, same |
| +// review) is what makes leaving this non-fatal safe: every subsequent pass |
| +// gets another chance at the live version's sibling, so a transient failure |
| +// here heals on its own instead of being permanently missed. |
| +func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host string) error { |
| staged := false |
| if _, err := os.Stat(VersionPath(optDir, acct, ver, app)); err == nil { |
| staged = true |
| @@ -376,6 +399,13 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) e |
| } |
| m, err := GetManifest(ctx, s, acct, app, ver) |
| if err != nil { |
| + if staged { |
| + // The binary IS staged — this function's whole promise — so a |
| + // fetch failure here is only ever worth a log line; see the |
| + // doc comment above and backfillManifest's per-pass retry. |
| + fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err) |
| + return nil |
| + } |
| return fmt.Errorf("manifest for %s/%s: %w", app, ver, err) |
| } |
| if !staged { |
| @@ -400,14 +430,47 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) e |
| // The sibling write is best-effort: a failure here is not fatal to |
| // staging the binary — log and continue. A binary that runs with an |
| // unreadable capability sibling is the leave-alone state, which is safe |
| - // (ReadStagedManifest's nil-nil-on-absence contract). |
| + // (ReadStagedManifest's nil-nil-on-absence contract), and |
| + // backfillManifest gets another chance at it on every later pass. |
| raw, err := json.MarshalIndent(m, "", " ") |
| if err != nil { |
| - fmt.Printf(" %s/%s: marshal staged manifest: %v\n", app, ver, err) |
| + fmt.Printf(" %s: marshal staged manifest for %s: %v\n", host, ver, err) |
| return nil |
| } |
| if err := StageManifest(optDir, acct, ver, app, raw); err != nil { |
| - fmt.Printf(" %s/%s: stage manifest sibling: %v\n", app, ver, err) |
| + fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err) |
| } |
| return nil |
| } |
| + |
| +// backfillManifest is adoptHost's per-pass repair for a LIVE version's |
| +// staged sibling (2026-08-03 review, C1). ensureStaged's own back-fill only |
| +// fires from the call inside adoptHost that sits behind the cur == ver |
| +// short-circuit — so a version already live, once adopted, never calls |
| +// ensureStaged again on its own account, and a sibling write that failed |
| +// once would stay missing forever for exactly the version actually running. |
| +// This gives every pass another shot at it: os.Stat first (the steady-state |
| +// cost — one stat when the sibling is already there), fetch only when |
| +// absent, and never return an error — an absent or unreadable sibling is |
| +// the safe leave-alone state (ReadStagedManifest's nil-nil contract; the |
| +// converge pass treats it as "unknown", never as "no capabilities"), so a |
| +// repeated failure here is only ever worth a log line, host-prefixed like |
| +// every other line adoptHost prints. |
| +func backfillManifest(ctx context.Context, s Store, optDir, acct, app, ver, host string) { |
| + if _, err := os.Stat(StagedManifestPath(optDir, acct, ver, app)); err == nil { |
| + return |
| + } |
| + m, err := GetManifest(ctx, s, acct, app, ver) |
| + if err != nil { |
| + fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err) |
| + return |
| + } |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + fmt.Printf(" %s: marshal staged manifest for %s: %v\n", host, ver, err) |
| + return |
| + } |
| + if err := StageManifest(optDir, acct, ver, app, raw); err != nil { |
| + fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err) |
| + } |
| +} |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index 9226975a8..02572cff0 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -768,7 +768,7 @@ func TestEnsureStagedWritesTheSiblingManifest(t *testing.T) { |
| t.Fatalf("Ship: %v", err) |
| } |
| - if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc"); err != nil { |
| + if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com"); err != nil { |
| t.Fatalf("ensureStaged: %v", err) |
| } |
| @@ -822,7 +822,7 @@ func TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary(t *testing.T) { |
| t.Fatalf("sibling should be absent before backfill: %+v, %v", m2, err) |
| } |
| - if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc"); err != nil { |
| + if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com"); err != nil { |
| t.Fatalf("ensureStaged (backfill): %v", err) |
| } |
| @@ -849,3 +849,132 @@ func TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary(t *testing.T) { |
| t.Fatalf("backfill re-downloaded the binary bytes") |
| } |
| } |
| + |
| +// TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary is |
| +// killing test 2 from the 2026-08-03 review: a mutant that makes the |
| +// marshal/StageManifest failure at the end of ensureStaged's fresh-download |
| +// path fatal must be caught. The binary must stage successfully even when |
| +// its sibling cannot be written — a missing sibling is the safe leave-alone |
| +// state, and backfillManifest gets another chance at it every later pass. |
| +// |
| +// StageManifest's own write is blocked by pre-creating its ".tmp" rename |
| +// target AS A DIRECTORY — os.WriteFile then fails to open it, without ever |
| +// touching StageArtifact's own (different) binary path. |
| +func TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + opt := t.TempDir() |
| + |
| + m := Manifest{ |
| + App: "keymail", Version: "v1abc", Kind: "binary", |
| + Artifacts: map[string]Artifact{adoptPlatform: {}}, |
| + Capabilities: &caps.Set{V: 1, Daemon: true, RuntimeDir: "keymail"}, |
| + } |
| + if err := Ship(ctx, s, "bab", m, map[string][]byte{adoptPlatform: []byte("relay binary")}); err != nil { |
| + t.Fatalf("Ship: %v", err) |
| + } |
| + |
| + tmpBlock := StagedManifestPath(opt, "bab", "v1abc", "keymail") + ".tmp" |
| + if err := os.MkdirAll(tmpBlock, 0o755); err != nil { |
| + t.Fatalf("pre-create tmp block: %v", err) |
| + } |
| + |
| + if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com"); err != nil { |
| + t.Fatalf("ensureStaged returned an error for a failed SIBLING write: %v — the binary staged fine and that must not be fatal", err) |
| + } |
| + |
| + body, err := os.ReadFile(VersionPath(opt, "bab", "v1abc", "keymail")) |
| + if err != nil || string(body) != "relay binary" { |
| + t.Fatalf("binary did not stage despite the sibling write failing: %q, %v", body, err) |
| + } |
| + if got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || got != nil { |
| + t.Fatalf("sibling = %+v, %v; want absent (the write genuinely failed)", got, err) |
| + } |
| +} |
| + |
| +// TestEnsureStagedBackfillFetchFailureIsNotFatal is killing test 3 / I2's |
| +// regression test (2026-08-03 review): ensureStaged's back-fill fetch (a |
| +// GetManifest for an ALREADY-STAGED binary) must not be fatal to adoption. |
| +// The store here has no release shipped at all, so GetManifest is |
| +// guaranteed to fail — simulating a bucket hiccup for a version that is |
| +// already running and needs no adoption. A mutant that makes this fetch |
| +// failure fatal again would hard-fail (and, on a canary route's 2-3-second |
| +// fast loop, journal-flood) adoption of a binary that doesn't need it. |
| +func TestEnsureStagedBackfillFetchFailureIsNotFatal(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) // empty — nothing ever shipped |
| + ctx := context.Background() |
| + opt := t.TempDir() |
| + |
| + if err := StageArtifact(opt, "bab", "v1abc", "keymail", []byte("relay binary")); err != nil { |
| + t.Fatalf("StageArtifact: %v", err) |
| + } |
| + |
| + if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com"); err != nil { |
| + t.Fatalf("ensureStaged returned an error for an already-staged binary's failed back-fill fetch: %v — adoption of an already-running version must proceed", err) |
| + } |
| + |
| + if got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || got != nil { |
| + t.Fatalf("sibling = %+v, %v; want absent (the fetch failed, nothing to write)", got, err) |
| + } |
| +} |
| + |
| +// TestPassBackfillsTheLiveVersionsSiblingOnAnUnchangedPass pins C1, the |
| +// critical finding from the 2026-08-03 review: ensureStaged's own back-fill |
| +// only runs from the call inside adoptHost that sits BEHIND the cur == ver |
| +// short-circuit, so a version that is already live — with no repoint due |
| +// this pass — never reaches it on its own. Since the migration path is |
| +// entirely "a box's CURRENT version was staged before this feature |
| +// existed," without a separate per-pass repair call the sibling for |
| +// exactly the version actually running would stay missing forever. This |
| +// test exercises the real call site (Pass, not ensureStaged directly): the |
| +// route is already on v1abc, nothing repoints, and the sibling must still |
| +// appear. |
| +func TestPassBackfillsTheLiveVersionsSiblingOnAnUnchangedPass(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + m := Manifest{ |
| + App: "keymail", Version: "v1abc", Kind: "binary", |
| + Artifacts: map[string]Artifact{adoptPlatform: {}}, |
| + Capabilities: &caps.Set{V: 1, Daemon: true, RuntimeDir: "keymail"}, |
| + } |
| + if err := Ship(ctx, s, "bab", m, map[string][]byte{adoptPlatform: []byte("relay binary")}); err != nil { |
| + t.Fatalf("Ship: %v", err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1abc"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + // The binary is staged and pointed live BY HAND, exactly as a |
| + // pre-Task-3 box would be — no sibling, and no version change due this |
| + // pass (the route is already on v1abc). |
| + if err := StageArtifact(opt, "bab", "v1abc", "keymail", []byte("relay binary")); err != nil { |
| + t.Fatalf("StageArtifact: %v", err) |
| + } |
| + addRoute(t, reg, "h.example.com", "keymail", "canary/x", false) |
| + if err := PointLive(opt, "h.example.com", "bab", "v1abc", "keymail"); err != nil { |
| + t.Fatalf("PointLive: %v", err) |
| + } |
| + |
| + if m2, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || m2 != nil { |
| + t.Fatalf("sibling should be absent before the pass: %+v, %v", m2, err) |
| + } |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + |
| + got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail") |
| + if err != nil { |
| + t.Fatalf("ReadStagedManifest after Pass: %v", err) |
| + } |
| + if got == nil || got.Capabilities == nil || !got.Capabilities.Daemon { |
| + t.Fatalf("Pass did not back-fill the live version's sibling: %+v", got) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "v1abc" { |
| + t.Fatalf("live version moved unexpectedly: %q", v) |
| + } |
| +} |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index 826fd8ea5..1f6063750 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -161,8 +161,29 @@ func StagedManifestPath(optDir, acct, ver, app string) string { |
| // StageManifest writes the manifest bytes atomically beside the binary, |
| // 0644 — root-writable, world-readable, never executable (it's JSON, not a |
| -// unit the sandboxed service execs). |
| +// unit the sandboxed service execs). The write is temp-file-then-rename |
| +// (WriteFileAtomic), so a crashed pass never leaves a truncated sibling — |
| +// that atomicity is relied on but not itself exercised by this package's |
| +// suite (no test kills a fault injected between the write and the rename); |
| +// it is the same primitive StageArtifact already trusts for the binary. |
| +// |
| +// raw is whatever bytes the caller already has (ensureStaged's |
| +// json.MarshalIndent of the fetched Manifest) — this is the ONE write path |
| +// that skips StageArtifact, so it repeats StageArtifact's three charset |
| +// gates itself rather than relying on a caller that staged the binary |
| +// first: the back-fill branch calls this with no StageArtifact call in the |
| +// same pass, and the reserved-word/traversal protection those gates give |
| +// the binary's path must cover this sibling path too. |
| func StageManifest(optDir, acct, ver, app string, raw []byte) error { |
| + if !ValidVersion(ver) { |
| + return fmt.Errorf("invalid version %q (allowed: letters, digits, . _ -)", ver) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("invalid app %q", app) |
| + } |
| + if acct != "" && !ValidAccount(acct) { |
| + return fmt.Errorf("invalid account %q", acct) |
| + } |
| return WriteFileAtomic(StagedManifestPath(optDir, acct, ver, app), raw, 0o644) |
| } |
| @@ -180,7 +201,21 @@ func StageManifest(optDir, acct, ver, app string, raw []byte) error { |
| // The parsed manifest runs through the same validateManifest gate every |
| // other manifest read does (GetManifest's), so a stale sibling written by a |
| // NEWER binary — one that understood a capability this binary does not — |
| -// refuses cleanly here instead of being silently trusted. |
| +// refuses cleanly here instead of being silently trusted. It also runs |
| +// GetManifest's identity cross-check: a sibling whose body names a |
| +// different app or version than the path it was read from is refused, |
| +// rather than trusted against the PATH's name — without this, a sibling at |
| +// keymail's path claiming app "console" would pass every charset and |
| +// reserved-word gate under keymail's own identity, and its capabilities |
| +// (host_role's User=, runtime_dir, ports) would land on keymail's unit. |
| +// |
| +// The sibling is a deliberately LOSSY re-marshal of the bucket's manifest: |
| +// json.MarshalIndent drops any top-level key this binary's Manifest struct |
| +// doesn't know, and caps.Set's decoder notes (unknown fields inside a known |
| +// capability) are unexported and never survive the round trip. The bucket |
| +// object stays the canonical copy; this sibling exists only so the converge |
| +// pass can read a running version's capabilities without a bucket round |
| +// trip on every tick. |
| func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) { |
| raw, err := os.ReadFile(StagedManifestPath(optDir, acct, ver, app)) |
| if err != nil { |
| @@ -196,6 +231,9 @@ func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) { |
| if err := validateManifest(&m); err != nil { |
| return nil, fmt.Errorf("staged manifest %s/%s: %w", app, ver, err) |
| } |
| + if m.App != app || m.Version != ver { |
| + return nil, fmt.Errorf("staged manifest at %s names %s/%s", StagedManifestPath(optDir, acct, ver, app), m.App, m.Version) |
| + } |
| return &m, nil |
| } |
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go| index 291d3dc59..f67043cda 100644 |
| --- a/internal/release/versions_test.go |
| +++ b/internal/release/versions_test.go |
| @@ -206,10 +206,13 @@ func TestStageManifestRoundTripsCapabilities(t *testing.T) { |
| // TestReadStagedManifestRunsTheValidationGate pins the Task-2-review |
| // backstop: a stale sibling written by a NEWER binary — one that understood |
| -// a "v" this binary does not — must refuse cleanly here, the same gate |
| -// GetManifest runs on every bucket read. Written directly to disk (bypassing |
| -// StageManifest's own marshal), exactly as a newer binary's bytes would |
| -// arrive. |
| +// a "v" this binary does not — must refuse cleanly here. This particular |
| +// "v" is caught by caps.Set's own DECODER (UnmarshalJSON's v-range check) |
| +// during ReadStagedManifest's json.Unmarshal, before validateManifest is |
| +// even reached — see TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes |
| +// below for the sibling that reaches validateManifest's caps.ValidCapabilities |
| +// call specifically. Written directly to disk (bypassing StageManifest's |
| +// own marshal), exactly as a newer binary's bytes would arrive. |
| func TestReadStagedManifestRunsTheValidationGate(t *testing.T) { |
| opt := t.TempDir() |
| raw := []byte(`{ |
| @@ -230,3 +233,73 @@ func TestReadStagedManifestRunsTheValidationGate(t *testing.T) { |
| t.Fatal("a sibling declaring a v this binary does not speak was accepted") |
| } |
| } |
| + |
| +// TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes |
| +// is the mutation-killing companion to the test above (2026-08-03 review, |
| +// killing test 1): runtime_dir "systemd" decodes CLEANLY — RuntimeDir is a |
| +// plain string field with no charset or reserved-word check in caps.Set's |
| +// decoder — and is refused only by ValidCapabilities' reserved-name check, |
| +// which ReadStagedManifest reaches exclusively through its validateManifest |
| +// call. A mutant that deletes that call (while leaving json.Unmarshal in |
| +// place) would let this exact sibling through; this test is the one that |
| +// catches it. |
| +func TestReadStagedManifestRefusesASemanticallyInvalidSiblingEvenThoughItDecodes(t *testing.T) { |
| + opt := t.TempDir() |
| + raw := []byte(`{ |
| + "schema": 1, |
| + "app": "keymail", |
| + "version": "v1abc", |
| + "kind": "binary", |
| + "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }, |
| + "capabilities": { "v": 1, "runtime_dir": "systemd" } |
| +}`) |
| + p := StagedManifestPath(opt, OpsAccount, "v1abc", "keymail") |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| + t.Fatal("a sibling with a reserved runtime_dir (refused only by ValidCapabilities, via validateManifest) was accepted") |
| + } |
| +} |
| + |
| +// TestReadStagedManifestRefusesAnIdentityMismatch pins I3 (2026-08-03 |
| +// review): the sibling's OWN App/Version fields must match the path it was |
| +// read from, in both directions. Without this, a sibling at keymail's path |
| +// whose body claims a different app or version is trusted against the |
| +// PATH's identity — so a sibling written for (or by) the wrong app would |
| +// have its capabilities validated and materialized under keymail's name. |
| +func TestReadStagedManifestRefusesAnIdentityMismatch(t *testing.T) { |
| + opt := t.TempDir() |
| + write := func(bodyApp, bodyVer string) { |
| + t.Helper() |
| + raw := []byte(`{ |
| + "schema": 1, |
| + "app": "` + bodyApp + `", |
| + "version": "` + bodyVer + `", |
| + "kind": "binary", |
| + "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } } |
| +}`) |
| + p := StagedManifestPath(opt, OpsAccount, "v1abc", "keymail") |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // Wrong app: a sibling at keymail's path whose body claims "console". |
| + write("console", "v1abc") |
| + if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| + t.Fatal("a sibling naming a different app than its path was accepted") |
| + } |
| + |
| + // Wrong version: same app, a different version in the body. |
| + write("keymail", "vOTHER") |
| + if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil { |
| + t.Fatal("a sibling naming a different version than its path was accepted") |
| + } |
| +} |