| 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 |
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 |
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 |
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 |
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 |
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 |
|
}
|