max / makenotwork
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 | } |