Skip to main content

max / makenotwork

bento: seal publish behind an unforgeable PublishAuthority (ultra-fuzz structural) OtaBackend::publish now requires a &PublishAuthority, a proof type whose only constructor (PublishAuthority::prove) IS the publish gate: it fails closed on any prior failed step and, for macOS/iOS, on a missing/rejected Gatekeeper verdict. The token's target field is private, so the proof is unconstructible outside ota.rs and unforgeable. The old standalone publish_precondition (a runtime check a future refactor could route around) is gone; the unverified-artifact ship path is now uncompilable in Rust. Recipes stay Rhai-orchestrated; they simply can never obtain the token out of order. Backends assert the authority's target matches the release as defense-in-depth. Also collapses an incidental nested-if in expand_tilde flagged by the newer clippy (same file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 01:09 UTC
Commit: 15f13530ab2aec50db95be71edeaffb670c27641
Parent: 294d309
2 files changed, +120 insertions, -58 deletions
@@ -13,9 +13,9 @@
13 13 //! which `spawn_blocking` guarantees.
14 14
15 15 use crate::config::Config;
16 - use crate::domain::{AppId, Platform, Status, Step, StepRunId, Target, Version};
16 + use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
17 17 use crate::events::{self, Event, EventTx};
18 - use crate::ota::{OtaRegistry, Release};
18 + use crate::ota::{OtaRegistry, PublishAuthority, Release};
19 19 use crate::state::ExecutorMap;
20 20 use anyhow::{Context as _, Result};
21 21 use ops_core::live_log::LiveLog;
@@ -372,11 +372,10 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> {
372 372
373 373 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
374 374 pub fn expand_tilde(p: &str) -> PathBuf {
375 - if let Some(rest) = p.strip_prefix("~/") {
376 - if let Ok(home) = std::env::var("HOME") {
375 + if let Some(rest) = p.strip_prefix("~/")
376 + && let Ok(home) = std::env::var("HOME") {
377 377 return Path::new(&home).join(rest);
378 378 }
379 - }
380 379 PathBuf::from(p)
381 380 }
382 381
@@ -630,12 +629,15 @@ impl RecipeCtx {
630 629 "publish channel `{channel}` does not support target {target}",
631 630 );
632 631
633 - // Step-success ledger (the Bento analogue of Sando's gate fail-closed).
634 - {
632 + // Step-success ledger (the Bento analogue of Sando's gate fail-closed),
633 + // minted as an unforgeable PublishAuthority. `backend.publish` cannot be
634 + // called without one, so the unverified/post-failure ship path is sealed
635 + // at the type level rather than guarded by a separate runtime check.
636 + let authority = {
635 637 let failed = self.failed_steps.lock().unwrap();
636 638 let gatekeeper = *self.gatekeeper_ok.lock().unwrap();
637 - publish_precondition(target, failed.as_slice(), gatekeeper)?;
638 - }
639 + PublishAuthority::prove(target, failed.as_slice(), gatekeeper)?
640 + };
639 641 let notes = meta.get("notes").and_then(|v| v.clone().into_string().ok()).unwrap_or_default();
640 642 // Resolve the artifact relative to the collected dist dir if not absolute.
641 643 let artifact_path = {
@@ -648,7 +650,7 @@ impl RecipeCtx {
648 650 };
649 651 let rel = Release { app: &app, target, version: &version, notes };
650 652 let receipt = backend
651 - .publish(&rel, &artifact_path)
653 + .publish(&rel, &artifact_path, &authority)
652 654 .with_context(|| format!("publish to `{channel}`"))?;
653 655 // Record for idempotency / monotonicity.
654 656 let me = self.clone();
@@ -692,36 +694,6 @@ fn dir_size(p: &Path) -> Option<i64> {
692 694 /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only
693 695 /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a
694 696 /// plain SSH session cannot. Each is gated by the host's `sign` capability.
695 - /// The publish step-success gate (CF1 analogue for Bento). Bars `publish` when
696 - /// any prior step failed, OR when a macOS/iOS artifact has not passed
697 - /// Gatekeeper (the proof it is actually signed + notarized). Both fail closed —
698 - /// an unverified or post-failure artifact is never shipped. Pure, so it is
699 - /// exhaustively unit-testable without a runtime.
700 - fn publish_precondition(
701 - target: Target,
702 - failed_steps: &[Step],
703 - gatekeeper_ok: Option<bool>,
704 - ) -> Result<()> {
705 - anyhow::ensure!(
706 - failed_steps.is_empty(),
707 - "refusing to publish {target}: {} prior step(s) failed ({})",
708 - failed_steps.len(),
709 - failed_steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
710 - );
711 - if matches!(target.platform, Platform::Macos | Platform::Ios) {
712 - match gatekeeper_ok {
713 - Some(true) => {}
714 - Some(false) => anyhow::bail!(
715 - "refusing to publish {target}: Gatekeeper rejected the artifact (verify_gatekeeper did not accept)"
716 - ),
717 - None => anyhow::bail!(
718 - "refusing to publish {target}: artifact was never verified — verify_gatekeeper must run and accept before publish"
719 - ),
720 - }
721 - }
722 - Ok(())
723 - }
724 -
725 697 fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) {
726 698 {
727 699 let ctx = ctx.clone();
@@ -953,39 +925,39 @@ mod tests {
953 925 #[test]
954 926 fn publish_gate_blocks_macos_without_verification() {
955 927 // Never verified -> blocked, with a message pointing at verify_gatekeeper.
956 - let err = publish_precondition(target("macos/aarch64"), &[], None).unwrap_err();
928 + let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err();
957 929 assert!(format!("{err:#}").contains("never verified"), "{err:#}");
958 930 }
959 931
960 932 #[test]
961 933 fn publish_gate_blocks_macos_when_gatekeeper_rejected() {
962 - let err = publish_precondition(target("macos/aarch64"), &[], Some(false)).unwrap_err();
934 + let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err();
963 935 assert!(format!("{err:#}").contains("Gatekeeper rejected"), "{err:#}");
964 936 }
965 937
966 938 #[test]
967 939 fn publish_gate_allows_macos_when_gatekeeper_accepted() {
968 - publish_precondition(target("macos/aarch64"), &[], Some(true)).unwrap();
940 + PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap();
969 941 // iOS is gated the same way.
970 - publish_precondition(target("ios/universal"), &[], Some(true)).unwrap();
971 - assert!(publish_precondition(target("ios/universal"), &[], None).is_err());
942 + PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap();
943 + assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err());
972 944 }
973 945
974 946 #[test]
975 947 fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() {
976 948 // Linux/Windows aren't notarized; no gatekeeper proof needed.
977 - publish_precondition(target("linux/x86_64"), &[], None).unwrap();
978 - publish_precondition(target("windows/x86_64"), &[], None).unwrap();
949 + PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap();
950 + PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap();
979 951 }
980 952
981 953 #[test]
982 954 fn publish_gate_blocks_when_any_prior_step_failed() {
983 955 // A failed step bars publish on every target, even a verified macOS one.
984 - let err = publish_precondition(target("linux/x86_64"), &[Step::Build], None).unwrap_err();
956 + let err = PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err();
985 957 assert!(format!("{err:#}").contains("prior step(s) failed"), "{err:#}");
986 958 assert!(format!("{err:#}").contains("build"), "names the failed step: {err:#}");
987 959
988 - let err = publish_precondition(target("macos/aarch64"), &[Step::Sign], Some(true)).unwrap_err();
960 + let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true)).unwrap_err();
989 961 assert!(format!("{err:#}").contains("prior step(s) failed"), "{err:#}");
990 962 }
991 963
@@ -12,7 +12,7 @@
12 12 //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks
13 13 //! and returns a descriptive receipt without transferring bytes.
14 14
15 - use crate::domain::{AppId, Target, Version};
15 + use crate::domain::{AppId, Platform, Step, Target, Version};
16 16 use anyhow::{Context, Result};
17 17 use std::collections::HashMap;
18 18 use std::path::Path;
@@ -25,6 +25,64 @@ pub struct Release<'a> {
25 25 pub notes: String,
26 26 }
27 27
28 + /// Proof that an artifact has cleared the publish gate: no prior step failed,
29 + /// and (for macOS/iOS) Gatekeeper accepted it — the evidence it is actually
30 + /// signed + notarized. The `target` field is private, so a `PublishAuthority`
31 + /// is unconstructible outside this module; [`PublishAuthority::prove`] is the
32 + /// only way to obtain one and it *is* the gate. Because [`OtaBackend::publish`]
33 + /// demands one, no Rust path can ship an unverified or post-failure artifact —
34 + /// the seal is enforced by the type system, not by a separate runtime check a
35 + /// future refactor could route around (the Bento analogue of Sando's
36 + /// fail-closed gate framework). `prove` is pure, so it stays exhaustively
37 + /// unit-testable without a runtime.
38 + #[derive(Debug)]
39 + pub struct PublishAuthority {
40 + target: Target,
41 + }
42 +
43 + impl PublishAuthority {
44 + /// Mint an authority for `target`, or fail closed. Bars publish when any
45 + /// prior step failed, OR when a macOS/iOS artifact has not passed Gatekeeper.
46 + pub fn prove(
47 + target: Target,
48 + failed_steps: &[Step],
49 + gatekeeper_ok: Option<bool>,
50 + ) -> Result<Self> {
51 + anyhow::ensure!(
52 + failed_steps.is_empty(),
53 + "refusing to publish {target}: {} prior step(s) failed ({})",
54 + failed_steps.len(),
55 + failed_steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
56 + );
57 + if matches!(target.platform, Platform::Macos | Platform::Ios) {
58 + match gatekeeper_ok {
59 + Some(true) => {}
60 + Some(false) => anyhow::bail!(
61 + "refusing to publish {target}: Gatekeeper rejected the artifact (verify_gatekeeper did not accept)"
62 + ),
63 + None => anyhow::bail!(
64 + "refusing to publish {target}: artifact was never verified — verify_gatekeeper must run and accept before publish"
65 + ),
66 + }
67 + }
68 + Ok(Self { target })
69 + }
70 +
71 + /// The target this authority was proven for. Backends assert their release
72 + /// target matches, so an authority minted for one target can't wave another
73 + /// through.
74 + pub fn target(&self) -> Target {
75 + self.target
76 + }
77 +
78 + /// Construct an authority without proving the gate — for tests that exercise
79 + /// a backend's own integrity checks (artifact missing/empty), not the gate.
80 + #[cfg(test)]
81 + pub fn for_test(target: Target) -> Self {
82 + Self { target }
83 + }
84 + }
85 +
28 86 /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
29 87 /// `github-releases`, …) is a single `impl` plus registering its id; recipes
30 88 /// don't change.
@@ -33,8 +91,10 @@ pub trait OtaBackend: Send + Sync {
33 91 fn supports(&self, target: Target) -> bool;
34 92 /// Publish one artifact; return a channel-specific receipt string. Must be
35 93 /// idempotent at the backend boundary (re-publishing the same
36 - /// version+artifact is a no-op, not a duplicate release).
37 - fn publish(&self, rel: &Release, artifact: &Path) -> Result<String>;
94 + /// version+artifact is a no-op, not a duplicate release). Requires a
95 + /// [`PublishAuthority`] — the type-level proof the artifact cleared the
96 + /// publish gate — so this method is uncallable for an unverified artifact.
97 + fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String>;
38 98 }
39 99
40 100 /// Named lookup of registered backends.
@@ -84,7 +144,16 @@ impl OtaBackend for TauriMnwBackend {
84 144 matches!(target.platform, Macos | Linux | Windows)
85 145 }
86 146
87 - fn publish(&self, rel: &Release, artifact: &Path) -> Result<String> {
147 + fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String> {
148 + // Defense-in-depth: the authority is proof the gate passed for *this*
149 + // target; refuse a mismatched one rather than trust the caller paired
150 + // them correctly.
151 + anyhow::ensure!(
152 + rel.target == authority.target(),
153 + "publish authority is for {} but the release targets {}",
154 + authority.target(),
155 + rel.target,
156 + );
88 157 // Guardrail: never publish an artifact that is missing OR present-but-empty
89 158 // (a truncated/zero-byte collect would otherwise pass a bare `exists()`).
90 159 // Full integrity (minisign .sig verification against the trusted pubkey)
@@ -129,8 +198,10 @@ mod tests {
129 198 let b = reg.get("tauri-mnw").unwrap();
130 199 let app = AppId::new("goingson");
131 200 let ver = Version::parse("0.4.1").unwrap();
132 - let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() };
133 - assert!(b.publish(&rel, Path::new("/no/such/file")).is_err());
201 + let target: Target = "macos/aarch64".parse().unwrap();
202 + let rel = Release { app: &app, target, version: &ver, notes: String::new() };
203 + let auth = PublishAuthority::for_test(target);
204 + assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err());
134 205 }
135 206
136 207 #[test]
@@ -139,15 +210,34 @@ mod tests {
139 210 let b = reg.get("tauri-mnw").unwrap();
140 211 let app = AppId::new("goingson");
141 212 let ver = Version::parse("0.4.1").unwrap();
142 - let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() };
213 + let target: Target = "macos/aarch64".parse().unwrap();
214 + let rel = Release { app: &app, target, version: &ver, notes: String::new() };
215 + let auth = PublishAuthority::for_test(target);
143 216 let tmp = tempfile::tempdir().unwrap();
144 217 // Zero-byte file: exists() would pass, but a truncated collect must not publish.
145 218 let empty = tmp.path().join("app.dmg");
146 219 std::fs::write(&empty, b"").unwrap();
147 - let err = b.publish(&rel, &empty).unwrap_err();
220 + let err = b.publish(&rel, &empty, &auth).unwrap_err();
148 221 assert!(format!("{err:#}").contains("empty"), "{err:#}");
149 222 // A non-empty file is accepted (P2 stub returns the would-publish receipt).
150 223 std::fs::write(&empty, b"x").unwrap();
151 - assert!(b.publish(&rel, &empty).is_ok());
224 + assert!(b.publish(&rel, &empty, &auth).is_ok());
225 + }
226 +
227 + #[test]
228 + fn publish_refuses_mismatched_authority_target() {
229 + let reg = OtaRegistry::standard("https://makenot.work");
230 + let b = reg.get("tauri-mnw").unwrap();
231 + let app = AppId::new("goingson");
232 + let ver = Version::parse("0.4.1").unwrap();
233 + let rel_target: Target = "macos/aarch64".parse().unwrap();
234 + let rel = Release { app: &app, target: rel_target, version: &ver, notes: String::new() };
235 + // Authority proven for a different target must not wave this release through.
236 + let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap());
237 + let tmp = tempfile::tempdir().unwrap();
238 + let f = tmp.path().join("app.tar.gz");
239 + std::fs::write(&f, b"x").unwrap();
240 + let err = b.publish(&rel, &f, &auth).unwrap_err();
241 + assert!(format!("{err:#}").contains("authority"), "{err:#}");
152 242 }
153 243 }