1use std::{
6 ffi::{OsStr, OsString},
7 path::{Path, PathBuf},
8 process::{Command, ExitStatus},
9};
10
11use serde::Deserialize;
12
13pub mod certificate;
14mod keychain;
15mod provisioning_profile;
16
17pub use keychain::{Keychain, Team};
18pub use provisioning_profile::ProvisioningProfile;
19
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22 #[error("failed to create temp directory: {0}")]
23 TempDir(std::io::Error),
24 #[error("failed to resolve home dir")]
25 ResolveHomeDir,
26 #[error("failed to resolve signing identity")]
27 ResolveSigningIdentity,
28 #[error("failed to decode provisioning profile")]
29 FailedToDecodeProvisioningProfile,
30 #[error("could not find provisioning profile UUID")]
31 FailedToFindProvisioningProfileUuid,
32 #[error("{context} {path}: {error}")]
33 Plist {
34 context: &'static str,
35 path: PathBuf,
36 error: plist::Error,
37 },
38 #[error("failed to upload app to Apple's notarization servers: {error}")]
39 FailedToUploadApp { error: std::io::Error },
40 #[error("failed to notarize app: {0}")]
41 Notarize(String),
42 #[error("failed to parse notarytool output as JSON: {output}")]
43 ParseNotarytoolOutput { output: String },
44 #[error("failed to run command {command}: {error}")]
45 CommandFailed {
46 command: String,
47 error: std::io::Error,
48 },
49 #[error("{context} {path}: {error}")]
50 Fs {
51 context: &'static str,
52 path: PathBuf,
53 error: std::io::Error,
54 },
55 #[error("failed to parse X509 certificate: {error}")]
56 X509Certificate {
57 error: x509_certificate::X509CertificateError,
58 },
59 #[error("failed to create PFX from self signed certificate")]
60 FailedToCreatePFX,
61 #[error("failed to create self signed certificate: {error}")]
62 FailedToCreateSelfSignedCertificate {
63 error: Box<apple_codesign::AppleCodesignError>,
64 },
65 #[error("failed to encode DER: {error}")]
66 FailedToEncodeDER { error: std::io::Error },
67 #[error("certificate missing common name")]
68 CertificateMissingCommonName,
69 #[error("certificate missing organization unit for common name {common_name}")]
70 CertificateMissingOrganizationUnit { common_name: String },
71}
72
73pub type Result<T> = std::result::Result<T, Error>;
74
75trait CommandExt {
76 fn piped(&mut self) -> std::io::Result<ExitStatus>;
79}
80
81impl CommandExt for Command {
82 fn piped(&mut self) -> std::io::Result<ExitStatus> {
83 self.stdin(os_pipe::dup_stdin()?);
84 self.stdout(os_pipe::dup_stdout()?);
85 self.stderr(os_pipe::dup_stderr()?);
86 let program = self.get_program().to_string_lossy().into_owned();
87 log::debug!(action = "Running"; "Command `{} {}`", program, self.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{acc} {arg}")));
88
89 self.status()
90 }
91}
92
93pub enum ApiKey {
94 Path(PathBuf),
95 Raw(Vec<u8>),
96}
97
98pub enum AppleNotarizationCredentials {
99 AppleId {
100 apple_id: OsString,
101 password: OsString,
102 team_id: OsString,
103 },
104 ApiKey {
105 issuer: OsString,
106 key_id: OsString,
107 key: ApiKey,
108 },
109}
110
111#[derive(Deserialize)]
112struct NotarytoolSubmitOutput {
113 id: String,
114 #[serde(default)]
115 status: Option<String>,
116 message: String,
117}
118
119pub fn notarize(
120 keychain: &Keychain,
121 app_bundle_path: &Path,
122 auth: &AppleNotarizationCredentials,
123) -> Result<()> {
124 notarize_inner(keychain, app_bundle_path, auth, true)
125}
126
127pub fn notarize_without_stapling(
128 keychain: &Keychain,
129 app_bundle_path: &Path,
130 auth: &AppleNotarizationCredentials,
131) -> Result<()> {
132 notarize_inner(keychain, app_bundle_path, auth, false)
133}
134
135fn notarize_inner(
136 keychain: &Keychain,
137 app_bundle_path: &Path,
138 auth: &AppleNotarizationCredentials,
139 wait: bool,
140) -> Result<()> {
141 let bundle_stem = app_bundle_path
142 .file_stem()
143 .expect("failed to get bundle filename");
144
145 let tmp_dir = tempfile::tempdir().map_err(Error::TempDir)?;
146 let zip_path = tmp_dir
147 .path()
148 .join(format!("{}.zip", bundle_stem.to_string_lossy()));
149 let zip_args = vec![
150 "-c",
151 "-k",
152 "--keepParent",
153 "--sequesterRsrc",
154 app_bundle_path
155 .to_str()
156 .expect("failed to convert bundle_path to string"),
157 zip_path
158 .to_str()
159 .expect("failed to convert zip_path to string"),
160 ];
161
162 assert_command(
165 Command::new("ditto").args(zip_args).piped(),
166 "failed to zip app with ditto",
167 )
168 .map_err(|error| Error::CommandFailed {
169 command: "ditto".to_string(),
170 error,
171 })?;
172
173 keychain.sign(&zip_path, None, false)?;
175
176 let mut notarize_args = vec![
177 "notarytool",
178 "submit",
179 zip_path
180 .to_str()
181 .expect("failed to convert zip_path to string"),
182 "--output-format",
183 "json",
184 ];
185 if wait {
186 notarize_args.push("--wait");
187 }
188 let notarize_args = notarize_args;
189
190 println!("Notarizing {}", app_bundle_path.display());
191
192 let output = Command::new("xcrun")
193 .args(notarize_args)
194 .notarytool_args(auth, tmp_dir.path())?
195 .output()
196 .map_err(|error| Error::FailedToUploadApp { error })?;
197
198 if !output.status.success() {
199 return Err(Error::Notarize(
200 String::from_utf8_lossy(&output.stderr).into_owned(),
201 ));
202 }
203
204 let output_str = String::from_utf8_lossy(&output.stdout);
205 if let Ok(submit_output) = serde_json::from_str::<NotarytoolSubmitOutput>(&output_str) {
206 let log_message = format!(
207 "{} with status {} for id {} ({})",
208 if wait { "Finished" } else { "Submitted" },
209 submit_output.status.as_deref().unwrap_or("Pending"),
210 submit_output.id,
211 submit_output.message
212 );
213 if submit_output.status.map_or(!wait, |s| s == "Accepted") {
215 println!("Notarizing {log_message}");
216
217 if wait {
218 println!("Stapling app...");
219 staple_app(app_bundle_path.to_path_buf())?;
220 } else {
221 println!("Not waiting for notarization to finish.");
222 println!("You can use `xcrun notarytool log` to check the notarization progress.");
223 println!(
224 "When it's done you can optionally staple your app via `xcrun stapler staple {}`",
225 app_bundle_path.display()
226 );
227 }
228
229 Ok(())
230 } else if let Ok(output) = Command::new("xcrun")
231 .args(["notarytool", "log"])
232 .arg(&submit_output.id)
233 .notarytool_args(auth, tmp_dir.path())?
234 .output()
235 {
236 Err(Error::Notarize(format!(
237 "{log_message}\nLog:\n{}",
238 String::from_utf8_lossy(&output.stdout)
239 )))
240 } else {
241 Err(Error::Notarize(log_message))
242 }
243 } else {
244 Err(Error::ParseNotarytoolOutput {
245 output: output_str.into_owned(),
246 })
247 }
248}
249
250fn staple_app(mut app_bundle_path: PathBuf) -> Result<()> {
251 let app_bundle_path_clone = app_bundle_path.clone();
252 let filename = app_bundle_path_clone
253 .file_name()
254 .expect("failed to get bundle filename")
255 .to_str()
256 .expect("failed to convert bundle filename to string");
257
258 app_bundle_path.pop();
259
260 Command::new("xcrun")
261 .args(vec!["stapler", "staple", "-v", filename])
262 .current_dir(app_bundle_path)
263 .output()
264 .map_err(|error| Error::CommandFailed {
265 command: "xcrun stapler staple".to_string(),
266 error,
267 })?;
268
269 Ok(())
270}
271
272pub trait NotarytoolCmdExt {
273 fn notarytool_args(
274 &mut self,
275 auth: &AppleNotarizationCredentials,
276 temp_dir: &Path,
277 ) -> Result<&mut Self>;
278}
279
280impl NotarytoolCmdExt for Command {
281 fn notarytool_args(
282 &mut self,
283 auth: &AppleNotarizationCredentials,
284 temp_dir: &Path,
285 ) -> Result<&mut Self> {
286 match auth {
287 AppleNotarizationCredentials::AppleId {
288 apple_id,
289 password,
290 team_id,
291 } => Ok(
292 self
293 .arg("--apple-id")
294 .arg(apple_id)
295 .arg("--password")
296 .arg(password)
297 .arg("--team-id")
298 .arg(team_id),
299 ),
300 AppleNotarizationCredentials::ApiKey {
301 key,
302 key_id,
303 issuer,
304 } => {
305 let key_path = match key {
306 ApiKey::Raw(k) => {
307 let key_path = temp_dir.join("AuthKey.p8");
308 std::fs::write(&key_path, k).map_err(|error| Error::Fs {
309 context: "failed to write notarization API key to temp file",
310 path: key_path.clone(),
311 error,
312 })?;
313 key_path
314 }
315 ApiKey::Path(p) => p.to_owned(),
316 };
317
318 Ok(
319 self
320 .arg("--key-id")
321 .arg(key_id)
322 .arg("--key")
323 .arg(key_path)
324 .arg("--issuer")
325 .arg(issuer),
326 )
327 }
328 }
329 }
330}
331
332fn decode_base64(base64: &OsStr, out_path: &Path) -> Result<()> {
333 let tmp_dir = tempfile::tempdir().map_err(Error::TempDir)?;
334
335 let src_path = tmp_dir.path().join("src");
336 let base64 = base64
337 .to_str()
338 .expect("failed to convert base64 to string")
339 .as_bytes();
340
341 std::fs::write(&src_path, base64).map_err(|error| Error::Fs {
345 context: "failed to write base64 to temp file",
346 path: src_path.clone(),
347 error,
348 })?;
349
350 assert_command(
351 std::process::Command::new("base64")
352 .arg("--decode")
353 .arg("-i")
354 .arg(&src_path)
355 .arg("-o")
356 .arg(out_path)
357 .piped(),
358 "failed to decode certificate",
359 )
360 .map_err(|error| Error::CommandFailed {
361 command: "base64 --decode".to_string(),
362 error,
363 })?;
364
365 Ok(())
366}
367
368fn assert_command(
369 response: std::result::Result<std::process::ExitStatus, std::io::Error>,
370 error_message: &str,
371) -> std::io::Result<()> {
372 let status =
373 response.map_err(|e| std::io::Error::new(e.kind(), format!("{error_message}: {e}")))?;
374 if !status.success() {
375 Err(std::io::Error::other(error_message))
376 } else {
377 Ok(())
378 }
379}