Skip to main content

max / makenotwork

16.0 KB · 408 lines History Blame Raw
1 //! Release-channel abstraction.
2 //!
3 //! The publish step is not hardcoded to one updater or store. A recipe calls
4 //! `publish("<channel>", ...)`; the daemon dispatches to the named
5 //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on
6 //! failure; notarytool needs retry) get encoded inside the backends so every
7 //! recipe inherits them.
8 //!
9 //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are
10 //! here so recipes can name a channel and the wiring is exercised end-to-end.
11 //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is
12 //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks
13 //! and returns a descriptive receipt without transferring bytes.
14
15 use crate::domain::{AppId, Platform, Step, Target, Version};
16 use anyhow::{Context, Result};
17 use std::collections::HashMap;
18 use std::path::Path;
19
20 /// One signed artifact ready to publish for `(app, target, version)`.
21 pub struct Release<'a> {
22 pub app: &'a AppId,
23 pub target: Target,
24 pub version: &'a Version,
25 pub notes: String,
26 }
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
56 .iter()
57 .map(|s| s.as_str())
58 .collect::<Vec<_>>()
59 .join(", "),
60 );
61 if matches!(target.platform, Platform::Macos | Platform::Ios) {
62 match gatekeeper_ok {
63 Some(true) => {}
64 Some(false) => anyhow::bail!(
65 "refusing to publish {target}: Gatekeeper rejected the artifact (verify_gatekeeper did not accept)"
66 ),
67 None => anyhow::bail!(
68 "refusing to publish {target}: artifact was never verified — verify_gatekeeper must run and accept before publish"
69 ),
70 }
71 }
72 Ok(Self { target })
73 }
74
75 /// The target this authority was proven for. Backends assert their release
76 /// target matches, so an authority minted for one target can't wave another
77 /// through.
78 pub fn target(&self) -> Target {
79 self.target
80 }
81
82 /// Construct an authority without proving the gate — for tests that exercise
83 /// a backend's own integrity checks (artifact missing/empty), not the gate.
84 #[cfg(test)]
85 pub fn for_test(target: Target) -> Self {
86 Self { target }
87 }
88 }
89
90 // ---------------------------------------------------------------------------
91 // Distribution probe
92 // ---------------------------------------------------------------------------
93
94 /// The MNW slug an app distributes under.
95 ///
96 /// MNW slugs are lowercase alphanumeric plus hyphens (`server/routes/ota.rs`
97 /// `validate_slug`), and Bento app ids use underscores, so the mapping is a
98 /// substitution rather than an identity. Deriving it beats carrying a second
99 /// name in the topology that can drift from the first.
100 pub fn mnw_slug(app: &str) -> String {
101 app.replace('_', "-")
102 }
103
104 /// Bento's `(platform, arch)` as MNW's OTA path segments, or `None` for a
105 /// target MNW's updater does not serve.
106 ///
107 /// MNW says `darwin` where Bento says `macos` — Tauri's updater vocabulary,
108 /// which the endpoint matches. iOS and Android ride TestFlight and Play, so
109 /// they have no MNW manifest and are not evidence of anything either way.
110 pub fn mnw_path_segments(target: Target) -> Option<(&'static str, &'static str)> {
111 use crate::domain::Platform::{Android, Ios, Linux, Macos, Windows};
112 let platform = match target.platform {
113 Macos => "darwin",
114 Linux => "linux",
115 Windows => "windows",
116 Ios | Android => return None,
117 };
118 Some((platform, target.arch.as_str()))
119 }
120
121 /// What the world can actually download for one app at one version.
122 #[derive(Debug, Default, Clone)]
123 pub struct Distribution {
124 /// Targets MNW serves a manifest for, at the expected version.
125 pub fetchable: Vec<String>,
126 /// Targets that have an MNW manifest but at some other version, or none.
127 pub missing: Vec<String>,
128 /// The probe could not reach MNW. Distinct from "nothing is published":
129 /// not knowing is not the same as knowing the answer is no.
130 pub error: Option<String>,
131 }
132
133 impl Distribution {
134 /// True only when every target that MNW *can* serve is being served.
135 ///
136 /// An app whose declared targets are all mobile has nothing to check and is
137 /// vacuously complete; the caller decides whether that is meaningful.
138 pub fn complete(&self) -> bool {
139 self.error.is_none() && self.missing.is_empty()
140 }
141 }
142
143 /// Ask MNW's public updater endpoint whether each target is downloadable at
144 /// `version`.
145 ///
146 /// This is a read of the same endpoint a user's Tauri app polls, which is the
147 /// point: it answers "is the release actually out" from the outside, rather
148 /// than from Bento's own record of what it believes it did. Bento's record is
149 /// exactly what cannot be trusted here — [`TauriMnwBackend::publish`] does not
150 /// transfer bytes yet, and the artifacts are uploaded by hand.
151 ///
152 /// `0.0.0` is passed as the updater's `current_version` so the endpoint always
153 /// considers the published release an upgrade and answers with the manifest.
154 pub async fn probe_distribution(
155 client: &reqwest::Client,
156 base_url: &str,
157 slug: &str,
158 version: &str,
159 targets: &[Target],
160 ) -> Distribution {
161 let mut dist = Distribution::default();
162
163 for target in targets {
164 let Some((platform, arch)) = mnw_path_segments(*target) else {
165 continue;
166 };
167 let url = format!(
168 "{}/api/v1/sync/ota/{slug}/{platform}/{arch}/0.0.0",
169 base_url.trim_end_matches('/')
170 );
171
172 match client.get(&url).send().await {
173 // 204 is the endpoint's "no update available", which for a probe
174 // anchored at 0.0.0 means nothing is published at all.
175 Ok(resp) if resp.status() == reqwest::StatusCode::NO_CONTENT => {
176 dist.missing.push(target.to_string());
177 }
178 Ok(resp) if resp.status().is_success() => {
179 match resp.json::<serde_json::Value>().await {
180 Ok(body) => {
181 let served = body.get("version").and_then(|v| v.as_str());
182 let has_url = body
183 .get("url")
184 .and_then(|v| v.as_str())
185 .is_some_and(|u| !u.is_empty());
186 // Both halves matter: a manifest naming the right
187 // version with no download URL is not distributable,
188 // and one with a URL at the wrong version is a stale
189 // release rather than this one.
190 if served == Some(version) && has_url {
191 dist.fetchable.push(target.to_string());
192 } else {
193 dist.missing.push(target.to_string());
194 }
195 }
196 Err(e) => dist.error = Some(format!("{target}: malformed manifest: {e}")),
197 }
198 }
199 Ok(resp) => {
200 // 404 is a slug MNW has never heard of, which is a real "not
201 // distributed" rather than a transport problem.
202 if resp.status() == reqwest::StatusCode::NOT_FOUND {
203 dist.missing.push(target.to_string());
204 } else {
205 dist.error = Some(format!("{target}: MNW answered {}", resp.status()));
206 }
207 }
208 Err(e) => dist.error = Some(format!("{target}: {e}")),
209 }
210 }
211
212 dist
213 }
214
215 /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
216 /// `github-releases`, …) is a single `impl` plus registering its id; recipes
217 /// don't change.
218 pub trait OtaBackend: Send + Sync {
219 fn id(&self) -> &str;
220 fn supports(&self, target: Target) -> bool;
221 /// Publish one artifact; return a channel-specific receipt string. Must be
222 /// idempotent at the backend boundary (re-publishing the same
223 /// version+artifact is a no-op, not a duplicate release). Requires a
224 /// [`PublishAuthority`] — the type-level proof the artifact cleared the
225 /// publish gate — so this method is uncallable for an unverified artifact.
226 fn publish(
227 &self,
228 rel: &Release,
229 artifact: &Path,
230 authority: &PublishAuthority,
231 ) -> Result<String>;
232 }
233
234 /// Named lookup of registered backends.
235 #[derive(Default)]
236 pub struct OtaRegistry {
237 backends: HashMap<String, Box<dyn OtaBackend>>,
238 }
239
240 impl OtaRegistry {
241 pub fn new() -> Self {
242 Self::default()
243 }
244
245 pub fn register(&mut self, backend: Box<dyn OtaBackend>) {
246 self.backends.insert(backend.id().to_string(), backend);
247 }
248
249 pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> {
250 self.backends.get(channel).map(std::convert::AsRef::as_ref)
251 }
252
253 /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint.
254 pub fn standard(mnw_base_url: impl Into<String>) -> Self {
255 let mut reg = Self::new();
256 reg.register(Box::new(TauriMnwBackend {
257 base_url: mnw_base_url.into(),
258 }));
259 reg
260 }
261 }
262
263 /// Hook into Tauri's OTA system via the MNW server (the manifest host +
264 /// release registry). The Tauri updater polls
265 /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a
266 /// minisign signature; this backend registers the release + uploads the signed
267 /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest.
268 pub struct TauriMnwBackend {
269 pub base_url: String,
270 }
271
272 impl OtaBackend for TauriMnwBackend {
273 fn id(&self) -> &'static str {
274 "tauri-mnw"
275 }
276
277 fn supports(&self, target: Target) -> bool {
278 use crate::domain::Platform::{Linux, Macos, Windows};
279 // Tauri's updater covers the desktop trio; mobile rides testflight/play.
280 matches!(target.platform, Macos | Linux | Windows)
281 }
282
283 fn publish(
284 &self,
285 rel: &Release,
286 artifact: &Path,
287 authority: &PublishAuthority,
288 ) -> Result<String> {
289 // Defense-in-depth: the authority is proof the gate passed for *this*
290 // target; refuse a mismatched one rather than trust the caller paired
291 // them correctly.
292 anyhow::ensure!(
293 rel.target == authority.target(),
294 "publish authority is for {} but the release targets {}",
295 authority.target(),
296 rel.target,
297 );
298 // Guardrail: never publish an artifact that is missing OR present-but-empty
299 // (a truncated/zero-byte collect would otherwise pass a bare `exists()`).
300 // Full integrity (minisign .sig verification against the trusted pubkey)
301 // lands with the P2 upload wiring below — this size floor is the cheap
302 // pre-check that catches a failed transfer.
303 let meta = std::fs::metadata(artifact)
304 .with_context(|| format!("artifact {} is not accessible", artifact.display()))?;
305 anyhow::ensure!(
306 meta.is_file(),
307 "artifact {} is not a regular file",
308 artifact.display()
309 );
310 anyhow::ensure!(
311 meta.len() > 0,
312 "artifact {} is empty (0 bytes)",
313 artifact.display()
314 );
315 // P2: POST the release + upload the artifact + its minisign .sig to the
316 // MNW OTA API at `self.base_url`. Until then, report what would happen
317 // so the recipe path is exercised without a half-published release.
318 Ok(format!(
319 "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]",
320 app = rel.app,
321 ver = rel.version,
322 target = rel.target,
323 artifact = artifact.display(),
324 base = self.base_url,
325 ))
326 }
327 }
328
329 #[cfg(test)]
330 mod tests {
331 use super::*;
332
333 #[test]
334 fn standard_registry_has_tauri_mnw() {
335 let reg = OtaRegistry::standard("https://makenot.work");
336 let b = reg.get("tauri-mnw").expect("registered");
337 assert_eq!(b.id(), "tauri-mnw");
338 assert!(b.supports("macos/aarch64".parse().unwrap()));
339 assert!(b.supports("linux/x86_64".parse().unwrap()));
340 assert!(!b.supports("ios/universal".parse().unwrap()));
341 assert!(reg.get("nope").is_none());
342 }
343
344 #[test]
345 fn publish_refuses_missing_artifact() {
346 let reg = OtaRegistry::standard("https://makenot.work");
347 let b = reg.get("tauri-mnw").unwrap();
348 let app = AppId::new("goingson");
349 let ver = Version::parse("0.4.1").unwrap();
350 let target: Target = "macos/aarch64".parse().unwrap();
351 let rel = Release {
352 app: &app,
353 target,
354 version: &ver,
355 notes: String::new(),
356 };
357 let auth = PublishAuthority::for_test(target);
358 assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err());
359 }
360
361 #[test]
362 fn publish_refuses_empty_artifact() {
363 let reg = OtaRegistry::standard("https://makenot.work");
364 let b = reg.get("tauri-mnw").unwrap();
365 let app = AppId::new("goingson");
366 let ver = Version::parse("0.4.1").unwrap();
367 let target: Target = "macos/aarch64".parse().unwrap();
368 let rel = Release {
369 app: &app,
370 target,
371 version: &ver,
372 notes: String::new(),
373 };
374 let auth = PublishAuthority::for_test(target);
375 let tmp = tempfile::tempdir().unwrap();
376 // Zero-byte file: exists() would pass, but a truncated collect must not publish.
377 let empty = tmp.path().join("app.dmg");
378 std::fs::write(&empty, b"").unwrap();
379 let err = b.publish(&rel, &empty, &auth).unwrap_err();
380 assert!(format!("{err:#}").contains("empty"), "{err:#}");
381 // A non-empty file is accepted (P2 stub returns the would-publish receipt).
382 std::fs::write(&empty, b"x").unwrap();
383 assert!(b.publish(&rel, &empty, &auth).is_ok());
384 }
385
386 #[test]
387 fn publish_refuses_mismatched_authority_target() {
388 let reg = OtaRegistry::standard("https://makenot.work");
389 let b = reg.get("tauri-mnw").unwrap();
390 let app = AppId::new("goingson");
391 let ver = Version::parse("0.4.1").unwrap();
392 let rel_target: Target = "macos/aarch64".parse().unwrap();
393 let rel = Release {
394 app: &app,
395 target: rel_target,
396 version: &ver,
397 notes: String::new(),
398 };
399 // Authority proven for a different target must not wave this release through.
400 let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap());
401 let tmp = tempfile::tempdir().unwrap();
402 let f = tmp.path().join("app.tar.gz");
403 std::fs::write(&f, b"x").unwrap();
404 let err = b.publish(&rel, &f, &auth).unwrap_err();
405 assert!(format!("{err:#}").contains("authority"), "{err:#}");
406 }
407 }
408