Skip to main content

max / makenotwork

8.7 KB · 228 lines History Blame Raw
1 //! The Apple-only host functions: notarization, and reading what the notary
2 //! said.
3
4 use super::RecipeCtx;
5 use super::rhai_err;
6 use crate::events::{self, Event};
7 use anyhow::Result;
8 use rhai::{Engine, EvalAltResult};
9 use std::sync::Arc;
10
11 /// macOS signing/notarization host functions. Thin wrappers over the right
12 /// shell incantations, dispatched through the named host's executor. On the mac
13 /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only
14 /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a
15 /// plain SSH session cannot. Each is gated by the host's `sign` capability.
16 pub(super) fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) {
17 {
18 let ctx = ctx.clone();
19 engine.register_fn(
20 "verify_gatekeeper",
21 move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> {
22 // spctl has no JSON mode, so assess on-host and decide there,
23 // emitting an unambiguous sentinel as the final line. We match the
24 // sentinel rather than substring-hunting `source=Notarized...` in a
25 // 2000-char tail: truncation only drops the front, so the sentinel
26 // is always present, and it can't be spoofed by spctl's own prose.
27 // The full assess output is still streamed to the step log.
28 let q = ops_core::remote::sh_quote(path);
29 let cmd = format!(
30 "out=$(spctl --assess -vv --type install {q} 2>&1); printf '%s\\n' \"$out\"; \
31 printf '%s' \"$out\" | grep -q 'source=Notarized Developer ID' \
32 && echo BENTO_GATEKEEPER_OK || echo BENTO_GATEKEEPER_FAIL",
33 );
34 let (_, tail) = ctx.run(host, &cmd).map_err(rhai_err)?;
35 let accepted = tail.contains("BENTO_GATEKEEPER_OK");
36 // Record the verdict for the publish gate. A rejection also
37 // fails the step, so the matrix shows red and `publish` is barred
38 // even if the recipe ignores the returned bool.
39 ctx.set_gatekeeper_ok(accepted);
40 if !accepted {
41 ctx.fail_current_step();
42 }
43 Ok(accepted)
44 },
45 );
46 }
47 {
48 let ctx = ctx.clone();
49 engine.register_fn(
50 "codesign",
51 move |host: &str, identity: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
52 let cmd = format!(
53 "codesign --force --options runtime --timestamp --sign {} {}",
54 ops_core::remote::sh_quote(identity),
55 ops_core::remote::sh_quote(path),
56 );
57 let (code, _) = ctx.run(host, &cmd).map_err(rhai_err)?;
58 if code != 0 {
59 return Err(rhai_err("codesign failed"));
60 }
61 Ok(())
62 },
63 );
64 }
65 {
66 let ctx = ctx.clone();
67 engine.register_fn(
68 "staple",
69 move |host: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
70 let (code, _) = ctx
71 .run(
72 host,
73 &format!("xcrun stapler staple {}", ops_core::remote::sh_quote(path)),
74 )
75 .map_err(rhai_err)?;
76 if code != 0 {
77 return Err(rhai_err("stapler failed"));
78 }
79 Ok(())
80 },
81 );
82 }
83 {
84 let ctx = ctx.clone();
85 engine.register_fn(
86 "notarize",
87 move |host: &str, path: &str| -> Result<String, Box<EvalAltResult>> {
88 ctx.notarize(host, path).map_err(rhai_err)
89 },
90 );
91 }
92 {
93 let ctx = ctx.clone();
94 engine.register_fn(
95 "keychain_open",
96 move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
97 // The full build-keychain lifecycle lives in dist/build-keychain.sh
98 // (design §7); this drives it by name so the recipe stays short.
99 let (code, _) = ctx
100 .run(
101 host,
102 &format!(
103 ". ~/.tauri/passwords.env && ./dist/build-keychain.sh open {}",
104 ops_core::remote::sh_quote(name)
105 ),
106 )
107 .map_err(rhai_err)?;
108 if code != 0 {
109 return Err(rhai_err("keychain_open failed"));
110 }
111 Ok(())
112 },
113 );
114 }
115 {
116 let ctx = ctx.clone();
117 engine.register_fn(
118 "keychain_close",
119 move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
120 let _ = ctx.run(
121 host,
122 &format!(
123 "./dist/build-keychain.sh close {}",
124 ops_core::remote::sh_quote(name)
125 ),
126 );
127 Ok(())
128 },
129 );
130 }
131 }
132
133 /// True iff `notarytool --output-format json` output reports `status: Accepted`.
134 /// Isolates the JSON object (`{`..`}`) from any shell-sourcing noise and reads
135 /// the typed `status` field, rather than substring-matching `"status":"Accepted"`
136 /// in a possibly-truncated tail — which could match the literal inside an error
137 /// message or miss it across a whitespace variant. Fails closed: any parse or
138 /// field miss returns false.
139 pub(super) fn notary_accepted(output: &str) -> bool {
140 let (Some(start), Some(end)) = (output.find('{'), output.rfind('}')) else {
141 return false;
142 };
143 if start > end {
144 return false;
145 }
146 serde_json::from_str::<serde_json::Value>(&output[start..=end])
147 .ok()
148 .and_then(|v| {
149 v.get("status")
150 .and_then(|s| s.as_str())
151 .map(|s| s.eq_ignore_ascii_case("accepted"))
152 })
153 .unwrap_or(false)
154 }
155
156 impl RecipeCtx {
157 /// `xcrun notarytool submit --wait` with bounded retry (the one flaky,
158 /// network-bound step). Emits `NotarizeRetry` per attempt.
159 fn notarize(self: &Arc<Self>, host: &str, path: &str) -> Result<String> {
160 const MAX_ATTEMPTS: u32 = 3;
161 let backoff = self
162 .cfg
163 .notarize_backoff_secs
164 .map_or(std::time::Duration::from_secs(15), |s| {
165 std::time::Duration::from_secs(s)
166 });
167 let cmd = format!(
168 ". ~/.tauri/passwords.env && xcrun notarytool submit {} \
169 --key \"$NOTARY_KEY\" --key-id \"$NOTARY_KEY_ID\" --issuer \"$NOTARY_ISSUER\" \
170 --wait --output-format json",
171 ops_core::remote::sh_quote(path),
172 );
173 let mut last = String::new();
174 for attempt in 1..=MAX_ATTEMPTS {
175 let (code, tail) = self.run(host, &cmd)?;
176 if code == 0 && notary_accepted(&tail) {
177 return Ok(tail);
178 }
179 last = tail;
180 if attempt < MAX_ATTEMPTS {
181 events::emit(
182 &self.events,
183 Event::NotarizeRetry {
184 app: self.app.clone(),
185 target: self.target,
186 attempt,
187 reason: format!("exit {code}"),
188 },
189 );
190 self.rt.block_on(tokio::time::sleep(backoff));
191 }
192 }
193 anyhow::bail!("notarization failed after {MAX_ATTEMPTS} attempts: {last}")
194 }
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200
201 #[test]
202 fn notary_accepted_parses_status_field() {
203 assert!(notary_accepted(
204 r#"{"id":"abc","status":"Accepted","message":"ok"}"#
205 ));
206 // Embedded in shell-sourcing noise: the object is isolated and parsed.
207 assert!(notary_accepted(
208 "sourcing env...\n{\n \"status\": \"Accepted\"\n}\nbye"
209 ));
210 // Whitespace variant that a tight substring `"status":"Accepted"` misses.
211 assert!(notary_accepted(r#"{ "status" : "Accepted" }"#));
212 }
213
214 #[test]
215 fn notary_accepted_rejects_non_accepted_and_garbage() {
216 assert!(!notary_accepted(r#"{"status":"Invalid"}"#));
217 assert!(!notary_accepted(r#"{"status":"In Progress"}"#));
218 assert!(!notary_accepted("no json here"));
219 assert!(!notary_accepted("")); // empty / truncated -> fail closed
220 // A truncated tail whose opening brace was cut off cannot parse -> closed.
221 assert!(!notary_accepted(r#""status":"Accepted"}"#));
222 // The literal appearing inside an error string must NOT pass as success.
223 assert!(!notary_accepted(
224 r#"{"status":"Invalid","message":"expected status:Accepted"}"#
225 ));
226 }
227 }
228