use std::{
cmp::Ordering,
ffi::OsString,
fs::File,
io::prelude::*,
path::{Path, PathBuf},
process::Command,
};
use serde::Deserialize;
use crate::{config::MacOsNotarizationCredentials, shell::CommandExt, Config, Error};
const KEYCHAIN_ID: &str = "cargo-packager.keychain";
const KEYCHAIN_PWD: &str = "cargo-packager";
#[tracing::instrument(level = "trace")]
pub fn setup_keychain(
certificate_encoded: OsString,
certificate_password: OsString,
) -> crate::Result<()> {
delete_keychain();
tracing::info!("Setting up keychain from environment variables...");
let keychain_list_output = Command::new("security")
.args(["list-keychain", "-d", "user"])
.output()
.map_err(Error::FailedToListKeyChain)?;
let tmp_dir = tempfile::tempdir()?;
let cert_path = tmp_dir
.path()
.join("cert.p12")
.to_string_lossy()
.to_string();
let cert_path_tmp = tmp_dir.path().join("cert.p12.tmp");
let cert_path_tmp_str = cert_path_tmp.to_string_lossy().to_string();
let certificate_encoded = certificate_encoded
.to_str()
.expect("failed to convert APPLE_CERTIFICATE to string")
.as_bytes();
let certificate_password = certificate_password
.to_str()
.expect("failed to convert APPLE_CERTIFICATE_PASSWORD to string")
.to_string();
let mut tmp_cert =
File::create(&cert_path_tmp).map_err(|e| Error::IoWithPath(cert_path_tmp, e))?;
tmp_cert.write_all(certificate_encoded)?;
Command::new("base64")
.args(["--decode", "-i", &cert_path_tmp_str, "-o", &cert_path])
.output_ok()
.map_err(Error::FailedToDecodeCert)?;
Command::new("security")
.args(["create-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID])
.output_ok()
.map_err(Error::FailedToCreateKeyChain)?;
Command::new("security")
.args(["unlock-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID])
.output_ok()
.map_err(Error::FailedToUnlockKeyChain)?;
Command::new("security")
.args([
"import",
&cert_path,
"-k",
KEYCHAIN_ID,
"-P",
&certificate_password,
"-T",
"/usr/bin/codesign",
"-T",
"/usr/bin/pkgbuild",
"-T",
"/usr/bin/productbuild",
])
.output_ok()
.map_err(Error::FailedToImportCert)?;
Command::new("security")
.args(["set-keychain-settings", "-t", "3600", "-u", KEYCHAIN_ID])
.output_ok()
.map_err(Error::FailedToSetKeychainSettings)?;
Command::new("security")
.args([
"set-key-partition-list",
"-S",
"apple-tool:,apple:,codesign:",
"-s",
"-k",
KEYCHAIN_PWD,
KEYCHAIN_ID,
])
.output_ok()
.map_err(Error::FailedToSetKeyPartitionList)?;
let current_keychains = String::from_utf8_lossy(&keychain_list_output.stdout)
.split('\n')
.map(|line| {
line.trim_matches(|c: char| c.is_whitespace() || c == '"')
.to_string()
})
.filter(|l| !l.is_empty())
.collect::<Vec<String>>();
Command::new("security")
.args(["list-keychain", "-d", "user", "-s"])
.args(current_keychains)
.arg(KEYCHAIN_ID)
.output_ok()
.map_err(Error::FailedToListKeyChain)?;
Ok(())
}
#[tracing::instrument(level = "trace")]
pub fn delete_keychain() {
let _ = Command::new("security")
.arg("delete-keychain")
.arg(KEYCHAIN_ID)
.output_ok();
}
#[derive(Debug, PartialEq, Eq)]
pub struct SignTarget {
pub path: PathBuf,
pub is_native_binary: bool,
}
impl Ord for SignTarget {
fn cmp(&self, other: &Self) -> Ordering {
let self_count = self.path.components().count();
let other_count = other.path.components().count();
other_count.cmp(&self_count)
}
}
impl PartialOrd for SignTarget {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[tracing::instrument(level = "trace", skip(config))]
pub fn try_sign(targets: Vec<SignTarget>, identity: &str, config: &Config) -> crate::Result<()> {
let certificate_encoded = config
.macos()
.and_then(|m| m.signing_certificate.clone())
.or_else(|| std::env::var_os("APPLE_CERTIFICATE"));
let certificate_password = config
.macos()
.and_then(|m| m.signing_certificate_password.clone())
.or_else(|| std::env::var_os("APPLE_CERTIFICATE_PASSWORD"));
let packager_keychain = if let (Some(certificate_encoded), Some(certificate_password)) =
(certificate_encoded, certificate_password)
{
setup_keychain(certificate_encoded, certificate_password)?;
true
} else {
false
};
for target in targets {
sign(
&target.path,
identity,
config,
target.is_native_binary,
packager_keychain,
)?;
}
if packager_keychain {
delete_keychain();
}
Ok(())
}
#[tracing::instrument(level = "trace", skip(config))]
fn sign(
path_to_sign: &Path,
identity: &str,
config: &Config,
is_native_binary: bool,
packager_keychain: bool,
) -> crate::Result<()> {
tracing::info!(
"Codesigning {} with identity \"{}\"",
path_to_sign.display(),
identity
);
let mut args = vec!["--force", "-s", identity];
if packager_keychain {
args.push("--keychain");
args.push(KEYCHAIN_ID);
}
if let Some(entitlements_path) = config.macos().and_then(|macos| macos.entitlements.as_ref()) {
args.push("--entitlements");
args.push(entitlements_path);
}
if is_native_binary {
args.push("--options");
args.push("runtime");
}
args.push("--timestamp");
Command::new("codesign")
.args(args)
.arg(path_to_sign)
.output_ok()
.map_err(Error::FailedToRunCodesign)?;
Ok(())
}
#[derive(Deserialize, Debug)]
struct NotarytoolSubmitOutput {
id: String,
status: String,
message: String,
}
#[tracing::instrument(level = "trace", skip(config))]
pub fn notarize(
app_bundle_path: PathBuf,
auth: MacOsNotarizationCredentials,
config: &Config,
) -> crate::Result<()> {
let bundle_stem = app_bundle_path
.file_stem()
.ok_or_else(|| Error::FailedToExtractFilename(app_bundle_path.clone()))?;
let tmp_dir = tempfile::tempdir()?;
let zip_path = tmp_dir
.path()
.join(format!("{}.zip", bundle_stem.to_string_lossy()));
let app_bundle_path_str = app_bundle_path.to_string_lossy().to_string();
let zip_path_str = zip_path.to_string_lossy().to_string();
let zip_args = vec![
"-c",
"-k",
"--keepParent",
"--sequesterRsrc",
&app_bundle_path_str,
&zip_path_str,
];
Command::new("ditto")
.args(zip_args)
.output_ok()
.map_err(Error::FailedToRunDitto)?;
if let Some(identity) = &config
.macos()
.and_then(|macos| macos.signing_identity.as_ref())
{
try_sign(
vec![SignTarget {
path: zip_path.clone(),
is_native_binary: false,
}],
identity,
config,
)?;
};
let zip_path_str = zip_path.to_string_lossy().to_string();
let notarize_args = vec![
"notarytool",
"submit",
&zip_path_str,
"--wait",
"--output-format",
"json",
];
tracing::info!("Notarizing {}", app_bundle_path.display());
let output = Command::new("xcrun")
.args(notarize_args)
.notarytool_args(&auth)
.output_ok()
.map_err(Error::FailedToRunXcrun)?;
if !output.status.success() {
return Err(Error::FailedToNotarize);
}
let output_str = String::from_utf8_lossy(&output.stdout);
if let Ok(submit_output) = serde_json::from_str::<NotarytoolSubmitOutput>(&output_str) {
let log_message = format!(
"Finished with status {} for id {} ({})",
submit_output.status, submit_output.id, submit_output.message
);
if submit_output.status == "Accepted" {
tracing::info!("Notarizing {}", log_message);
staple_app(app_bundle_path)?;
Ok(())
} else if let Ok(output) = Command::new("xcrun")
.args(["notarytool", "log"])
.arg(&submit_output.id)
.notarytool_args(&auth)
.output_ok()
{
Err(Error::NotarizeRejected(format!(
"{log_message}\nLog:\n{}",
String::from_utf8_lossy(&output.stdout),
)))
} else {
Err(Error::NotarizeRejected(log_message))
}
} else {
Err(Error::FailedToParseNotarytoolOutput(
output_str.into_owned(),
))
}
}
fn staple_app(app_bundle_path: PathBuf) -> crate::Result<()> {
let filename = app_bundle_path
.file_name()
.ok_or_else(|| Error::FailedToExtractFilename(app_bundle_path.clone()))?
.to_string_lossy()
.to_string();
let app_bundle_path_dir = app_bundle_path
.parent()
.ok_or_else(|| Error::ParentDirNotFound(app_bundle_path.clone()))?;
Command::new("xcrun")
.args(vec!["stapler", "staple", "-v", &filename])
.current_dir(app_bundle_path_dir)
.output_ok()
.map_err(Error::FailedToRunXcrun)?;
Ok(())
}
pub trait NotarytoolCmdExt {
fn notarytool_args(&mut self, auth: &MacOsNotarizationCredentials) -> &mut Self;
}
impl NotarytoolCmdExt for Command {
fn notarytool_args(&mut self, auth: &MacOsNotarizationCredentials) -> &mut Self {
match auth {
MacOsNotarizationCredentials::AppleId {
apple_id,
password,
team_id,
} => {
self.arg("--apple-id")
.arg(apple_id)
.arg("--password")
.arg(password)
.arg("--team-id")
.arg(team_id);
self
}
MacOsNotarizationCredentials::ApiKey {
key_id,
key_path,
issuer,
} => self
.arg("--key-id")
.arg(key_id)
.arg("--key")
.arg(key_path)
.arg("--issuer")
.arg(issuer),
MacOsNotarizationCredentials::KeychainProfile { keychain_profile } => {
self.arg("--keychain-profile").arg(keychain_profile)
}
}
}
}
#[tracing::instrument(level = "trace")]
pub fn notarize_auth() -> crate::Result<MacOsNotarizationCredentials> {
if let Some(keychain_profile) = std::env::var_os("APPLE_KEYCHAIN_PROFILE") {
Ok(MacOsNotarizationCredentials::KeychainProfile { keychain_profile })
} else {
match (
std::env::var_os("APPLE_ID"),
std::env::var_os("APPLE_PASSWORD"),
std::env::var_os("APPLE_TEAM_ID"),
) {
(Some(apple_id), Some(password), Some(team_id)) => {
Ok(MacOsNotarizationCredentials::AppleId {
apple_id,
password,
team_id,
})
}
_ => {
match (
std::env::var_os("APPLE_API_KEY"),
std::env::var_os("APPLE_API_ISSUER"),
std::env::var("APPLE_API_KEY_PATH"),
) {
(Some(key_id), Some(issuer), Ok(key_path)) => {
Ok(MacOsNotarizationCredentials::ApiKey {
key_id,
key_path: key_path.into(),
issuer,
})
}
(Some(key_id), Some(issuer), Err(_)) => {
let mut api_key_file_name = OsString::from("AuthKey_");
api_key_file_name.push(&key_id);
api_key_file_name.push(".p8");
let mut key_path = None;
let mut search_paths = vec!["./private_keys".into()];
if let Some(home_dir) = dirs::home_dir() {
search_paths.push(home_dir.join("private_keys"));
search_paths.push(home_dir.join(".private_keys"));
search_paths.push(home_dir.join(".appstoreconnect/private_keys"));
}
for folder in search_paths {
if let Some(path) = find_api_key(folder, &api_key_file_name) {
key_path = Some(path);
break;
}
}
if let Some(key_path) = key_path {
Ok(MacOsNotarizationCredentials::ApiKey {
key_id,
key_path,
issuer,
})
} else {
Err(Error::ApiKeyMissing {
filename: api_key_file_name
.into_string()
.expect("failed to convert api_key_file_name to string"),
})
}
}
_ => Err(Error::MissingNotarizeAuthVars),
}
}
}
}
}
fn find_api_key(folder: PathBuf, file_name: &OsString) -> Option<PathBuf> {
let path = folder.join(file_name);
if path.exists() {
Some(path)
} else {
None
}
}