//! The Apple-only host functions: notarization, and reading what the notary //! said. use super::RecipeCtx; use super::rhai_err; use crate::events::{self, Event}; use anyhow::Result; use rhai::{Engine, EvalAltResult}; use std::sync::Arc; /// macOS signing/notarization host functions. Thin wrappers over the right /// shell incantations, dispatched through the named host's executor. On the mac /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a /// plain SSH session cannot. Each is gated by the host's `sign` capability. pub(super) fn register_macos_fns(engine: &mut Engine, ctx: &Arc) { { let ctx = ctx.clone(); engine.register_fn( "verify_gatekeeper", move |host: &str, path: &str| -> Result> { // spctl has no JSON mode, so assess on-host and decide there, // emitting an unambiguous sentinel as the final line. We match the // sentinel rather than substring-hunting `source=Notarized...` in a // 2000-char tail: truncation only drops the front, so the sentinel // is always present, and it can't be spoofed by spctl's own prose. // The full assess output is still streamed to the step log. let q = ops_core::remote::sh_quote(path); let cmd = format!( "out=$(spctl --assess -vv --type install {q} 2>&1); printf '%s\\n' \"$out\"; \ printf '%s' \"$out\" | grep -q 'source=Notarized Developer ID' \ && echo BENTO_GATEKEEPER_OK || echo BENTO_GATEKEEPER_FAIL", ); let (_, tail) = ctx.run(host, &cmd).map_err(rhai_err)?; let accepted = tail.contains("BENTO_GATEKEEPER_OK"); // Record the verdict for the publish gate. A rejection also // fails the step, so the matrix shows red and `publish` is barred // even if the recipe ignores the returned bool. ctx.set_gatekeeper_ok(accepted); if !accepted { ctx.fail_current_step(); } Ok(accepted) }, ); } { let ctx = ctx.clone(); engine.register_fn( "codesign", move |host: &str, identity: &str, path: &str| -> Result<(), Box> { let cmd = format!( "codesign --force --options runtime --timestamp --sign {} {}", ops_core::remote::sh_quote(identity), ops_core::remote::sh_quote(path), ); let (code, _) = ctx.run(host, &cmd).map_err(rhai_err)?; if code != 0 { return Err(rhai_err("codesign failed")); } Ok(()) }, ); } { let ctx = ctx.clone(); engine.register_fn( "staple", move |host: &str, path: &str| -> Result<(), Box> { let (code, _) = ctx .run( host, &format!("xcrun stapler staple {}", ops_core::remote::sh_quote(path)), ) .map_err(rhai_err)?; if code != 0 { return Err(rhai_err("stapler failed")); } Ok(()) }, ); } { let ctx = ctx.clone(); engine.register_fn( "notarize", move |host: &str, path: &str| -> Result> { ctx.notarize(host, path).map_err(rhai_err) }, ); } { let ctx = ctx.clone(); engine.register_fn( "keychain_open", move |host: &str, name: &str| -> Result<(), Box> { // The full build-keychain lifecycle lives in dist/build-keychain.sh // (design §7); this drives it by name so the recipe stays short. let (code, _) = ctx .run( host, &format!( ". ~/.tauri/passwords.env && ./dist/build-keychain.sh open {}", ops_core::remote::sh_quote(name) ), ) .map_err(rhai_err)?; if code != 0 { return Err(rhai_err("keychain_open failed")); } Ok(()) }, ); } { let ctx = ctx.clone(); engine.register_fn( "keychain_close", move |host: &str, name: &str| -> Result<(), Box> { let _ = ctx.run( host, &format!( "./dist/build-keychain.sh close {}", ops_core::remote::sh_quote(name) ), ); Ok(()) }, ); } } /// True iff `notarytool --output-format json` output reports `status: Accepted`. /// Isolates the JSON object (`{`..`}`) from any shell-sourcing noise and reads /// the typed `status` field, rather than substring-matching `"status":"Accepted"` /// in a possibly-truncated tail — which could match the literal inside an error /// message or miss it across a whitespace variant. Fails closed: any parse or /// field miss returns false. pub(super) fn notary_accepted(output: &str) -> bool { let (Some(start), Some(end)) = (output.find('{'), output.rfind('}')) else { return false; }; if start > end { return false; } serde_json::from_str::(&output[start..=end]) .ok() .and_then(|v| { v.get("status") .and_then(|s| s.as_str()) .map(|s| s.eq_ignore_ascii_case("accepted")) }) .unwrap_or(false) } impl RecipeCtx { /// `xcrun notarytool submit --wait` with bounded retry (the one flaky, /// network-bound step). Emits `NotarizeRetry` per attempt. fn notarize(self: &Arc, host: &str, path: &str) -> Result { const MAX_ATTEMPTS: u32 = 3; let backoff = self .cfg .notarize_backoff_secs .map_or(std::time::Duration::from_secs(15), |s| { std::time::Duration::from_secs(s) }); let cmd = format!( ". ~/.tauri/passwords.env && xcrun notarytool submit {} \ --key \"$NOTARY_KEY\" --key-id \"$NOTARY_KEY_ID\" --issuer \"$NOTARY_ISSUER\" \ --wait --output-format json", ops_core::remote::sh_quote(path), ); let mut last = String::new(); for attempt in 1..=MAX_ATTEMPTS { let (code, tail) = self.run(host, &cmd)?; if code == 0 && notary_accepted(&tail) { return Ok(tail); } last = tail; if attempt < MAX_ATTEMPTS { events::emit( &self.events, Event::NotarizeRetry { app: self.app.clone(), target: self.target, attempt, reason: format!("exit {code}"), }, ); self.rt.block_on(tokio::time::sleep(backoff)); } } anyhow::bail!("notarization failed after {MAX_ATTEMPTS} attempts: {last}") } } #[cfg(test)] mod tests { use super::*; #[test] fn notary_accepted_parses_status_field() { assert!(notary_accepted( r#"{"id":"abc","status":"Accepted","message":"ok"}"# )); // Embedded in shell-sourcing noise: the object is isolated and parsed. assert!(notary_accepted( "sourcing env...\n{\n \"status\": \"Accepted\"\n}\nbye" )); // Whitespace variant that a tight substring `"status":"Accepted"` misses. assert!(notary_accepted(r#"{ "status" : "Accepted" }"#)); } #[test] fn notary_accepted_rejects_non_accepted_and_garbage() { assert!(!notary_accepted(r#"{"status":"Invalid"}"#)); assert!(!notary_accepted(r#"{"status":"In Progress"}"#)); assert!(!notary_accepted("no json here")); assert!(!notary_accepted("")); // empty / truncated -> fail closed // A truncated tail whose opening brace was cut off cannot parse -> closed. assert!(!notary_accepted(r#""status":"Accepted"}"#)); // The literal appearing inside an error string must NOT pass as success. assert!(!notary_accepted( r#"{"status":"Invalid","message":"expected status:Accepted"}"# )); } }