main
rs 392 lines 14.2 KB
Raw
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 }