use std::path::Path;
use async_trait::async_trait;
use serde::Deserialize;
use crate::provider::apple::{write_p8_key, SLOT_ISSUER, SLOT_KEY_ID, SLOT_KEY_P8};
use crate::provider::{ProviderContext, ProviderReport, ReleaseProvider};
use crate::runner::RunnerError;
use crate::types::ProducedArtifact;
const NOTARIZABLE_EXTS: &[&str] = &["app", "dmg", "pkg", "zip"];
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct NotarizeConfig {
artifacts: Vec<String>,
}
#[derive(Debug, Default)]
pub struct NotarizeProvider {
xcrun_bin: Option<String>,
}
impl NotarizeProvider {
fn xcrun(&self) -> &str {
self.xcrun_bin.as_deref().unwrap_or("xcrun")
}
}
#[async_trait]
impl ReleaseProvider for NotarizeProvider {
fn name(&self) -> &str {
"notarize"
}
fn required_slots(&self) -> Vec<&str> {
vec![SLOT_KEY_ID, SLOT_ISSUER, SLOT_KEY_P8]
}
async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
let cfg: NotarizeConfig = parse_config(ctx.config)?;
let key_id = ctx.require_secret(SLOT_KEY_ID)?;
let issuer = ctx.require_secret(SLOT_ISSUER)?;
let key_p8 = ctx.require_secret(SLOT_KEY_P8)?;
let selected = select_artifacts(ctx.artifacts, &cfg.artifacts)?;
let mut report = ProviderReport::default();
if ctx.dry_run {
for art in &selected {
report.actions.push(format!(
"would submit {} to notarytool (key-id {}) + staple",
basename(&art.path),
key_id,
));
}
return Ok(report);
}
let key_path = write_p8_key(ctx.work_dir, &key_id, &key_p8)?;
for art in selected {
notarize_one(self.xcrun(), &art, &key_path, &key_id, &issuer).await?;
staple_one(self.xcrun(), &art).await?;
report
.actions
.push(format!("notarized + stapled {}", basename(&art.path)));
report.produced.push(art);
}
Ok(report)
}
}
fn parse_config(value: &serde_json::Value) -> Result<NotarizeConfig, RunnerError> {
if value.is_null() {
return Ok(NotarizeConfig::default());
}
serde_json::from_value(value.clone())
.map_err(|e| RunnerError::Outcome(format!("notarize: invalid `with` config: {e}")))
}
fn select_artifacts(
artifacts: &[ProducedArtifact],
globs: &[String],
) -> Result<Vec<ProducedArtifact>, RunnerError> {
let selected: Vec<ProducedArtifact> = artifacts
.iter()
.filter(|a| is_notarizable(&a.path))
.filter(|a| globs.is_empty() || globs.iter().any(|g| artifact_matches(a, g)))
.cloned()
.collect();
if selected.is_empty() {
let detail = if globs.is_empty() {
format!(
"no notarizable artifact (.app/.dmg/.pkg/.zip) among {} produced",
artifacts.len()
)
} else {
format!(
"no notarizable artifact matched config globs {globs:?} (of {} produced)",
artifacts.len()
)
};
return Err(RunnerError::Outcome(format!("notarize: {detail}")));
}
Ok(selected)
}
fn is_notarizable(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| NOTARIZABLE_EXTS.iter().any(|n| e.eq_ignore_ascii_case(n)))
.unwrap_or(false)
}
fn artifact_matches(art: &ProducedArtifact, glob: &str) -> bool {
glob_match(glob, &art.binary) || glob_match(glob, basename(&art.path))
}
fn basename(path: &str) -> &str {
Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path)
}
fn glob_match(pattern: &str, text: &str) -> bool {
let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut star_t): (Option<usize>, usize) = (None, 0);
while ti < t.len() {
if pi < p.len() && (p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_t = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
star_t += 1;
ti = star_t;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
async fn notarize_one(
xcrun: &str,
art: &ProducedArtifact,
key_path: &Path,
key_id: &str,
issuer: &str,
) -> Result<(), RunnerError> {
let out = tokio::process::Command::new(xcrun)
.arg("notarytool")
.arg("submit")
.arg(&art.path)
.arg("--key")
.arg(key_path)
.arg("--key-id")
.arg(key_id)
.arg("--issuer")
.arg(issuer)
.arg("--wait")
.output()
.await
.map_err(|e| RunnerError::Outcome(format!("notarize: spawning `{xcrun} notarytool`: {e}")))?;
if !out.status.success() {
return Err(RunnerError::Outcome(format!(
"notarize: notarytool rejected {} (status {}): {}",
basename(&art.path),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
)));
}
Ok(())
}
async fn staple_one(xcrun: &str, art: &ProducedArtifact) -> Result<(), RunnerError> {
let out = tokio::process::Command::new(xcrun)
.arg("stapler")
.arg("staple")
.arg(&art.path)
.output()
.await
.map_err(|e| RunnerError::Outcome(format!("notarize: spawning `{xcrun} stapler`: {e}")))?;
if !out.status.success() {
return Err(RunnerError::Outcome(format!(
"notarize: stapler failed on {} (status {}): {}",
basename(&art.path),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::MapSecrets;
use std::collections::BTreeMap;
fn art(binary: &str, path: &str) -> ProducedArtifact {
ProducedArtifact {
binary: binary.into(),
path: path.into(),
triple: Some("darwin-aarch64".into()),
}
}
fn full_secrets() -> MapSecrets {
let mut m = BTreeMap::new();
m.insert(SLOT_KEY_ID.into(), "ABC123".into());
m.insert(SLOT_ISSUER.into(), "issuer-uuid".into());
m.insert(SLOT_KEY_P8.into(), "-----BEGIN PRIVATE KEY-----\nx\n".into());
MapSecrets(m)
}
fn ctx<'a>(
secrets: &'a dyn crate::provider::SecretSource,
work: &'a Path,
cfg: &'a serde_json::Value,
artifacts: &'a [ProducedArtifact],
dry_run: bool,
) -> ProviderContext<'a> {
ProviderContext {
version: "1.2.3",
artifacts,
base_url: None,
config: cfg,
work_dir: work,
secrets,
dry_run,
}
}
#[test]
fn declares_apple_api_key_slots() {
let p = NotarizeProvider::default();
assert_eq!(p.name(), "notarize");
assert_eq!(
p.required_slots(),
vec![SLOT_KEY_ID, SLOT_ISSUER, SLOT_KEY_P8]
);
}
#[tokio::test]
async fn dry_run_plans_per_artifact_without_spawning_xcrun() {
let work = tempfile::tempdir().unwrap();
let secrets = full_secrets();
let artifacts = vec![art("desktop", "out/Desktop.dmg"), art("yah", "out/yah")];
let cfg = serde_json::Value::Null;
let report = NotarizeProvider::default()
.dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
.await
.unwrap();
assert_eq!(report.actions.len(), 1);
assert!(report.actions[0].contains("would submit Desktop.dmg"));
assert!(report.produced.is_empty(), "dry run mutates nothing");
assert!(report.published.is_empty());
assert!(!work.path().join("AuthKey_ABC123.p8").exists());
}
#[tokio::test]
async fn dry_run_missing_slot_is_typed_error_naming_slot() {
let work = tempfile::tempdir().unwrap();
let mut m = BTreeMap::new();
m.insert(SLOT_KEY_ID.into(), "ABC123".into());
m.insert(SLOT_ISSUER.into(), "issuer-uuid".into());
let secrets = MapSecrets(m);
let artifacts = vec![art("desktop", "out/Desktop.dmg")];
let cfg = serde_json::Value::Null;
let err = NotarizeProvider::default()
.dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
.await
.unwrap_err();
assert!(format!("{err}").contains(SLOT_KEY_P8), "names the slot: {err}");
}
#[tokio::test]
async fn no_notarizable_artifact_is_a_config_error() {
let work = tempfile::tempdir().unwrap();
let secrets = full_secrets();
let artifacts = vec![art("yah", "out/yah"), art("camp", "out/camp")];
let cfg = serde_json::Value::Null;
let err = NotarizeProvider::default()
.dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
.await
.unwrap_err();
assert!(format!("{err}").contains("no notarizable artifact"));
}
#[tokio::test]
async fn config_glob_filters_to_named_bundle() {
let work = tempfile::tempdir().unwrap();
let secrets = full_secrets();
let artifacts = vec![
art("desktop", "out/Desktop.dmg"),
art("helper", "out/Helper.pkg"),
];
let cfg = serde_json::json!({ "artifacts": ["desktop"] });
let report = NotarizeProvider::default()
.dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
.await
.unwrap();
assert_eq!(report.actions.len(), 1);
assert!(report.actions[0].contains("Desktop.dmg"));
}
#[tokio::test]
async fn config_glob_with_no_match_errors() {
let work = tempfile::tempdir().unwrap();
let secrets = full_secrets();
let artifacts = vec![art("desktop", "out/Desktop.dmg")];
let cfg = serde_json::json!({ "artifacts": ["nonexistent-*"] });
let err = NotarizeProvider::default()
.dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
.await
.unwrap_err();
assert!(format!("{err}").contains("no notarizable artifact matched"));
}
#[test]
fn glob_match_supports_star() {
assert!(glob_match("*.dmg", "Desktop.dmg"));
assert!(glob_match("desktop", "desktop"));
assert!(glob_match("yah-*", "yah-helper"));
assert!(glob_match("*", "anything"));
assert!(!glob_match("*.dmg", "Desktop.pkg"));
assert!(!glob_match("desktop", "desktop-helper"));
}
#[test]
fn notarizable_extension_is_case_insensitive() {
assert!(is_notarizable("App.DMG"));
assert!(is_notarizable("Foo.app"));
assert!(is_notarizable("Bar.pkg"));
assert!(!is_notarizable("yah"));
assert!(!is_notarizable("notes.txt"));
}
}