Skip to main content

smbcloud_ascapi_frontend/
upload.rs

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
17use serde::Serialize;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20use 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)]
26pub 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)]
34pub 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)]
53pub 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
61impl 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.
72pub 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.
95pub 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.
144pub 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`].
159pub 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
164fn 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
188fn 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.
208fn 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.
225fn 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.
239fn 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)]
252mod 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}