Skip to main content

max / makenotwork

10.8 KB · 283 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 /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
91 /// `github-releases`, …) is a single `impl` plus registering its id; recipes
92 /// don't change.
93 pub trait OtaBackend: Send + Sync {
94 fn id(&self) -> &str;
95 fn supports(&self, target: Target) -> bool;
96 /// Publish one artifact; return a channel-specific receipt string. Must be
97 /// idempotent at the backend boundary (re-publishing the same
98 /// version+artifact is a no-op, not a duplicate release). Requires a
99 /// [`PublishAuthority`] — the type-level proof the artifact cleared the
100 /// publish gate — so this method is uncallable for an unverified artifact.
101 fn publish(
102 &self,
103 rel: &Release,
104 artifact: &Path,
105 authority: &PublishAuthority,
106 ) -> Result<String>;
107 }
108
109 /// Named lookup of registered backends.
110 #[derive(Default)]
111 pub struct OtaRegistry {
112 backends: HashMap<String, Box<dyn OtaBackend>>,
113 }
114
115 impl OtaRegistry {
116 pub fn new() -> Self {
117 Self::default()
118 }
119
120 pub fn register(&mut self, backend: Box<dyn OtaBackend>) {
121 self.backends.insert(backend.id().to_string(), backend);
122 }
123
124 pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> {
125 self.backends.get(channel).map(std::convert::AsRef::as_ref)
126 }
127
128 /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint.
129 pub fn standard(mnw_base_url: impl Into<String>) -> Self {
130 let mut reg = Self::new();
131 reg.register(Box::new(TauriMnwBackend {
132 base_url: mnw_base_url.into(),
133 }));
134 reg
135 }
136 }
137
138 /// Hook into Tauri's OTA system via the MNW server (the manifest host +
139 /// release registry). The Tauri updater polls
140 /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a
141 /// minisign signature; this backend registers the release + uploads the signed
142 /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest.
143 pub struct TauriMnwBackend {
144 pub base_url: String,
145 }
146
147 impl OtaBackend for TauriMnwBackend {
148 fn id(&self) -> &'static str {
149 "tauri-mnw"
150 }
151
152 fn supports(&self, target: Target) -> bool {
153 use crate::domain::Platform::{Linux, Macos, Windows};
154 // Tauri's updater covers the desktop trio; mobile rides testflight/play.
155 matches!(target.platform, Macos | Linux | Windows)
156 }
157
158 fn publish(
159 &self,
160 rel: &Release,
161 artifact: &Path,
162 authority: &PublishAuthority,
163 ) -> Result<String> {
164 // Defense-in-depth: the authority is proof the gate passed for *this*
165 // target; refuse a mismatched one rather than trust the caller paired
166 // them correctly.
167 anyhow::ensure!(
168 rel.target == authority.target(),
169 "publish authority is for {} but the release targets {}",
170 authority.target(),
171 rel.target,
172 );
173 // Guardrail: never publish an artifact that is missing OR present-but-empty
174 // (a truncated/zero-byte collect would otherwise pass a bare `exists()`).
175 // Full integrity (minisign .sig verification against the trusted pubkey)
176 // lands with the P2 upload wiring below — this size floor is the cheap
177 // pre-check that catches a failed transfer.
178 let meta = std::fs::metadata(artifact)
179 .with_context(|| format!("artifact {} is not accessible", artifact.display()))?;
180 anyhow::ensure!(
181 meta.is_file(),
182 "artifact {} is not a regular file",
183 artifact.display()
184 );
185 anyhow::ensure!(
186 meta.len() > 0,
187 "artifact {} is empty (0 bytes)",
188 artifact.display()
189 );
190 // P2: POST the release + upload the artifact + its minisign .sig to the
191 // MNW OTA API at `self.base_url`. Until then, report what would happen
192 // so the recipe path is exercised without a half-published release.
193 Ok(format!(
194 "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]",
195 app = rel.app,
196 ver = rel.version,
197 target = rel.target,
198 artifact = artifact.display(),
199 base = self.base_url,
200 ))
201 }
202 }
203
204 #[cfg(test)]
205 mod tests {
206 use super::*;
207
208 #[test]
209 fn standard_registry_has_tauri_mnw() {
210 let reg = OtaRegistry::standard("https://makenot.work");
211 let b = reg.get("tauri-mnw").expect("registered");
212 assert_eq!(b.id(), "tauri-mnw");
213 assert!(b.supports("macos/aarch64".parse().unwrap()));
214 assert!(b.supports("linux/x86_64".parse().unwrap()));
215 assert!(!b.supports("ios/universal".parse().unwrap()));
216 assert!(reg.get("nope").is_none());
217 }
218
219 #[test]
220 fn publish_refuses_missing_artifact() {
221 let reg = OtaRegistry::standard("https://makenot.work");
222 let b = reg.get("tauri-mnw").unwrap();
223 let app = AppId::new("goingson");
224 let ver = Version::parse("0.4.1").unwrap();
225 let target: Target = "macos/aarch64".parse().unwrap();
226 let rel = Release {
227 app: &app,
228 target,
229 version: &ver,
230 notes: String::new(),
231 };
232 let auth = PublishAuthority::for_test(target);
233 assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err());
234 }
235
236 #[test]
237 fn publish_refuses_empty_artifact() {
238 let reg = OtaRegistry::standard("https://makenot.work");
239 let b = reg.get("tauri-mnw").unwrap();
240 let app = AppId::new("goingson");
241 let ver = Version::parse("0.4.1").unwrap();
242 let target: Target = "macos/aarch64".parse().unwrap();
243 let rel = Release {
244 app: &app,
245 target,
246 version: &ver,
247 notes: String::new(),
248 };
249 let auth = PublishAuthority::for_test(target);
250 let tmp = tempfile::tempdir().unwrap();
251 // Zero-byte file: exists() would pass, but a truncated collect must not publish.
252 let empty = tmp.path().join("app.dmg");
253 std::fs::write(&empty, b"").unwrap();
254 let err = b.publish(&rel, &empty, &auth).unwrap_err();
255 assert!(format!("{err:#}").contains("empty"), "{err:#}");
256 // A non-empty file is accepted (P2 stub returns the would-publish receipt).
257 std::fs::write(&empty, b"x").unwrap();
258 assert!(b.publish(&rel, &empty, &auth).is_ok());
259 }
260
261 #[test]
262 fn publish_refuses_mismatched_authority_target() {
263 let reg = OtaRegistry::standard("https://makenot.work");
264 let b = reg.get("tauri-mnw").unwrap();
265 let app = AppId::new("goingson");
266 let ver = Version::parse("0.4.1").unwrap();
267 let rel_target: Target = "macos/aarch64".parse().unwrap();
268 let rel = Release {
269 app: &app,
270 target: rel_target,
271 version: &ver,
272 notes: String::new(),
273 };
274 // Authority proven for a different target must not wave this release through.
275 let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap());
276 let tmp = tempfile::tempdir().unwrap();
277 let f = tmp.path().join("app.tar.gz");
278 std::fs::write(&f, b"x").unwrap();
279 let err = b.publish(&rel, &f, &auth).unwrap_err();
280 assert!(format!("{err:#}").contains("authority"), "{err:#}");
281 }
282 }
283