//! Release-channel abstraction. //! //! The publish step is not hardcoded to one updater or store. A recipe calls //! `publish("", ...)`; the daemon dispatches to the named //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on //! failure; notarytool needs retry) get encoded inside the backends so every //! recipe inherits them. //! //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are //! here so recipes can name a channel and the wiring is exercised end-to-end. //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks //! and returns a descriptive receipt without transferring bytes. use crate::domain::{AppId, Platform, Step, Target, Version}; use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::Path; /// One signed artifact ready to publish for `(app, target, version)`. pub struct Release<'a> { pub app: &'a AppId, pub target: Target, pub version: &'a Version, pub notes: String, } /// Proof that an artifact has cleared the publish gate: no prior step failed, /// and (for macOS/iOS) Gatekeeper accepted it — the evidence it is actually /// signed + notarized. The `target` field is private, so a `PublishAuthority` /// is unconstructible outside this module; [`PublishAuthority::prove`] is the /// only way to obtain one and it *is* the gate. Because [`OtaBackend::publish`] /// demands one, no Rust path can ship an unverified or post-failure artifact — /// the seal is enforced by the type system, not by a separate runtime check a /// future refactor could route around (the Bento analogue of Sando's /// fail-closed gate framework). `prove` is pure, so it stays exhaustively /// unit-testable without a runtime. #[derive(Debug)] pub struct PublishAuthority { target: Target, } impl PublishAuthority { /// Mint an authority for `target`, or fail closed. Bars publish when any /// prior step failed, OR when a macOS/iOS artifact has not passed Gatekeeper. pub fn prove( target: Target, failed_steps: &[Step], gatekeeper_ok: Option, ) -> Result { anyhow::ensure!( failed_steps.is_empty(), "refusing to publish {target}: {} prior step(s) failed ({})", failed_steps.len(), failed_steps .iter() .map(|s| s.as_str()) .collect::>() .join(", "), ); if matches!(target.platform, Platform::Macos | Platform::Ios) { match gatekeeper_ok { Some(true) => {} Some(false) => anyhow::bail!( "refusing to publish {target}: Gatekeeper rejected the artifact (verify_gatekeeper did not accept)" ), None => anyhow::bail!( "refusing to publish {target}: artifact was never verified — verify_gatekeeper must run and accept before publish" ), } } Ok(Self { target }) } /// The target this authority was proven for. Backends assert their release /// target matches, so an authority minted for one target can't wave another /// through. pub fn target(&self) -> Target { self.target } /// Construct an authority without proving the gate — for tests that exercise /// a backend's own integrity checks (artifact missing/empty), not the gate. #[cfg(test)] pub fn for_test(target: Target) -> Self { Self { target } } } // --------------------------------------------------------------------------- // Distribution probe // --------------------------------------------------------------------------- /// The MNW slug an app distributes under. /// /// MNW slugs are lowercase alphanumeric plus hyphens (`server/routes/ota.rs` /// `validate_slug`), and Bento app ids use underscores, so the mapping is a /// substitution rather than an identity. Deriving it beats carrying a second /// name in the topology that can drift from the first. pub fn mnw_slug(app: &str) -> String { app.replace('_', "-") } /// Bento's `(platform, arch)` as MNW's OTA path segments, or `None` for a /// target MNW's updater does not serve. /// /// MNW says `darwin` where Bento says `macos` — Tauri's updater vocabulary, /// which the endpoint matches. iOS and Android ride TestFlight and Play, so /// they have no MNW manifest and are not evidence of anything either way. pub fn mnw_path_segments(target: Target) -> Option<(&'static str, &'static str)> { use crate::domain::Platform::{Android, Ios, Linux, Macos, Windows}; let platform = match target.platform { Macos => "darwin", Linux => "linux", Windows => "windows", Ios | Android => return None, }; Some((platform, target.arch.as_str())) } /// What the world can actually download for one app at one version. #[derive(Debug, Default, Clone)] pub struct Distribution { /// Targets MNW serves a manifest for, at the expected version. pub fetchable: Vec, /// Targets that have an MNW manifest but at some other version, or none. pub missing: Vec, /// The probe could not reach MNW. Distinct from "nothing is published": /// not knowing is not the same as knowing the answer is no. pub error: Option, } impl Distribution { /// True only when every target that MNW *can* serve is being served. /// /// An app whose declared targets are all mobile has nothing to check and is /// vacuously complete; the caller decides whether that is meaningful. pub fn complete(&self) -> bool { self.error.is_none() && self.missing.is_empty() } } /// Ask MNW's public updater endpoint whether each target is downloadable at /// `version`. /// /// This is a read of the same endpoint a user's Tauri app polls, which is the /// point: it answers "is the release actually out" from the outside, rather /// than from Bento's own record of what it believes it did. Bento's record is /// exactly what cannot be trusted here — [`TauriMnwBackend::publish`] does not /// transfer bytes yet, and the artifacts are uploaded by hand. /// /// `0.0.0` is passed as the updater's `current_version` so the endpoint always /// considers the published release an upgrade and answers with the manifest. pub async fn probe_distribution( client: &reqwest::Client, base_url: &str, slug: &str, version: &str, targets: &[Target], ) -> Distribution { let mut dist = Distribution::default(); for target in targets { let Some((platform, arch)) = mnw_path_segments(*target) else { continue; }; let url = format!( "{}/api/v1/sync/ota/{slug}/{platform}/{arch}/0.0.0", base_url.trim_end_matches('/') ); match client.get(&url).send().await { // 204 is the endpoint's "no update available", which for a probe // anchored at 0.0.0 means nothing is published at all. Ok(resp) if resp.status() == reqwest::StatusCode::NO_CONTENT => { dist.missing.push(target.to_string()); } Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(body) => { let served = body.get("version").and_then(|v| v.as_str()); let has_url = body .get("url") .and_then(|v| v.as_str()) .is_some_and(|u| !u.is_empty()); // Both halves matter: a manifest naming the right // version with no download URL is not distributable, // and one with a URL at the wrong version is a stale // release rather than this one. if served == Some(version) && has_url { dist.fetchable.push(target.to_string()); } else { dist.missing.push(target.to_string()); } } Err(e) => dist.error = Some(format!("{target}: malformed manifest: {e}")), } } Ok(resp) => { // 404 is a slug MNW has never heard of, which is a real "not // distributed" rather than a transport problem. if resp.status() == reqwest::StatusCode::NOT_FOUND { dist.missing.push(target.to_string()); } else { dist.error = Some(format!("{target}: MNW answered {}", resp.status())); } } Err(e) => dist.error = Some(format!("{target}: {e}")), } } dist } /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`, /// `github-releases`, …) is a single `impl` plus registering its id; recipes /// don't change. pub trait OtaBackend: Send + Sync { fn id(&self) -> &str; fn supports(&self, target: Target) -> bool; /// Publish one artifact; return a channel-specific receipt string. Must be /// idempotent at the backend boundary (re-publishing the same /// version+artifact is a no-op, not a duplicate release). Requires a /// [`PublishAuthority`] — the type-level proof the artifact cleared the /// publish gate — so this method is uncallable for an unverified artifact. fn publish( &self, rel: &Release, artifact: &Path, authority: &PublishAuthority, ) -> Result; } /// Named lookup of registered backends. #[derive(Default)] pub struct OtaRegistry { backends: HashMap>, } impl OtaRegistry { pub fn new() -> Self { Self::default() } pub fn register(&mut self, backend: Box) { self.backends.insert(backend.id().to_string(), backend); } pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> { self.backends.get(channel).map(std::convert::AsRef::as_ref) } /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint. pub fn standard(mnw_base_url: impl Into) -> Self { let mut reg = Self::new(); reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into(), })); reg } } /// Hook into Tauri's OTA system via the MNW server (the manifest host + /// release registry). The Tauri updater polls /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a /// minisign signature; this backend registers the release + uploads the signed /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest. pub struct TauriMnwBackend { pub base_url: String, } impl OtaBackend for TauriMnwBackend { fn id(&self) -> &'static str { "tauri-mnw" } fn supports(&self, target: Target) -> bool { use crate::domain::Platform::{Linux, Macos, Windows}; // Tauri's updater covers the desktop trio; mobile rides testflight/play. matches!(target.platform, Macos | Linux | Windows) } fn publish( &self, rel: &Release, artifact: &Path, authority: &PublishAuthority, ) -> Result { // Defense-in-depth: the authority is proof the gate passed for *this* // target; refuse a mismatched one rather than trust the caller paired // them correctly. anyhow::ensure!( rel.target == authority.target(), "publish authority is for {} but the release targets {}", authority.target(), rel.target, ); // Guardrail: never publish an artifact that is missing OR present-but-empty // (a truncated/zero-byte collect would otherwise pass a bare `exists()`). // Full integrity (minisign .sig verification against the trusted pubkey) // lands with the P2 upload wiring below — this size floor is the cheap // pre-check that catches a failed transfer. let meta = std::fs::metadata(artifact) .with_context(|| format!("artifact {} is not accessible", artifact.display()))?; anyhow::ensure!( meta.is_file(), "artifact {} is not a regular file", artifact.display() ); anyhow::ensure!( meta.len() > 0, "artifact {} is empty (0 bytes)", artifact.display() ); // P2: POST the release + upload the artifact + its minisign .sig to the // MNW OTA API at `self.base_url`. Until then, report what would happen // so the recipe path is exercised without a half-published release. Ok(format!( "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]", app = rel.app, ver = rel.version, target = rel.target, artifact = artifact.display(), base = self.base_url, )) } } #[cfg(test)] mod tests { use super::*; #[test] fn standard_registry_has_tauri_mnw() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").expect("registered"); assert_eq!(b.id(), "tauri-mnw"); assert!(b.supports("macos/aarch64".parse().unwrap())); assert!(b.supports("linux/x86_64".parse().unwrap())); assert!(!b.supports("ios/universal".parse().unwrap())); assert!(reg.get("nope").is_none()); } #[test] fn publish_refuses_missing_artifact() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").unwrap(); let app = AppId::new("goingson"); let ver = Version::parse("0.4.1").unwrap(); let target: Target = "macos/aarch64".parse().unwrap(); let rel = Release { app: &app, target, version: &ver, notes: String::new(), }; let auth = PublishAuthority::for_test(target); assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err()); } #[test] fn publish_refuses_empty_artifact() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").unwrap(); let app = AppId::new("goingson"); let ver = Version::parse("0.4.1").unwrap(); let target: Target = "macos/aarch64".parse().unwrap(); let rel = Release { app: &app, target, version: &ver, notes: String::new(), }; let auth = PublishAuthority::for_test(target); let tmp = tempfile::tempdir().unwrap(); // Zero-byte file: exists() would pass, but a truncated collect must not publish. let empty = tmp.path().join("app.dmg"); std::fs::write(&empty, b"").unwrap(); let err = b.publish(&rel, &empty, &auth).unwrap_err(); assert!(format!("{err:#}").contains("empty"), "{err:#}"); // A non-empty file is accepted (P2 stub returns the would-publish receipt). std::fs::write(&empty, b"x").unwrap(); assert!(b.publish(&rel, &empty, &auth).is_ok()); } #[test] fn publish_refuses_mismatched_authority_target() { let reg = OtaRegistry::standard("https://makenot.work"); let b = reg.get("tauri-mnw").unwrap(); let app = AppId::new("goingson"); let ver = Version::parse("0.4.1").unwrap(); let rel_target: Target = "macos/aarch64".parse().unwrap(); let rel = Release { app: &app, target: rel_target, version: &ver, notes: String::new(), }; // Authority proven for a different target must not wave this release through. let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap()); let tmp = tempfile::tempdir().unwrap(); let f = tmp.path().join("app.tar.gz"); std::fs::write(&f, b"x").unwrap(); let err = b.publish(&rel, &f, &auth).unwrap_err(); assert!(format!("{err:#}").contains("authority"), "{err:#}"); } }