| 1 | //! Uploading an `.ipa`/`.pkg` to App Store Connect through `xcrun altool`. |
| 2 | //! |
| 3 | //! The App Store Connect REST API has no binary-upload endpoint a client |
| 4 | //! can drive end to end, so this shells out to Apple's own uploader. What |
| 5 | //! this module adds over calling altool by hand is authentication parity: |
| 6 | //! altool is handed the exact `.p8` the rest of the CLI resolved, via |
| 7 | //! `--p8-file-path`, instead of searching `~/.appstoreconnect/private_keys` |
| 8 | //! and friends for an `AuthKey_<id>.p8`. A search can find a stale key with |
| 9 | //! the same id that the API then rejects with a 401, which is |
| 10 | //! indistinguishable at the call site from a revoked key. |
| 11 | //! |
| 12 | //! Once the upload succeeds, look the build up with |
| 13 | //! [`BuildsApi::list_builds`](smbcloud_ascapi_aso::build::BuildsApi::list_builds) |
| 14 | //! filtered by build number, or pass `wait` to have altool block until App |
| 15 | //! Store Connect finishes processing. |
| 16 | |
| 17 | use serde::Serialize; |
| 18 | use std::ffi::OsString; |
| 19 | use std::path::{Path, PathBuf}; |
| 20 | use std::process::{Command, Stdio}; |
| 21 | |
| 22 | /// The API key altool authenticates with. Paths and ids only: altool's |
| 23 | /// `--auth-string` would take the key itself, which would then sit in the |
| 24 | /// process table for any local user to read, so it is never used. |
| 25 | #[derive(Debug, Clone)] |
| 26 | pub struct AltoolAuth { |
| 27 | pub key_id: String, |
| 28 | pub issuer_id: String, |
| 29 | pub private_key_path: PathBuf, |
| 30 | } |
| 31 | |
| 32 | /// The result of one altool upload, as reported to callers. |
| 33 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 34 | pub struct UploadOutcome { |
| 35 | pub package: String, |
| 36 | pub success: bool, |
| 37 | /// altool's exit status; `None` if it was killed by a signal. |
| 38 | pub exit_code: Option<i32>, |
| 39 | /// The delivery UUID altool reports for a successful upload, if its |
| 40 | /// output carried one. It is also the build's App Store Connect id, so |
| 41 | /// `asc apps build <delivery_id>` reads the build back. |
| 42 | pub delivery_id: Option<String>, |
| 43 | /// altool's `--output-format json` body when it parsed (an array of |
| 44 | /// both documents with `--wait`), otherwise its stdout as a string. |
| 45 | pub output: serde_json::Value, |
| 46 | } |
| 47 | |
| 48 | /// Which altool operation to run against the package: the pre-flight |
| 49 | /// `--validate-app` or the real `--upload-package`. They take the same |
| 50 | /// authentication and the same output format, so they share one argv |
| 51 | /// builder. |
| 52 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 53 | pub enum AltoolOp { |
| 54 | /// `--validate-app`: run App Store Connect's checks without delivering |
| 55 | /// the binary. Catches most of what a failed upload would, in seconds. |
| 56 | Validate, |
| 57 | /// `--upload-package`: deliver the binary for real. |
| 58 | Upload, |
| 59 | } |
| 60 | |
| 61 | impl AltoolOp { |
| 62 | fn flag(self) -> &'static str { |
| 63 | match self { |
| 64 | AltoolOp::Validate => "--validate-app", |
| 65 | AltoolOp::Upload => "--upload-package", |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// The arguments after `xcrun`. Separate from [`upload_package`] so the |
| 71 | /// exact command line is testable and `--dry-run` can print it. |
| 72 | pub fn altool_args(package: &Path, auth: &AltoolAuth, op: AltoolOp, wait: bool) -> Vec<OsString> { |
| 73 | let mut args: Vec<OsString> = vec![ |
| 74 | "altool".into(), |
| 75 | op.flag().into(), |
| 76 | package.as_os_str().to_owned(), |
| 77 | "--api-key".into(), |
| 78 | auth.key_id.clone().into(), |
| 79 | "--api-issuer".into(), |
| 80 | auth.issuer_id.clone().into(), |
| 81 | "--p8-file-path".into(), |
| 82 | auth.private_key_path.as_os_str().to_owned(), |
| 83 | "--output-format".into(), |
| 84 | "json".into(), |
| 85 | ]; |
| 86 | // --wait only means anything for an upload; validation is already |
| 87 | // synchronous, so don't pass a flag altool would reject there. |
| 88 | if wait && op == AltoolOp::Upload { |
| 89 | args.push("--wait".into()); |
| 90 | } |
| 91 | args |
| 92 | } |
| 93 | |
| 94 | /// Check the package before spending minutes in altool on it. |
| 95 | pub fn validate_package(package: &Path) -> Result<(), String> { |
| 96 | let extension = package |
| 97 | .extension() |
| 98 | .and_then(|extension| extension.to_str()) |
| 99 | .map(str::to_ascii_lowercase); |
| 100 | match extension.as_deref() { |
| 101 | Some("ipa") | Some("pkg") => {} |
| 102 | // A `.app` is a directory, so the generic "not a file" check below |
| 103 | // would mislabel the most common mistake — pointing altool at the |
| 104 | // built app instead of the exported archive. Name it precisely. |
| 105 | Some("app") => { |
| 106 | return Err(format!( |
| 107 | "{} is an .app bundle, not an uploadable package; export the \ |
| 108 | archive first (xcodebuild -exportArchive) to get an .ipa/.pkg", |
| 109 | package.display() |
| 110 | )) |
| 111 | } |
| 112 | _ => { |
| 113 | return Err(format!( |
| 114 | "{} is not an .ipa or .pkg; export the archive first \ |
| 115 | (xcodebuild -exportArchive)", |
| 116 | package.display() |
| 117 | )) |
| 118 | } |
| 119 | } |
| 120 | // Distinguish "nothing here" from "here, but a directory" so the error |
| 121 | // points at the real problem instead of a generic "does not exist". |
| 122 | if !package.exists() { |
| 123 | return Err(format!("{} does not exist", package.display())); |
| 124 | } |
| 125 | if !package.is_file() { |
| 126 | return Err(format!( |
| 127 | "{} is a directory, not a package file", |
| 128 | package.display() |
| 129 | )); |
| 130 | } |
| 131 | Ok(()) |
| 132 | } |
| 133 | |
| 134 | /// Run `xcrun altool --upload-package` and report what happened. |
| 135 | /// |
| 136 | /// Blocks for the length of the upload (and processing, with `wait`), so |
| 137 | /// async callers should run it on a blocking thread. altool's stderr is |
| 138 | /// passed through so its progress stays visible; stdout is captured for |
| 139 | /// the JSON result. |
| 140 | /// |
| 141 | /// A failed upload is an `Ok` with `success: false`, not an `Err`: altool's |
| 142 | /// own error body is the useful part and the caller should see it. `Err` |
| 143 | /// means altool could not be run at all. |
| 144 | pub fn upload_package( |
| 145 | package: &Path, |
| 146 | auth: &AltoolAuth, |
| 147 | wait: bool, |
| 148 | ) -> Result<UploadOutcome, String> { |
| 149 | run_altool(package, auth, AltoolOp::Upload, wait) |
| 150 | } |
| 151 | |
| 152 | /// Run `xcrun altool --validate-app` and report what happened. |
| 153 | /// |
| 154 | /// A pre-flight check that runs App Store Connect's validation without |
| 155 | /// delivering the binary: it catches most of what a failed upload would |
| 156 | /// (bad entitlements, missing icons, bundle-id mismatches) in seconds |
| 157 | /// instead of minutes, before anything is uploaded. Same success/`Err` |
| 158 | /// contract as [`upload_package`]. |
| 159 | pub fn validate_app(package: &Path, auth: &AltoolAuth) -> Result<UploadOutcome, String> { |
| 160 | // --wait is upload-only; validation is already synchronous. |
| 161 | run_altool(package, auth, AltoolOp::Validate, false) |
| 162 | } |
| 163 | |
| 164 | fn run_altool( |
| 165 | package: &Path, |
| 166 | auth: &AltoolAuth, |
| 167 | op: AltoolOp, |
| 168 | wait: bool, |
| 169 | ) -> Result<UploadOutcome, String> { |
| 170 | validate_package(package)?; |
| 171 | if !auth.private_key_path.is_file() { |
| 172 | return Err(format!( |
| 173 | "could not read the App Store Connect private key at {}", |
| 174 | auth.private_key_path.display() |
| 175 | )); |
| 176 | } |
| 177 | |
| 178 | let output = Command::new("xcrun") |
| 179 | .args(altool_args(package, auth, op, wait)) |
| 180 | .stdin(Stdio::null()) |
| 181 | .stderr(Stdio::inherit()) |
| 182 | .output() |
| 183 | .map_err(|error| format!("running xcrun altool (is Xcode installed?): {error}"))?; |
| 184 | |
| 185 | Ok(outcome(package, output.status.code(), &output.stdout)) |
| 186 | } |
| 187 | |
| 188 | fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome { |
| 189 | let output = parse_output(stdout); |
| 190 | // A zero exit is necessary but not sufficient: altool has been seen to |
| 191 | // exit 0 while reporting errors in its JSON body (e.g. the |
| 192 | // `product-errors` array). Trust the exit code only when the body |
| 193 | // doesn't contradict it. |
| 194 | let success = exit_code == Some(0) && !has_errors(&output); |
| 195 | UploadOutcome { |
| 196 | package: package.display().to_string(), |
| 197 | success, |
| 198 | exit_code, |
| 199 | delivery_id: find_delivery_id(&output), |
| 200 | output, |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | /// altool reports failures in its JSON body under a handful of keys |
| 205 | /// (`product-errors` / `product_errors` for upload, `errors` for |
| 206 | /// validation). A non-empty array under any of them means the operation |
| 207 | /// failed regardless of the exit code. |
| 208 | fn has_errors(value: &serde_json::Value) -> bool { |
| 209 | const ERROR_KEYS: [&str; 3] = ["product-errors", "product_errors", "errors"]; |
| 210 | match value { |
| 211 | serde_json::Value::Object(map) => { |
| 212 | ERROR_KEYS.iter().any(|key| { |
| 213 | matches!(map.get(*key), Some(serde_json::Value::Array(errors)) if !errors.is_empty()) |
| 214 | }) || map.values().any(has_errors) |
| 215 | } |
| 216 | serde_json::Value::Array(items) => items.iter().any(has_errors), |
| 217 | _ => false, |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /// With `--wait`, altool writes two JSON documents back to back: the build's |
| 222 | /// processing status, then the upload result. Read them as a stream and |
| 223 | /// return an array when there is more than one. Anything that isn't JSON |
| 224 | /// is kept as text. |
| 225 | fn parse_output(stdout: &[u8]) -> serde_json::Value { |
| 226 | let documents: Result<Vec<serde_json::Value>, _> = serde_json::Deserializer::from_slice(stdout) |
| 227 | .into_iter() |
| 228 | .collect(); |
| 229 | match documents { |
| 230 | Ok(mut documents) if documents.len() == 1 => documents.remove(0), |
| 231 | Ok(documents) if !documents.is_empty() => serde_json::Value::Array(documents), |
| 232 | _ => serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string()), |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// altool has spelled this key differently across releases |
| 237 | /// (`delivery-uuid` in the legacy tool), so search the body for any of |
| 238 | /// the spellings rather than pinning one path. |
| 239 | fn find_delivery_id(value: &serde_json::Value) -> Option<String> { |
| 240 | const KEYS: [&str; 4] = ["delivery-uuid", "deliveryUuid", "delivery-id", "deliveryId"]; |
| 241 | match value { |
| 242 | serde_json::Value::Object(map) => KEYS |
| 243 | .iter() |
| 244 | .find_map(|key| map.get(*key).and_then(|v| v.as_str()).map(str::to_string)) |
| 245 | .or_else(|| map.values().find_map(find_delivery_id)), |
| 246 | serde_json::Value::Array(items) => items.iter().find_map(find_delivery_id), |
| 247 | _ => None, |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | #[cfg(test)] |
| 252 | mod tests { |
| 253 | use super::*; |
| 254 | |
| 255 | fn auth() -> AltoolAuth { |
| 256 | AltoolAuth { |
| 257 | key_id: "ABC123".to_string(), |
| 258 | issuer_id: "57246542-96fe-1a63-e053-0824d011072a".to_string(), |
| 259 | private_key_path: PathBuf::from("/keys/AuthKey_ABC123.p8"), |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | fn strings(args: Vec<OsString>) -> Vec<String> { |
| 264 | args.into_iter() |
| 265 | .map(|arg| arg.into_string().unwrap()) |
| 266 | .collect() |
| 267 | } |
| 268 | |
| 269 | #[test] |
| 270 | fn passes_the_resolved_key_file_rather_than_letting_altool_search() { |
| 271 | let args = strings(altool_args( |
| 272 | Path::new("/out/Klepon.ipa"), |
| 273 | &auth(), |
| 274 | AltoolOp::Upload, |
| 275 | false, |
| 276 | )); |
| 277 | assert_eq!( |
| 278 | args, |
| 279 | [ |
| 280 | "altool", |
| 281 | "--upload-package", |
| 282 | "/out/Klepon.ipa", |
| 283 | "--api-key", |
| 284 | "ABC123", |
| 285 | "--api-issuer", |
| 286 | "57246542-96fe-1a63-e053-0824d011072a", |
| 287 | "--p8-file-path", |
| 288 | "/keys/AuthKey_ABC123.p8", |
| 289 | "--output-format", |
| 290 | "json", |
| 291 | ] |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | #[test] |
| 296 | fn never_puts_key_material_on_the_command_line() { |
| 297 | let args = strings(altool_args( |
| 298 | Path::new("/out/Klepon.ipa"), |
| 299 | &auth(), |
| 300 | AltoolOp::Upload, |
| 301 | true, |
| 302 | )); |
| 303 | assert!(!args.iter().any(|arg| arg == "--auth-string")); |
| 304 | assert_eq!(args.last().map(String::as_str), Some("--wait")); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn validation_uses_validate_app_and_ignores_wait() { |
| 309 | let args = strings(altool_args( |
| 310 | Path::new("/out/Klepon.ipa"), |
| 311 | &auth(), |
| 312 | AltoolOp::Validate, |
| 313 | true, |
| 314 | )); |
| 315 | assert_eq!(args[1], "--validate-app"); |
| 316 | assert!( |
| 317 | !args.iter().any(|arg| arg == "--wait"), |
| 318 | "--wait is upload-only: {args:?}" |
| 319 | ); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn points_at_the_archive_when_given_an_app_bundle() { |
| 324 | let error = validate_package(Path::new("/out/Klepon.app")).unwrap_err(); |
| 325 | assert!(error.contains(".app"), "unhelpful error: {error}"); |
| 326 | assert!(error.contains("exportArchive"), "unhelpful error: {error}"); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn rejects_an_unexported_archive() { |
| 331 | let error = validate_package(Path::new("/out/Klepon.xcarchive")).unwrap_err(); |
| 332 | assert!(error.contains("exportArchive"), "unhelpful error: {error}"); |
| 333 | } |
| 334 | |
| 335 | #[test] |
| 336 | fn reads_a_delivery_id_from_nested_json() { |
| 337 | let body = br#"{"tool-version":"9.0","details":{"delivery-uuid":"d1e2f3"}}"#; |
| 338 | let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body); |
| 339 | assert!(outcome.success); |
| 340 | assert_eq!(outcome.delivery_id.as_deref(), Some("d1e2f3")); |
| 341 | } |
| 342 | |
| 343 | /// The shape Xcode 27's altool prints with `--wait`, trimmed from a real |
| 344 | /// smbcloud-mailx visionOS upload: a status document, then the upload |
| 345 | /// document, with no separator between them. |
| 346 | #[test] |
| 347 | fn reads_both_documents_altool_prints_with_wait() { |
| 348 | let body = br#"{ |
| 349 | "build-status" : "VALID", |
| 350 | "bundle-short-version-string" : "1.1.0", |
| 351 | "bundle-version" : "1791527508", |
| 352 | "delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c" |
| 353 | } |
| 354 | { |
| 355 | "details" : { |
| 356 | "delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c" |
| 357 | }, |
| 358 | "success-message" : "No errors uploading archive" |
| 359 | } |
| 360 | "#; |
| 361 | let outcome = outcome(Path::new("/out/MailXVision.ipa"), Some(0), body); |
| 362 | assert_eq!( |
| 363 | outcome.delivery_id.as_deref(), |
| 364 | Some("3a12861d-5afd-4fc1-8b2d-96c80e055b1c") |
| 365 | ); |
| 366 | let documents = outcome.output.as_array().expect("two documents"); |
| 367 | assert_eq!(documents.len(), 2); |
| 368 | assert_eq!(documents[0]["build-status"], "VALID"); |
| 369 | } |
| 370 | |
| 371 | #[test] |
| 372 | fn reports_failure_when_altool_exits_zero_but_the_body_has_errors() { |
| 373 | let body = br#"{"product-errors":[{"message":"Invalid bundle"}]}"#; |
| 374 | let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body); |
| 375 | assert!( |
| 376 | !outcome.success, |
| 377 | "a non-empty product-errors array means failure even on exit 0" |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn keeps_non_json_output_as_text_and_reports_failure() { |
| 383 | let outcome = outcome( |
| 384 | Path::new("/out/Klepon.ipa"), |
| 385 | Some(1), |
| 386 | b"ERROR: bad bundle\n", |
| 387 | ); |
| 388 | assert!(!outcome.success); |
| 389 | assert_eq!(outcome.output, serde_json::json!("ERROR: bad bundle")); |
| 390 | assert_eq!(outcome.delivery_id, None); |
| 391 | } |
| 392 | } |