Skip to main content

native_code_sign/
macos.rs

1//! macOS code signing using Apple's `codesign` tool.
2//!
3//! Environment variables:
4//! - `CODE_SIGN_IDENTITY`: signing identity (e.g. "Developer ID Application: ...")
5//! - `CODE_SIGN_CERTIFICATE`: base64-encoded `.p12` certificate
6//! - `CODE_SIGN_CERTIFICATE_PASSWORD`: password for the `.p12`
7//! - `CODE_SIGN_OPTIONS`: (optional) extra `--options` value (e.g. `"runtime"`)
8//! - `CODE_SIGN_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 `CODE_SIGN_IDENTITY`, `CODE_SIGN_CERTIFICATE`, and
13//!    `CODE_SIGN_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 CODE_SIGN_IDENTITY, CODE_SIGN_CERTIFICATE, and CODE_SIGN_CERTIFICATE_PASSWORD are required (missing: {missing})"
138    )]
139    IncompleteConfiguration { missing: String },
140    #[error("CODE_SIGN_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 `CODE_SIGN_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 `CODE_SIGN_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    ///   `CODE_SIGN_IDENTITY`, `CODE_SIGN_CERTIFICATE`, and `CODE_SIGN_CERTIFICATE_PASSWORD` are set.
167    /// - [`CodesignConfigError::InvalidCertificate`] when `CODE_SIGN_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("CODE_SIGN_IDENTITY").ok();
173        let cert_b64 = std::env::var("CODE_SIGN_CERTIFICATE").ok();
174        let password = std::env::var("CODE_SIGN_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("CODE_SIGN_OPTIONS").ok();
190                let allow_untrusted = std::env::var("CODE_SIGN_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("CODE_SIGN_IDENTITY");
206                }
207                if cert_b64.is_none() {
208                    missing.push("CODE_SIGN_CERTIFICATE");
209                }
210                if password.is_none() {
211                    missing.push("CODE_SIGN_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 `CODE_SIGN_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        if !output.status.success() {
449            return Err(CodesignError::keychain(
450                KeychainStep::VerifyIdentity,
451                crate::CommandError::Failed {
452                    status: output.status,
453                    stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
454                    stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
455                },
456            ));
457        }
458
459        let stdout = String::from_utf8_lossy(&output.stdout);
460
461        if stdout.contains(identity) {
462            tracing::debug!("verified identity `{identity}` exists in keychain");
463            return Ok(());
464        }
465
466        // Collect available identities for the error message.
467        // Output lines look like:
468        //   1) AABBCCDD... "Developer ID Application: Example (TEAM1234)"
469        let available: Vec<String> = stdout
470            .lines()
471            .filter(|line| line.contains('"'))
472            .map(|line| {
473                line.trim()
474                    .split_once(") ")
475                    .map_or(line.trim(), |x| x.1)
476                    .to_string()
477            })
478            .collect();
479
480        Err(CodesignError::IdentityNotFound {
481            identity: identity.to_string(),
482            available,
483        })
484    }
485
486    fn import_certificate(
487        &self,
488        certificate: &[u8],
489        passphrase: &str,
490    ) -> Result<(), CodesignError> {
491        let keychain_str = self.path_str()?;
492
493        // Write cert to temp file with restrictive permissions.
494        let cert_path = self.temp_dir.path().join("cert.p12");
495        {
496            use std::io::Write;
497            let mut opts = fs_err::OpenOptions::new();
498            opts.write(true).create_new(true);
499            #[cfg(unix)]
500            {
501                use fs_err::os::unix::fs::OpenOptionsExt;
502                opts.mode(0o600);
503            }
504            let mut file = opts
505                .open(&cert_path)
506                .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
507            file.write_all(certificate)
508                .map_err(|e| CodesignError::keychain(KeychainStep::WriteCertificate, e))?;
509        }
510
511        let cert_path_str = cert_path.to_str().ok_or_else(|| {
512            CodesignError::non_utf8_path(KeychainStep::ImportCertificate, &cert_path)
513        })?;
514
515        // Import into keychain.
516        //
517        // Use explicit `-T` entries with absolute paths rather than the overly broad `-A` flag
518        // (which would grant all applications access to the key). The `set-key-partition-list`
519        // call below is what actually controls access on modern macOS, but correct `-T` entries
520        // are still important for the legacy ACL layer.
521        //
522        // The `-T` flag registers a specific binary in the keychain item's access control list.
523        // Absolute paths are required because the Keychain Services ACL matches on the exact
524        // path — bare names like "codesign" won't resolve and can silently fail to grant access.
525        run_security(
526            KeychainStep::ImportCertificate,
527            &[
528                "import",
529                cert_path_str,
530                "-k",
531                keychain_str,
532                "-P",
533                passphrase,
534                "-f",
535                "pkcs12",
536                "-T",
537                "/usr/bin/codesign",
538                "-T",
539                "/usr/bin/security",
540                "-T",
541                "/usr/bin/productbuild",
542                "-T",
543                "/usr/bin/pkgbuild",
544            ],
545        )?;
546
547        // Set key partition list for signing keys (`-s`).
548        //
549        // This is the modern access control mechanism on macOS. The partition list must include
550        // "apple:" for `/usr/bin/codesign` to access the key.
551        run_security(
552            KeychainStep::SetPartitionList,
553            &[
554                "set-key-partition-list",
555                "-S",
556                "apple-tool:,apple:,codesign:",
557                "-s",
558                "-k",
559                self.password.expose(),
560                keychain_str,
561            ],
562        )?;
563
564        Ok(())
565    }
566}
567
568/// Acquire an exclusive file lock to serialise access to the keychain search list.
569///
570/// The lock file lives in the system temp directory so all `cargo-code-sign` processes for the same
571/// user converge on the same path.
572fn acquire_keychain_lock() -> Result<fs_err::File, CodesignError> {
573    let lock_path = std::env::temp_dir().join("cargo-code-sign-keychain.lock");
574    let file = fs_err::OpenOptions::new()
575        .write(true)
576        .create(true)
577        .truncate(false)
578        .open(&lock_path)
579        .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
580    tracing::debug!("waiting for keychain lock at {}", lock_path.display());
581    file.lock()
582        .map_err(|e| CodesignError::keychain(KeychainStep::AcquireLock, e))?;
583    tracing::debug!("acquired keychain lock");
584    Ok(file)
585}
586
587/// Query the current keychain search list.
588fn get_keychain_search_list() -> Result<Vec<PathBuf>, CodesignError> {
589    let output = Command::new(SECURITY_BIN)
590        .args(["list-keychains", "-d", "user"])
591        .output()
592        .map_err(|e| CodesignError::keychain(KeychainStep::GetSearchList, e))?;
593
594    if !output.status.success() {
595        return Err(CodesignError::keychain(
596            KeychainStep::GetSearchList,
597            crate::CommandError::Failed {
598                status: output.status,
599                stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
600                stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
601            },
602        ));
603    }
604
605    // Output is one quoted path per line, e.g.:
606    //     "/Users/foo/Library/Keychains/login.keychain-db"
607    Ok(String::from_utf8_lossy(&output.stdout)
608        .lines()
609        .map(|line| line.trim().trim_matches('"'))
610        .filter(|s| !s.is_empty())
611        .map(PathBuf::from)
612        .collect())
613}
614
615/// Run a `codesign` command and translate failures into [`CodesignError`].
616fn run_codesign(cmd: &mut Command, path: &Path) -> Result<(), CodesignError> {
617    crate::run_command(cmd).map_err(|source| CodesignError::Sign {
618        path: path.to_path_buf(),
619        source,
620    })
621}
622
623/// Run a `security` command and map failures to keychain-specific errors.
624fn run_security(step: KeychainStep, args: &[&str]) -> Result<(), CodesignError> {
625    crate::run_command(Command::new(SECURITY_BIN).args(args))
626        .map_err(|e| CodesignError::keychain(step, e))
627}
628
629/// Generate a random hex string for ephemeral keychain passwords.
630fn random_hex_password() -> Result<Secret<String>, CodesignError> {
631    let mut buf = [0u8; 32];
632    getrandom::fill(&mut buf)
633        .map_err(|e| CodesignError::keychain(KeychainStep::GeneratePassword, e))?;
634    let mut hex = String::with_capacity(64);
635    for b in &buf {
636        use fmt::Write;
637        write!(hex, "{b:02x}").unwrap();
638    }
639    buf.zeroize();
640    Ok(Secret::new(hex))
641}
642
643#[cfg(all(test, target_os = "macos"))]
644mod tests {
645    use super::*;
646
647    #[cfg(target_os = "macos")]
648    fn require_command_or_skip(context: &str, command: &str) -> bool {
649        if Command::new(command).arg("--help").output().is_ok() {
650            return true;
651        }
652        eprintln!("skipping {context}: required command not found in PATH: {command}");
653        false
654    }
655
656    #[cfg(unix)]
657    #[test]
658    fn test_random_hex_password() {
659        let a = random_hex_password().unwrap();
660        let b = random_hex_password().unwrap();
661        let a = a.expose();
662        let b = b.expose();
663        assert_eq!(a.len(), 64, "expected 32 bytes = 64 hex chars");
664        assert_ne!(a, b, "two random passwords should differ");
665        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
666    }
667
668    #[cfg(target_os = "macos")]
669    #[test]
670    fn test_adhoc_sign_real_binary() {
671        if !require_command_or_skip("adhoc signing real binary", CODESIGN_BIN) {
672            return;
673        }
674
675        let tmp = tempfile::tempdir().unwrap();
676        let path = tmp.path().join("true_copy");
677        fs_err::copy("/usr/bin/true", &path).unwrap();
678        adhoc_sign(&path).unwrap();
679    }
680
681    #[cfg(target_os = "macos")]
682    #[test]
683    fn test_adhoc_sign_nonexistent_fails() {
684        if !require_command_or_skip("adhoc signing nonexistent file", CODESIGN_BIN) {
685            return;
686        }
687
688        let tmp = tempfile::tempdir().unwrap();
689        let path = tmp.path().join("nope");
690        // This should fail because the file doesn't exist.
691        assert!(adhoc_sign(&path).is_err());
692    }
693}