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. `altool --build-status --delivery-id` takes it.
41
+ pub delivery_id: Option<String>,
42
+ /// altool's `--output-format json` body when it parsed, otherwise its
43
+ /// stdout as a string.
44
+ pub output: serde_json::Value,
45
+}
46
+
47
+/// The arguments after `xcrun`. Separate from [`upload_package`] so the
48
+/// exact command line is testable and `--dry-run` can print it.
49
+pub fn altool_args(package: &Path, auth: &AltoolAuth, wait: bool) -> Vec<OsString> {
50
+ let mut args: Vec<OsString> = vec![
51
+ "altool".into(),
52
+ "--upload-package".into(),
53
+ package.as_os_str().to_owned(),
54
+ "--api-key".into(),
55
+ auth.key_id.clone().into(),
56
+ "--api-issuer".into(),
57
+ auth.issuer_id.clone().into(),
58
+ "--p8-file-path".into(),
59
+ auth.private_key_path.as_os_str().to_owned(),
60
+ "--output-format".into(),
61
+ "json".into(),
62
+ ];
63
+ if wait {
64
+ args.push("--wait".into());
65
+ }
66
+ args
67
+}
68
+
69
+/// Check the package before spending minutes in altool on it.
70
+pub fn validate_package(package: &Path) -> Result<(), String> {
71
+ let extension = package
72
+ .extension()
73
+ .and_then(|extension| extension.to_str())
74
+ .map(str::to_ascii_lowercase);
75
+ match extension.as_deref() {
76
+ Some("ipa") | Some("pkg") => {}
77
+ _ => {
78
+ return Err(format!(
79
+ "{} is not an .ipa or .pkg; export the archive first \
80
+ (xcodebuild -exportArchive)",
81
+ package.display()
82
+ ))
83
+ }
84
+ }
85
+ if !package.is_file() {
86
+ return Err(format!("{} does not exist", package.display()));
87
+ }
88
+ Ok(())
89
+}
90
+
91
+/// Run `xcrun altool --upload-package` and report what happened.
92
+///
93
+/// Blocks for the length of the upload (and processing, with `wait`), so
94
+/// async callers should run it on a blocking thread. altool's stderr is
95
+/// passed through so its progress stays visible; stdout is captured for
96
+/// the JSON result.
97
+///
98
+/// A failed upload is an `Ok` with `success: false`, not an `Err`: altool's
99
+/// own error body is the useful part and the caller should see it. `Err`
100
+/// means altool could not be run at all.
101
+pub fn upload_package(
102
+ package: &Path,
103
+ auth: &AltoolAuth,
104
+ wait: bool,
105
+) -> Result<UploadOutcome, String> {
106
+ validate_package(package)?;
107
+ if !auth.private_key_path.is_file() {
108
+ return Err(format!(
109
+ "could not read the App Store Connect private key at {}",
110
+ auth.private_key_path.display()
111
+ ));
112
+ }
113
+
114
+ let output = Command::new("xcrun")
115
+ .args(altool_args(package, auth, wait))
116
+ .stdin(Stdio::null())
117
+ .stderr(Stdio::inherit())
118
+ .output()
119
+ .map_err(|error| format!("running xcrun altool (is Xcode installed?): {error}"))?;
120
+
121
+ Ok(outcome(package, output.status.code(), &output.stdout))
122
+}
123
+
124
+fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome {
125
+ let output = serde_json::from_slice::<serde_json::Value>(stdout).unwrap_or_else(|_| {
126
+ serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string())
127
+ });
128
+ UploadOutcome {
129
+ package: package.display().to_string(),
130
+ success: exit_code == Some(0),
131
+ exit_code,
132
+ delivery_id: find_delivery_id(&output),
133
+ output,
134
+ }
135
+}
136
+
137
+/// altool has spelled this key differently across releases
138
+/// (`delivery-uuid` in the legacy tool), so search the body for any of
139
+/// the spellings rather than pinning one path.
140
+fn find_delivery_id(value: &serde_json::Value) -> Option<String> {
141
+ const KEYS: [&str; 4] = ["delivery-uuid", "deliveryUuid", "delivery-id", "deliveryId"];
142
+ match value {
143
+ serde_json::Value::Object(map) => KEYS
144
+ .iter()
145
+ .find_map(|key| map.get(*key).and_then(|v| v.as_str()).map(str::to_string))
146
+ .or_else(|| map.values().find_map(find_delivery_id)),
147
+ serde_json::Value::Array(items) => items.iter().find_map(find_delivery_id),
148
+ _ => None,
149
+ }
150
+}
151
+
152
+#[cfg(test)]
153
+mod tests {
154
+ use super::*;
155
+
156
+ fn auth() -> AltoolAuth {
157
+ AltoolAuth {
158
+ key_id: "ABC123".to_string(),
159
+ issuer_id: "57246542-96fe-1a63-e053-0824d011072a".to_string(),
160
+ private_key_path: PathBuf::from("/keys/AuthKey_ABC123.p8"),
161
+ }
162
+ }
163
+
164
+ fn strings(args: Vec<OsString>) -> Vec<String> {
165
+ args.into_iter()
166
+ .map(|arg| arg.into_string().unwrap())
167
+ .collect()
168
+ }
169
+
170
+ #[test]
171
+ fn passes_the_resolved_key_file_rather_than_letting_altool_search() {
172
+ let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), false));
173
+ assert_eq!(
174
+ args,
175
+ [
176
+ "altool",
177
+ "--upload-package",
178
+ "/out/Klepon.ipa",
179
+ "--api-key",
180
+ "ABC123",
181
+ "--api-issuer",
182
+ "57246542-96fe-1a63-e053-0824d011072a",
183
+ "--p8-file-path",
184
+ "/keys/AuthKey_ABC123.p8",
185
+ "--output-format",
186
+ "json",
187
+ ]
188
+ );
189
+ }
190
+
191
+ #[test]
192
+ fn never_puts_key_material_on_the_command_line() {
193
+ let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), true));
194
+ assert!(!args.iter().any(|arg| arg == "--auth-string"));
195
+ assert_eq!(args.last().map(String::as_str), Some("--wait"));
196
+ }
197
+
198
+ #[test]
199
+ fn rejects_an_unexported_archive() {
200
+ let error = validate_package(Path::new("/out/Klepon.xcarchive")).unwrap_err();
201
+ assert!(error.contains("exportArchive"), "unhelpful error: {error}");
202
+ }
203
+
204
+ #[test]
205
+ fn reads_a_delivery_id_from_nested_json() {
206
+ let body = br#"{"tool-version":"9.0","details":{"delivery-uuid":"d1e2f3"}}"#;
207
+ let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body);
208
+ assert!(outcome.success);
209
+ assert_eq!(outcome.delivery_id.as_deref(), Some("d1e2f3"));
210
+ }
211
+
212
+ #[test]
213
+ fn keeps_non_json_output_as_text_and_reports_failure() {
214
+ let outcome = outcome(
215
+ Path::new("/out/Klepon.ipa"),
216
+ Some(1),
217
+ b"ERROR: bad bundle\n",
218
+ );
219
+ assert!(!outcome.success);
220
+ assert_eq!(outcome.output, serde_json::json!("ERROR: bad bundle"));
221
+ assert_eq!(outcome.delivery_id, None);
222
+ }
223
+}