Skip to main content

native_code_sign/
macos.rs

1//! macOS code signing using Apple's `codesign` tool.
2//!
3//! Environment variables:
4//! - `CODESIGN_IDENTITY`: signing identity (e.g. "Developer ID Application: ...")
5//! - `CODESIGN_CERTIFICATE`: base64-encoded `.p12` certificate
6//! - `CODESIGN_CERTIFICATE_PASSWORD`: password for the `.p12`
7//! - `CODESIGN_OPTIONS`: (optional) extra `--options` value (e.g. `"runtime"`)
8//! - `CODESIGN_ALLOW_UNTRUSTED`: (optional) set to `1` or `true` to allow
9//!   self-signed certificates that are not in the system trust store.
10//!
11//! Supports two modes:
12//! 1. **Identity signing**: if `CODESIGN_IDENTITY`, `CODESIGN_CERTIFICATE`, and
13//!    `CODESIGN_CERTIFICATE_PASSWORD` are all set, creates an ephemeral keychain,
14//!    imports the certificate, and signs with the named identity.
15//! 2. **Ad-hoc signing**: if no identity certificate config is provided, uses
16//!    `codesign --force --sign -` (local development).
17
18use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::{fmt, io};
21
22use base64::Engine;
23use thiserror::Error;
24use zeroize::Zeroize;
25
26use crate::secret::Secret;
27
28const CODESIGN_BIN: &str = "codesign";
29const SECURITY_BIN: &str = "security";
30
31#[derive(Debug, Error)]
32pub enum CodesignError {
33    #[error("codesign failed for `{}`: {source}", path.display())]
34    Sign {
35        path: PathBuf,
36        #[source]
37        source: crate::CommandError,
38    },
39    #[error("failed to create ephemeral keychain: {source}")]
40    KeychainSetup {
41        step: KeychainStep,
42        #[source]
43        source: KeychainSetupError,
44    },
45    #[error(
46        "signing identity `{identity}` not found in keychain after certificate import\n\
47         available identities:\n{}",
48        format_available_identities(available)
49    )]
50    IdentityNotFound {
51        identity: String,
52        available: Vec<String>,
53    },
54}
55
56fn format_available_identities(identities: &[String]) -> String {
57    if identities.is_empty() {
58        return "  (none)".to_string();
59    }
60    identities
61        .iter()
62        .map(|id| format!("  - {id}"))
63        .collect::<Vec<_>>()
64        .join("\n")
65}
66
67/// The step during ephemeral keychain setup that failed.
68#[derive(Debug, Clone, Copy)]
69pub enum KeychainStep {
70    AcquireLock,
71    CreateTempdir,
72    CreateKeychain,
73    SetSettings,
74    Unlock,
75    SetSearchList,
76    GetSearchList,
77    WriteCertificate,
78    ImportCertificate,
79    SetPartitionList,
80    GeneratePassword,
81    VerifyIdentity,
82}
83
84impl fmt::Display for KeychainStep {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::AcquireLock => write!(f, "acquire keychain lock"),
88            Self::CreateTempdir => write!(f, "create tempdir"),
89            Self::CreateKeychain => write!(f, "create keychain"),
90            Self::SetSettings => write!(f, "set keychain settings"),
91            Self::Unlock => write!(f, "unlock keychain"),
92            Self::SetSearchList => write!(f, "set keychain search list"),
93            Self::GetSearchList => write!(f, "get keychain search list"),
94            Self::WriteCertificate => write!(f, "write certificate"),
95            Self::ImportCertificate => write!(f, "import certificate"),
96            Self::SetPartitionList => write!(f, "set key partition list"),
97            Self::GeneratePassword => write!(f, "generate password"),
98            Self::VerifyIdentity => write!(f, "verify signing identity"),
99        }
100    }
101}
102
103/// The underlying error during a keychain setup step.
104#[derive(Debug, Error)]
105pub enum KeychainSetupError {
106    #[error("{0}")]
107    Io(#[from] io::Error),
108    #[error("{0}")]
109    Command(#[from] crate::CommandError),
110    #[error("failed to generate random bytes: {0}")]
111    Getrandom(#[from] getrandom::Error),
112    #[error("path contains non-UTF-8 characters: {}", path.display())]
113    NonUtf8Path { path: PathBuf },
114}
115
116impl CodesignError {
117    fn keychain(step: KeychainStep, source: impl Into<KeychainSetupError>) -> Self {
118        Self::KeychainSetup {
119            step,
120            source: source.into(),
121        }
122    }
123
124    fn non_utf8_path(step: KeychainStep, path: &Path) -> Self {
125        Self::KeychainSetup {
126            step,
127            source: KeychainSetupError::NonUtf8Path {
128                path: path.to_path_buf(),
129            },
130        }
131    }
132}
133
134#[derive(Debug, Error)]
135pub enum CodesignConfigError {
136    #[error(
137        "incomplete macOS signing configuration: all of CODESIGN_IDENTITY, CODESIGN_CERTIFICATE, and CODESIGN_CERTIFICATE_PASSWORD are required (missing: {missing})"
138    )]
139    IncompleteConfiguration { missing: String },
140    #[error("CODESIGN_CERTIFICATE is not valid base64: {0}")]
141    InvalidCertificate(#[source] base64::DecodeError),
142}
143
144/// Configuration for identity-based macOS signing.
145#[derive(Debug)]
146pub struct MacOsSigner {
147    identity: String,
148    certificate: Secret<Vec<u8>>,
149    certificate_password: Secret<String>,
150    /// Extra `--options` value for codesign, parsed from `CODESIGN_OPTIONS`
151    options: Option<String>,
152    /// When `true`, skip the trust check when verifying the signing identity
153    /// exists in the keychain. This is useful for self-signed certificates
154    /// (e.g. in CI) that are not in the system trust store.
155    ///
156    /// Controlled by `CODESIGN_ALLOW_UNTRUSTED=1`.
157    allow_untrusted: bool,
158}
159
160impl MacOsSigner {
161    /// Construct from environment variables.
162    ///
163    /// # Errors
164    ///
165    /// - [`CodesignConfigError::IncompleteConfiguration`] when some but not all of
166    ///   `CODESIGN_IDENTITY`, `CODESIGN_CERTIFICATE`, and `CODESIGN_CERTIFICATE_PASSWORD` are set.
167    /// - [`CodesignConfigError::InvalidCertificate`] when `CODESIGN_CERTIFICATE` is not valid
168    ///   base64.
169    ///
170    /// Returns [`Ok(None)`] when none of the identity variables are set.
171    pub fn from_env() -> Result<Option<Self>, CodesignConfigError> {
172        let identity = std::env::var("CODESIGN_IDENTITY").ok();
173        let cert_b64 = std::env::var("CODESIGN_CERTIFICATE").ok();
174        let password = std::env::var("CODESIGN_CERTIFICATE_PASSWORD").ok();
175
176        match (identity, cert_b64, password) {
177            (None, None, None) => Ok(None),
178            (Some(identity), Some(cert_b64), Some(password)) => {
179                // Strip whitespace before decoding — base64 output from `openssl base64`
180                // and similar tools commonly contains line breaks every 76 characters.
181                // See: https://github.com/marshallpierce/rust-base64/issues/105
182                let cert_b64_clean: String = cert_b64
183                    .chars()
184                    .filter(|c| !c.is_ascii_whitespace())
185                    .collect();
186                let certificate = base64::engine::general_purpose::STANDARD
187                    .decode(&cert_b64_clean)
188                    .map_err(CodesignConfigError::InvalidCertificate)?;
189                let options = std::env::var("CODESIGN_OPTIONS").ok();
190                let allow_untrusted = std::env::var("CODESIGN_ALLOW_UNTRUSTED")
191                    .ok()
192                    .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
193
194                Ok(Some(Self {
195                    identity,
196                    certificate: Secret::new(certificate),
197                    certificate_password: Secret::new(password),
198                    options,
199                    allow_untrusted,
200                }))
201            }
202            (identity, cert_b64, password) => {
203                let mut missing = Vec::new();
204                if identity.is_none() {
205                    missing.push("CODESIGN_IDENTITY");
206                }
207                if cert_b64.is_none() {
208                    missing.push("CODESIGN_CERTIFICATE");
209                }
210                if password.is_none() {
211                    missing.push("CODESIGN_CERTIFICATE_PASSWORD");
212                }
213                Err(CodesignConfigError::IncompleteConfiguration {
214                    missing: missing.join(", "),
215                })
216            }
217        }
218    }
219
220    /// Create a signing session with a shared ephemeral keychain.
221    ///
222    /// The session creates one ephemeral keychain, imports the certificate into it, and holds an
223    /// exclusive file lock to prevent concurrent processes from racing on the macOS keychain search
224    /// list. Use [`MacOsSigningSession::sign`] to sign individual files.
225    ///
226    /// # Errors
227    ///
228    /// - [`CodesignError::KeychainSetup`] if the ephemeral keychain cannot be created, unlocked, or
229    ///   if certificate import fails.
230    pub fn begin_session(&self) -> Result<MacOsSigningSession, CodesignError> {
231        let keychain = EphemeralKeychain::create()?;
232        keychain.import_certificate(
233            self.certificate.expose(),
234            self.certificate_password.expose(),
235        )?;
236        keychain.verify_identity(&self.identity, self.allow_untrusted)?;
237
238        Ok(MacOsSigningSession {
239            identity: self.identity.clone(),
240            options: self.options.clone(),
241            keychain,
242        })
243    }
244}
245
246/// An active identity-signing session backed by a shared ephemeral keychain.
247///
248/// Created via [`MacOsSigner::begin_session`]. The keychain (and its file lock) are held for the
249/// lifetime of this value, so signing multiple files reuses the same keychain and certificate
250/// import.
251#[derive(Debug)]
252pub struct MacOsSigningSession {
253    identity: String,
254    options: Option<String>,
255    keychain: EphemeralKeychain,
256}
257
258impl MacOsSigningSession {
259    /// Sign a single file using the session's ephemeral keychain.
260    ///
261    /// # Errors
262    ///
263    /// - [`CodesignError::Io`] if the `codesign` process cannot be spawned.
264    /// - [`CodesignError::Failed`] if `codesign` exits with a non-zero status.
265    pub fn sign(&self, path: &Path) -> Result<(), CodesignError> {
266        let keychain_str = self.keychain.path_str()?;
267
268        let mut cmd = Command::new(CODESIGN_BIN);
269        cmd.args(["--force", "--sign", &self.identity]);
270        if let Some(options) = &self.options {
271            cmd.args(["--options", options]);
272        }
273        cmd.args(["--keychain", keychain_str]);
274        cmd.arg(path);
275        run_codesign(&mut cmd, path)?;
276
277        tracing::debug!("identity-signed {}", path.display());
278        Ok(())
279    }
280}
281
282/// Sign a file with an ad-hoc identity (no certificate needed).
283///
284/// # Errors
285///
286/// - [`CodesignError::Io`] if the `codesign` process cannot be spawned.
287/// - [`CodesignError::Failed`] if `codesign` exits with a non-zero status.
288pub fn adhoc_sign(path: &Path) -> Result<(), CodesignError> {
289    let mut cmd = Command::new(CODESIGN_BIN);
290    cmd.args(["--force", "--sign", "-"]);
291    cmd.arg(path);
292    run_codesign(&mut cmd, path)?;
293
294    tracing::debug!("ad-hoc signed {}", path.display());
295    Ok(())
296}
297
298/// An ephemeral macOS keychain.
299///
300/// # Drop order
301///
302/// Fields are dropped in declaration order after the manual `Drop` impl runs.
303/// The intended sequence is:
304///
305/// 1. Manual `Drop`: `security delete-keychain` (removes keychain from search list AND deletes
306///    the file).
307/// 2. `temp_dir`: removes the temporary directory (the keychain file is already gone).
308/// 3. `_lock`: releases the exclusive file lock so other processes can proceed.
309#[derive(Debug)]
310struct EphemeralKeychain {
311    temp_dir: tempfile::TempDir,
312    path: PathBuf,
313    password: Secret<String>,
314    /// Exclusive file lock held while the keychain search list is modified.
315    ///
316    /// This prevents concurrent `cargo-code-sign` processes from racing on the global per-user
317    /// keychain search list. The lock is acquired before we modify the search list and released
318    /// when this struct is dropped (after the keychain is deleted in [`Drop`]).
319    _lock: fs_err::File,
320}
321
322impl Drop for EphemeralKeychain {
323    fn drop(&mut self) {
324        // `security delete-keychain` both removes the keychain from the search list AND deletes
325        // the keychain file on disk. This is a single atomic operation that avoids the
326        // stale-snapshot problem of manually restoring a saved search list.
327        let result = Command::new(SECURITY_BIN)
328            .args(["delete-keychain"])
329            .arg(&self.path)
330            .output();
331
332        match result {
333            Ok(output) if output.status.success() => {
334                tracing::debug!("deleted ephemeral keychain {}", self.path.display());
335            }
336            Ok(output) => {
337                tracing::warn!(
338                    "failed to delete ephemeral keychain {}: {}",
339                    self.path.display(),
340                    String::from_utf8_lossy(&output.stderr).trim()
341                );
342            }
343            Err(e) => {
344                tracing::warn!(
345                    "failed to run `security delete-keychain` for {}: {e}",
346                    self.path.display()
347                );
348            }
349        }
350    }
351}
352
353impl EphemeralKeychain {
354    fn create() -> Result<Self, CodesignError> {
355        // Acquire an exclusive file lock before touching the keychain search list.
356        //
357        // This serialises concurrent `cargo-code-sign` processes so they don't clobber each
358        // other's search list changes. The lock is held until this struct is dropped (after
359        // `delete-keychain` has cleaned up).
360        let lock = acquire_keychain_lock()?;
361
362        let temp_dir = tempfile::tempdir()
363            .map_err(|e| CodesignError::keychain(KeychainStep::CreateTempdir, e))?;
364        let path = temp_dir.path().join("signing.keychain-db");
365
366        // Use a random password for the ephemeral keychain.
367        let password = random_hex_password()?;
368
369        let path_str = path
370            .to_str()
371            .ok_or_else(|| CodesignError::non_utf8_path(KeychainStep::CreateKeychain, &path))?;
372
373        // Create keychain
374        run_security(
375            KeychainStep::CreateKeychain,
376            &["create-keychain", "-p", password.expose(), path_str],
377        )?;
378
379        // Set timeout so the keychain stays unlocked during the build.
380        // `-u` locks after the timeout; `-t` sets the interval in seconds.
381        // We intentionally omit `-l` (lock on sleep) — on developer laptops a sleep/wake
382        // mid-build would otherwise lock the keychain and cause signing to fail.
383        run_security(
384            KeychainStep::SetSettings,
385            &["set-keychain-settings", "-t", "21600", "-u", path_str],
386        )?;
387
388        // Unlock
389        run_security(
390            KeychainStep::Unlock,
391            &["unlock-keychain", "-p", password.expose(), path_str],
392        )?;
393
394        // Read the current search list so we can prepend our keychain to it.
395        let current_search_list = get_keychain_search_list()?;
396
397        // Add the ephemeral keychain to the search list (without modifying the default keychain).
398        // This allows codesign to find the imported certificate. On Drop,
399        // `security delete-keychain` will atomically remove it from the search list.
400        {
401            let mut args = vec!["list-keychains", "-d", "user", "-s", path_str];
402            let prev_strs: Vec<&str> = current_search_list
403                .iter()
404                .filter_map(|p| p.to_str())
405                .collect();
406            args.extend(prev_strs);
407            run_security(KeychainStep::SetSearchList, &args)?;
408        }
409
410        Ok(Self {
411            temp_dir,
412            path,
413            password,
414            _lock: lock,
415        })
416    }
417
418    /// Return the keychain path as a UTF-8 string.
419    fn path_str(&self) -> Result<&str, CodesignError> {
420        self.path
421            .to_str()
422            .ok_or_else(|| CodesignError::non_utf8_path(KeychainStep::CreateKeychain, &self.path))
423    }
424
425    /// Verify that the given signing identity exists in this keychain.
426    ///
427    /// Runs `security find-identity -p codesigning` and checks that the output
428    /// contains the requested identity string. This catches typos, expired certificates,
429    /// and wrong certificate types early — before `codesign` fails with a cryptic error.
430    ///
431    /// When `allow_untrusted` is `false` (the default), the `-v` flag is passed to
432    /// filter to valid (trusted) identities only. Set `CODESIGN_ALLOW_UNTRUSTED=1`
433    /// to skip the trust check, which is useful for self-signed certificates in CI.
434    fn verify_identity(&self, identity: &str, allow_untrusted: bool) -> Result<(), CodesignError> {
435        let keychain_str = self.path_str()?;
436
437        let mut cmd = Command::new(SECURITY_BIN);
438        cmd.arg("find-identity");
439        if !allow_untrusted {
440            cmd.arg("-v");
441        }
442        cmd.args(["-p", "codesigning", keychain_str]);
443
444        let output = cmd
445            .output()
446            .map_err(|e| CodesignError::keychain(KeychainStep::VerifyIdentity, e))?;
447
448        let stdout = String::from_utf8_lossy(&output.stdout);
449
450        if stdout.contains(identity) {
451            tracing::debug!("verified identity `{identity}` exists in keychain");
452            return Ok(());
453        }
454
455        // Collect available identities for the error message.
456        // Output lines look like:
457        //   1) AABBCCDD... "Developer ID Application: Example (TEAM1234)"
458        let available: Vec<String> = stdout
459            .lines()
460            .filter(|line| line.contains('"'))
461            .map(|line| {
462                line.trim()
463                    .split_once(") ")
464                    .map_or(line.trim(), |x| x.1)
465                    .to_string()
466            })
467            .collect();
468
469        Err(CodesignError::IdentityNotFound {
470            identity: identity.to_string(),
471            available,
472        })
473    }
474
475    fn import_certificate(
476        &self,
477        certificate: &[u8],
478        passphrase: &str,
479    ) -> Result<(), CodesignError> {
480        let keychain_str = self.path_str()?;
481
482        // Write cert to temp file with restrictive permissions.
483        let cert_path = self.temp_dir.path().join("cert.p12");
484        {
485            use std::io::Write;
486            let mut opts = fs_err::OpenOptions::new();
487            opts.write(true).create_new(true);
488            #[cfg(unix)]
489            {
490                use fs_err::os::unix::fs::OpenOptionsExt;
491                opts.mode(0o600);
492            }
493            let mut file = opts
494                .open(&cert_path)
495                .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
496            file.write_all(certificate)
497                .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
498        }
499
500        let cert_path_str = cert_path.to_str().ok_or_else(|| {
501            CodesignError::non_utf8_path(KeychainStep::ImportCertificate, &cert_path)
502        })?;
503
504        // Import into keychain.
505        //
506        // Use explicit `-T` entries with absolute paths rather than the overly broad `-A` flag
507        // (which would grant all applications access to the key). The `set-key-partition-list`
508        // call below is what actually controls access on modern macOS, but correct `-T` entries
509        // are still important for the legacy ACL layer.
510        //
511        // The `-T` flag registers a specific binary in the keychain item's access control list.
512        // Absolute paths are required because the Keychain Services ACL matches on the exact
513        // path — bare names like "codesign" won't resolve and can silently fail to grant access.
514        run_security(
515            KeychainStep::ImportCertificate,
516            &[
517                "import",
518                cert_path_str,
519                "-k",
520                keychain_str,
521                "-P",
522                passphrase,
523                "-f",
524                "pkcs12",
525                "-T",
526                "/usr/bin/codesign",
527                "-T",
528                "/usr/bin/security",
529                "-T",
530                "/usr/bin/productbuild",
531                "-T",
532                "/usr/bin/pkgbuild",
533            ],
534        )?;
535
536        // Set key partition list for signing keys (`-s`).
537        //
538        // This is the modern access control mechanism on macOS. The partition list must include
539        // "apple:" for `/usr/bin/codesign` to access the key.
540        run_security(
541            KeychainStep::SetPartitionList,
542            &[
543                "set-key-partition-list",
544                "-S",
545                "apple-tool:,apple:,codesign:",
546                "-s",
547                "-k",
548                self.password.expose(),
549                keychain_str,
550            ],
551        )?;
552
553        Ok(())
554    }
555}
556
557/// Acquire an exclusive file lock to serialise access to the keychain search list.
558///
559/// The lock file lives in the system temp directory so all `cargo-code-sign` processes for the same
560/// user converge on the same path.
561fn acquire_keychain_lock() -> Result<fs_err::File, CodesignError> {
562    let lock_path = std::env::temp_dir().join("cargo-code-sign-keychain.lock");
563    let file = fs_err::OpenOptions::new()
564        .write(true)
565        .create(true)
566        .truncate(false)
567        .open(&lock_path)
568        .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
569    tracing::debug!("waiting for keychain lock at {}", lock_path.display());
570    file.lock()
571        .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
572    tracing::debug!("acquired keychain lock");
573    Ok(file)
574}
575
576/// Query the current keychain search list.
577fn get_keychain_search_list() -> Result<Vec<PathBuf>, CodesignError> {
578    let output = Command::new(SECURITY_BIN)
579        .args(["list-keychains", "-d", "user"])
580        .output()
581        .map_err(|e| CodesignError::keychain(KeychainStep::GetSearchList, e))?;
582
583    if !output.status.success() {
584        return Err(CodesignError::keychain(
585            KeychainStep::GetSearchList,
586            crate::CommandError::Failed {
587                status: output.status,
588                stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
589                stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
590            },
591        ));
592    }
593
594    // Output is one quoted path per line, e.g.:
595    //     "/Users/foo/Library/Keychains/login.keychain-db"
596    Ok(String::from_utf8_lossy(&output.stdout)
597        .lines()
598        .map(|line| line.trim().trim_matches('"'))
599        .filter(|s| !s.is_empty())
600        .map(PathBuf::from)
601        .collect())
602}
603
604/// Run a `codesign` command and translate failures into [`CodesignError`].
605fn run_codesign(cmd: &mut Command, path: &Path) -> Result<(), CodesignError> {
606    crate::run_command(cmd).map_err(|source| CodesignError::Sign {
607        path: path.to_path_buf(),
608        source,
609    })
610}
611
612/// Run a `security` command and map failures to keychain-specific errors.
613fn run_security(step: KeychainStep, args: &[&str]) -> Result<(), CodesignError> {
614    crate::run_command(Command::new(SECURITY_BIN).args(args))
615        .map_err(|e| CodesignError::keychain(step, e))
616}
617
618/// Generate a random hex string for ephemeral keychain passwords.
619fn random_hex_password() -> Result<Secret<String>, CodesignError> {
620    let mut buf = [0u8; 32];
621    getrandom::fill(&mut buf)
622        .map_err(|e| CodesignError::keychain(KeychainStep::GeneratePassword, e))?;
623    let mut hex = String::with_capacity(64);
624    for b in &buf {
625        use fmt::Write;
626        write!(hex, "{b:02x}").unwrap();
627    }
628    buf.zeroize();
629    Ok(Secret::new(hex))
630}
631
632#[cfg(all(test, target_os = "macos"))]
633mod tests {
634    use super::*;
635
636    #[cfg(target_os = "macos")]
637    fn require_command_or_skip(context: &str, command: &str) -> bool {
638        if Command::new(command).arg("--help").output().is_ok() {
639            return true;
640        }
641        eprintln!("skipping {context}: required command not found in PATH: {command}");
642        false
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn test_random_hex_password() {
648        let a = random_hex_password().unwrap();
649        let b = random_hex_password().unwrap();
650        let a = a.expose();
651        let b = b.expose();
652        assert_eq!(a.len(), 64, "expected 32 bytes = 64 hex chars");
653        assert_ne!(a, b, "two random passwords should differ");
654        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
655    }
656
657    #[cfg(target_os = "macos")]
658    #[test]
659    fn test_adhoc_sign_real_binary() {
660        if !require_command_or_skip("adhoc signing real binary", CODESIGN_BIN) {
661            return;
662        }
663
664        let tmp = tempfile::tempdir().unwrap();
665        let path = tmp.path().join("true_copy");
666        fs_err::copy("/usr/bin/true", &path).unwrap();
667        adhoc_sign(&path).unwrap();
668    }
669
670    #[cfg(target_os = "macos")]
671    #[test]
672    fn test_adhoc_sign_nonexistent_fails() {
673        if !require_command_or_skip("adhoc signing nonexistent file", CODESIGN_BIN) {
674            return;
675        }
676
677        let tmp = tempfile::tempdir().unwrap();
678        let path = tmp.path().join("nope");
679        // This should fail because the file doesn't exist.
680        assert!(adhoc_sign(&path).is_err());
681    }
682}