use std::ffi::OsStr;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use plist::Value;
use xshell::{Cmd, Shell, cmd};
const HOST_XCODE_ENV: &[&str] = &[
"HOME",
"TMPDIR",
"USER",
"LOGNAME",
"SHELL",
"SSH_AUTH_SOCK",
];
#[derive(Clone, Debug)]
pub struct Xcode {
developer_dir: PathBuf,
}
impl Xcode {
pub fn resolve(env_key: &str) -> Self {
let developer_dir = std::env::var_os(env_key)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/Applications/Xcode.app/Contents/Developer"));
Self { developer_dir }
}
pub fn new(developer_dir: impl Into<PathBuf>) -> Self {
Self {
developer_dir: developer_dir.into(),
}
}
pub fn developer_dir(&self) -> &Path {
&self.developer_dir
}
pub fn has_ios_sdk(&self) -> bool {
self.developer_dir
.join("Platforms/iPhoneOS.platform")
.exists()
}
pub fn xcrun(&self, sh: &Shell, sdk: &str, args: &[&str]) -> Result<String> {
let mut full = vec!["--sdk", sdk];
full.extend_from_slice(args);
Ok(cmd!(sh, "/usr/bin/xcrun {full...}")
.env("DEVELOPER_DIR", self.developer_dir.as_os_str())
.read()
.with_context(|| format!("run xcrun for SDK {sdk}"))?
.trim()
.to_string())
}
pub fn sdk_path(&self, sh: &Shell, sdk: &str) -> Result<String> {
self.xcrun(sh, sdk, &["--show-sdk-path"])
}
pub fn clean_cmd<'a>(&self, sh: &'a Shell, program: &str, args: &[&str]) -> Cmd<'a> {
keep_host_env(sh.cmd(program).args(args).env_clear())
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
.env("MACOSX_DEPLOYMENT_TARGET", "14.0")
.env("DEVELOPER_DIR", self.developer_dir.as_os_str())
}
}
fn keep_host_env(cmd: Cmd<'_>) -> Cmd<'_> {
HOST_XCODE_ENV
.iter()
.filter_map(|key| std::env::var_os(key).map(|value| (*key, value)))
.fold(cmd, |cmd, (key, value)| cmd.env(key, value))
}
#[derive(Clone, Debug)]
pub struct CodesignOptions<'a> {
pub identity: &'a str,
pub target: &'a Path,
pub entitlements: Option<&'a Path>,
pub identifier: Option<&'a str>,
pub hardened_runtime: bool,
pub timestamp: bool,
}
impl<'a> CodesignOptions<'a> {
pub fn runtime(identity: &'a str, target: &'a Path) -> Self {
Self {
identity,
target,
entitlements: None,
identifier: None,
hardened_runtime: true,
timestamp: true,
}
}
}
pub fn codesign(options: &CodesignOptions<'_>) -> Result<()> {
let mut command = Command::new("/usr/bin/codesign");
command.arg("--force");
if options.hardened_runtime {
command.args(["--options", "runtime"]);
}
if let Some(identifier) = options.identifier {
command.args(["--identifier", identifier]);
}
if let Some(entitlements) = options.entitlements {
command.arg("--entitlements").arg(entitlements);
}
command.args(["--sign", options.identity]);
if options.timestamp && options.identity != "-" {
command.arg("--timestamp");
}
command.arg(options.target);
let status = command
.status()
.with_context(|| format!("run codesign for {}", options.target.display()))?;
if status.success() {
Ok(())
} else {
bail!(
"codesign failed for {} with {status}",
options.target.display()
)
}
}
pub fn verify_signature(target: impl AsRef<Path>) -> Result<()> {
let target = target.as_ref();
let status = Command::new("/usr/bin/codesign")
.args([OsStr::new("--verify"), OsStr::new("--strict")])
.arg(target)
.status()
.with_context(|| format!("verify signature for {}", target.display()))?;
if status.success() {
Ok(())
} else {
bail!(
"codesign verification failed for {} with {status}",
target.display()
)
}
}
pub fn entitlements_xml(target: impl AsRef<Path>) -> Result<String> {
let target = target.as_ref();
let output = Command::new("/usr/bin/codesign")
.args(["-d", "--entitlements", "-", "--xml"])
.arg(target)
.output()
.with_context(|| format!("read entitlements for {}", target.display()))?;
if !output.status.success() {
bail!(
"codesign could not read entitlements for {}",
target.display()
);
}
let mut xml = String::from_utf8_lossy(&output.stdout).into_owned();
xml.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(xml)
}
pub fn set_plist_string(plist: impl AsRef<Path>, key: &str, value: &str) -> Result<()> {
let plist = plist.as_ref();
let mut root =
Value::from_file(plist).with_context(|| format!("read plist {}", plist.display()))?;
let dict = root
.as_dictionary_mut()
.with_context(|| format!("plist root is not a dictionary: {}", plist.display()))?;
dict.insert(key.into(), Value::String(value.to_string()));
root.to_file_xml(plist)
.with_context(|| format!("write plist {}", plist.display()))
}
#[derive(Clone, Debug)]
pub struct ProvisioningProfile {
pub path: PathBuf,
pub name: String,
pub application_identifier: String,
pub provisions_all_devices: bool,
}
pub fn find_provisioning_profiles(app_id: &str) -> Vec<ProvisioningProfile> {
profile_search_roots()
.into_iter()
.flat_map(|root| profile_files(&root))
.filter_map(|path| read_provisioning_profile(&path).ok())
.filter(|profile| profile.application_identifier == app_id)
.collect()
}
pub fn resolve_provisioning_profile(
env_key: &str,
app_id: &str,
) -> Result<Option<ProvisioningProfile>> {
if let Ok(path) = std::env::var(env_key) {
let path = PathBuf::from(path);
if !path.exists() {
bail!("{env_key} does not exist: {}", path.display());
}
return read_provisioning_profile(&path).map(Some);
}
let profiles = find_provisioning_profiles(app_id);
match profiles.as_slice() {
[] => Ok(None),
[profile] => Ok(Some(profile.clone())),
many => {
let developer_id = many
.iter()
.filter(|profile| profile.provisions_all_devices)
.collect::<Vec<_>>();
if let [profile] = developer_id.as_slice() {
return Ok(Some((*profile).clone()));
}
let mut message = format!(
"multiple installed provisioning profiles matched {app_id}; set {env_key} to choose one:"
);
for profile in many {
message.push_str(&format!(
"\n - {} ({}, ProvisionsAllDevices={})",
profile.path.display(),
profile.name,
profile.provisions_all_devices
));
}
bail!(message)
}
}
}
pub fn read_provisioning_profile(path: impl AsRef<Path>) -> Result<ProvisioningProfile> {
let path = path.as_ref();
let output = Command::new("/usr/bin/security")
.args(["cms", "-D", "-i"])
.arg(path)
.output()
.with_context(|| format!("decode provisioning profile {}", path.display()))?;
if !output.status.success() {
bail!("security cms failed for {}", path.display());
}
let plist = Value::from_reader_xml(Cursor::new(output.stdout))?;
let root = plist
.as_dictionary()
.with_context(|| format!("profile root is not a dictionary: {}", path.display()))?;
let entitlements = root
.get("Entitlements")
.and_then(Value::as_dictionary)
.context("profile has no Entitlements dictionary")?;
let application_identifier = entitlements
.get("com.apple.application-identifier")
.and_then(Value::as_string)
.context("profile has no com.apple.application-identifier entitlement")?
.to_string();
Ok(ProvisioningProfile {
path: path.to_path_buf(),
name: root
.get("Name")
.and_then(Value::as_string)
.unwrap_or("<unnamed>")
.to_string(),
application_identifier,
provisions_all_devices: root
.get("ProvisionsAllDevices")
.and_then(Value::as_boolean)
.unwrap_or(false),
})
}
fn profile_search_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(home) = std::env::var("HOME") {
let standard = PathBuf::from(&home).join("Library/MobileDevice/Provisioning Profiles");
if standard.exists() {
roots.push(standard);
} else {
roots.push(PathBuf::from(home).join("Developer"));
}
}
roots
}
fn profile_files(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|ext| ext.to_str());
if matches!(ext, Some("provisionprofile" | "mobileprovision")) {
files.push(path);
}
}
}
files
}
pub fn notarize_and_staple(artifact: impl AsRef<Path>, keychain_profile: &str) -> Result<()> {
let artifact = artifact.as_ref();
let output = Command::new("/usr/bin/xcrun")
.args([
"notarytool",
"submit",
&artifact.to_string_lossy(),
"--keychain-profile",
keychain_profile,
"--wait",
"--timeout",
"90m",
])
.output()
.with_context(|| format!("submit {} for notarization", artifact.display()))?;
let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
log.push_str(&String::from_utf8_lossy(&output.stderr));
print!("{log}");
if !output.status.success() || !log.contains("status: Accepted") {
bail!(
"notarization did not reach Accepted for {}",
artifact.display()
);
}
let status = Command::new("/usr/bin/xcrun")
.args(["stapler", "staple", &artifact.to_string_lossy()])
.status()
.with_context(|| format!("staple notarization ticket to {}", artifact.display()))?;
if status.success() {
Ok(())
} else {
bail!("stapler failed for {} with {status}", artifact.display())
}
}