smbcloud_ascapi_frontend/
upload.rs1use serde::Serialize;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22#[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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
34pub struct UploadOutcome {
35 pub package: String,
36 pub success: bool,
37 pub exit_code: Option<i32>,
39 pub delivery_id: Option<String>,
43 pub output: serde_json::Value,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AltoolOp {
54 Validate,
57 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
70pub 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 if wait && op == AltoolOp::Upload {
89 args.push("--wait".into());
90 }
91 args
92}
93
94pub 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 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 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
134pub 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
152pub fn validate_app(package: &Path, auth: &AltoolAuth) -> Result<UploadOutcome, String> {
160 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 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
204fn 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
221fn 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
236fn 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 #[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}