carlos / platform Public

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>
Paul Campbell dce290550bed63ca82e6d0e51f63e56c2a06c1fc parent 7d786af
4 files changed, +321 −18
  • internal/release/adopt.go +73 −10
  • internal/release/adopt_test.go +131 −2
  • internal/release/versions.go +40 −2
  • internal/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")
+ }
+}