use serde::Serialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone)]
pub struct AltoolAuth {
pub key_id: String,
pub issuer_id: String,
pub private_key_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct UploadOutcome {
pub package: String,
pub success: bool,
pub exit_code: Option<i32>,
pub delivery_id: Option<String>,
pub output: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AltoolOp {
Validate,
Upload,
}
impl AltoolOp {
fn flag(self) -> &'static str {
match self {
AltoolOp::Validate => "--validate-app",
AltoolOp::Upload => "--upload-package",
}
}
}
pub fn altool_args(package: &Path, auth: &AltoolAuth, op: AltoolOp, wait: bool) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
"altool".into(),
op.flag().into(),
package.as_os_str().to_owned(),
"--api-key".into(),
auth.key_id.clone().into(),
"--api-issuer".into(),
auth.issuer_id.clone().into(),
"--p8-file-path".into(),
auth.private_key_path.as_os_str().to_owned(),
"--output-format".into(),
"json".into(),
];
if wait && op == AltoolOp::Upload {
args.push("--wait".into());
}
args
}
pub fn validate_package(package: &Path) -> Result<(), String> {
let extension = package
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("ipa") | Some("pkg") => {}
Some("app") => {
return Err(format!(
"{} is an .app bundle, not an uploadable package; export the \
archive first (xcodebuild -exportArchive) to get an .ipa/.pkg",
package.display()
))
}
_ => {
return Err(format!(
"{} is not an .ipa or .pkg; export the archive first \
(xcodebuild -exportArchive)",
package.display()
))
}
}
if !package.exists() {
return Err(format!("{} does not exist", package.display()));
}
if !package.is_file() {
return Err(format!(
"{} is a directory, not a package file",
package.display()
));
}
Ok(())
}
pub fn upload_package(
package: &Path,
auth: &AltoolAuth,
wait: bool,
) -> Result<UploadOutcome, String> {
run_altool(package, auth, AltoolOp::Upload, wait)
}
pub fn validate_app(package: &Path, auth: &AltoolAuth) -> Result<UploadOutcome, String> {
run_altool(package, auth, AltoolOp::Validate, false)
}
fn run_altool(
package: &Path,
auth: &AltoolAuth,
op: AltoolOp,
wait: bool,
) -> Result<UploadOutcome, String> {
validate_package(package)?;
if !auth.private_key_path.is_file() {
return Err(format!(
"could not read the App Store Connect private key at {}",
auth.private_key_path.display()
));
}
let output = Command::new("xcrun")
.args(altool_args(package, auth, op, wait))
.stdin(Stdio::null())
.stderr(Stdio::inherit())
.output()
.map_err(|error| format!("running xcrun altool (is Xcode installed?): {error}"))?;
Ok(outcome(package, output.status.code(), &output.stdout))
}
fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome {
let output = parse_output(stdout);
let success = exit_code == Some(0) && !has_errors(&output);
UploadOutcome {
package: package.display().to_string(),
success,
exit_code,
delivery_id: find_delivery_id(&output),
output,
}
}
fn has_errors(value: &serde_json::Value) -> bool {
const ERROR_KEYS: [&str; 3] = ["product-errors", "product_errors", "errors"];
match value {
serde_json::Value::Object(map) => {
ERROR_KEYS.iter().any(|key| {
matches!(map.get(*key), Some(serde_json::Value::Array(errors)) if !errors.is_empty())
}) || map.values().any(has_errors)
}
serde_json::Value::Array(items) => items.iter().any(has_errors),
_ => false,
}
}
fn parse_output(stdout: &[u8]) -> serde_json::Value {
let documents: Result<Vec<serde_json::Value>, _> = serde_json::Deserializer::from_slice(stdout)
.into_iter()
.collect();
match documents {
Ok(mut documents) if documents.len() == 1 => documents.remove(0),
Ok(documents) if !documents.is_empty() => serde_json::Value::Array(documents),
_ => serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string()),
}
}
fn find_delivery_id(value: &serde_json::Value) -> Option<String> {
const KEYS: [&str; 4] = ["delivery-uuid", "deliveryUuid", "delivery-id", "deliveryId"];
match value {
serde_json::Value::Object(map) => KEYS
.iter()
.find_map(|key| map.get(*key).and_then(|v| v.as_str()).map(str::to_string))
.or_else(|| map.values().find_map(find_delivery_id)),
serde_json::Value::Array(items) => items.iter().find_map(find_delivery_id),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn auth() -> AltoolAuth {
AltoolAuth {
key_id: "ABC123".to_string(),
issuer_id: "57246542-96fe-1a63-e053-0824d011072a".to_string(),
private_key_path: PathBuf::from("/keys/AuthKey_ABC123.p8"),
}
}
fn strings(args: Vec<OsString>) -> Vec<String> {
args.into_iter()
.map(|arg| arg.into_string().unwrap())
.collect()
}
#[test]
fn passes_the_resolved_key_file_rather_than_letting_altool_search() {
let args = strings(altool_args(
Path::new("/out/Klepon.ipa"),
&auth(),
AltoolOp::Upload,
false,
));
assert_eq!(
args,
[
"altool",
"--upload-package",
"/out/Klepon.ipa",
"--api-key",
"ABC123",
"--api-issuer",
"57246542-96fe-1a63-e053-0824d011072a",
"--p8-file-path",
"/keys/AuthKey_ABC123.p8",
"--output-format",
"json",
]
);
}
#[test]
fn never_puts_key_material_on_the_command_line() {
let args = strings(altool_args(
Path::new("/out/Klepon.ipa"),
&auth(),
AltoolOp::Upload,
true,
));
assert!(!args.iter().any(|arg| arg == "--auth-string"));
assert_eq!(args.last().map(String::as_str), Some("--wait"));
}
#[test]
fn validation_uses_validate_app_and_ignores_wait() {
let args = strings(altool_args(
Path::new("/out/Klepon.ipa"),
&auth(),
AltoolOp::Validate,
true,
));
assert_eq!(args[1], "--validate-app");
assert!(
!args.iter().any(|arg| arg == "--wait"),
"--wait is upload-only: {args:?}"
);
}
#[test]
fn points_at_the_archive_when_given_an_app_bundle() {
let error = validate_package(Path::new("/out/Klepon.app")).unwrap_err();
assert!(error.contains(".app"), "unhelpful error: {error}");
assert!(error.contains("exportArchive"), "unhelpful error: {error}");
}
#[test]
fn rejects_an_unexported_archive() {
let error = validate_package(Path::new("/out/Klepon.xcarchive")).unwrap_err();
assert!(error.contains("exportArchive"), "unhelpful error: {error}");
}
#[test]
fn reads_a_delivery_id_from_nested_json() {
let body = br#"{"tool-version":"9.0","details":{"delivery-uuid":"d1e2f3"}}"#;
let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body);
assert!(outcome.success);
assert_eq!(outcome.delivery_id.as_deref(), Some("d1e2f3"));
}
#[test]
fn reads_both_documents_altool_prints_with_wait() {
let body = br#"{
"build-status" : "VALID",
"bundle-short-version-string" : "1.1.0",
"bundle-version" : "1791527508",
"delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c"
}
{
"details" : {
"delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c"
},
"success-message" : "No errors uploading archive"
}
"#;
let outcome = outcome(Path::new("/out/MailXVision.ipa"), Some(0), body);
assert_eq!(
outcome.delivery_id.as_deref(),
Some("3a12861d-5afd-4fc1-8b2d-96c80e055b1c")
);
let documents = outcome.output.as_array().expect("two documents");
assert_eq!(documents.len(), 2);
assert_eq!(documents[0]["build-status"], "VALID");
}
#[test]
fn reports_failure_when_altool_exits_zero_but_the_body_has_errors() {
let body = br#"{"product-errors":[{"message":"Invalid bundle"}]}"#;
let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body);
assert!(
!outcome.success,
"a non-empty product-errors array means failure even on exit 0"
);
}
#[test]
fn keeps_non_json_output_as_text_and_reports_failure() {
let outcome = outcome(
Path::new("/out/Klepon.ipa"),
Some(1),
b"ERROR: bad bundle\n",
);
assert!(!outcome.success);
assert_eq!(outcome.output, serde_json::json!("ERROR: bad bundle"));
assert_eq!(outcome.delivery_id, None);
}
}