Skip to main content

native_code_sign/
lib.rs

1//! Platform-native code signing.
2//!
3//! Wraps native platform tools to sign binaries:
4//! - [`macos`]: Apple `codesign` via ephemeral keychain (for CI) or ad-hoc signing.
5//! - [`windows`]: Microsoft `signtool.exe` with local certificate or Azure Trusted Signing.
6
7pub(crate) mod macos;
8pub(crate) mod secret;
9pub(crate) mod windows;
10
11use std::io;
12use std::path::Path;
13use std::process::{Command, ExitStatus};
14
15use thiserror::Error;
16
17pub use macos::{
18    CodesignConfigError, CodesignError, KeychainSetupError, KeychainStep, MacOsSigner,
19    MacOsSigningSession, adhoc_sign,
20};
21pub use windows::{SigntoolConfigError, SigntoolError, WindowsSigner};
22
23/// Error from running an external command (codesign, security, signtool).
24#[derive(Debug, Error)]
25pub enum CommandError {
26    #[error("failed to spawn: {0}")]
27    Spawn(#[source] io::Error),
28    #[error("exited with {status}\nstdout: {stdout}\nstderr: {stderr}")]
29    Failed {
30        status: ExitStatus,
31        stdout: String,
32        stderr: String,
33    },
34}
35
36/// Run a command, returning a [`CommandError`] on spawn failure or non-zero exit.
37pub(crate) fn run_command(cmd: &mut Command) -> Result<(), CommandError> {
38    let output = cmd.output().map_err(CommandError::Spawn)?;
39    if !output.status.success() {
40        return Err(CommandError::Failed {
41            status: output.status,
42            stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
43            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
44        });
45    }
46    Ok(())
47}
48
49#[derive(Debug, Error)]
50pub enum SignError {
51    #[error("codesign failed: {0}")]
52    Codesign(#[from] macos::CodesignError),
53    #[error("signtool failed: {0}")]
54    Signtool(#[from] windows::SigntoolError),
55}
56
57#[derive(Debug, Error)]
58pub enum SignConfigError {
59    #[error("invalid macOS signing configuration: {0}")]
60    MacOs(#[from] macos::CodesignConfigError),
61    #[error("invalid Windows signing configuration: {0}")]
62    Windows(#[from] windows::SigntoolConfigError),
63}
64
65/// A configured signer, determined from the environment and target triple.
66#[derive(Debug)]
67pub enum Signer {
68    MacOsIdentity(MacOsSigner),
69    MacOsAdHoc,
70    Windows(WindowsSigner),
71}
72
73impl Signer {
74    /// Detect signing configuration from environment variables and target triple.
75    ///
76    /// # Errors
77    ///
78    /// - [`SignConfigError::MacOs`] when macOS signing env vars are partially set
79    ///   or the certificate is invalid base64.
80    /// - [`SignConfigError::Windows`] when Windows signing env vars are partially set.
81    ///
82    /// Returns `Ok(None)` when signing is intentionally unavailable for this
83    /// target (e.g., no credentials configured, or an unsupported platform).
84    pub fn from_env(target_triple: &str) -> Result<Option<Self>, SignConfigError> {
85        if target_triple.contains("apple") {
86            if let Some(signer) = MacOsSigner::from_env()? {
87                return Ok(Some(Self::MacOsIdentity(signer)));
88            }
89            // Fall back to ad-hoc signing, but only when running on macOS.
90            // Cross-compiling to apple targets from other hosts can't use codesign.
91            if cfg!(target_os = "macos") {
92                return Ok(Some(Self::MacOsAdHoc));
93            }
94            tracing::warn!(
95                target = %target_triple,
96                "skipping Apple signing on non-macOS host"
97            );
98            return Ok(None);
99        }
100
101        if target_triple.contains("windows") {
102            // Cross-compiling to Windows from non-Windows hosts cannot run signtool.exe.
103            if !cfg!(target_os = "windows") {
104                tracing::warn!(
105                    target = %target_triple,
106                    "skipping Windows signing on non-Windows host"
107                );
108                return Ok(None);
109            }
110
111            if let Some(signer) = WindowsSigner::from_env()? {
112                return Ok(Some(Self::Windows(signer)));
113            }
114            // No Windows signing credentials configured.
115            return Ok(None);
116        }
117
118        // Linux/other: no signing support.
119        Ok(None)
120    }
121
122    /// Prepare a signing session.
123    ///
124    /// For macOS identity signing this creates a shared ephemeral keychain
125    /// (with an exclusive file lock) that is reused for every file signed
126    /// during the session. Call [`SigningSession::sign`] for each artifact.
127    ///
128    /// # Errors
129    ///
130    /// - [`SignError::Codesign`] if the macOS ephemeral keychain cannot be
131    ///   created or the certificate cannot be imported.
132    pub fn begin_session(self) -> Result<SigningSession, SignError> {
133        match self {
134            Self::MacOsIdentity(s) => {
135                let session = s.begin_session()?;
136                Ok(SigningSession::MacOsIdentity(session))
137            }
138            Self::MacOsAdHoc => Ok(SigningSession::MacOsAdHoc),
139            Self::Windows(s) => Ok(SigningSession::Windows(s)),
140        }
141    }
142}
143
144/// An active signing session.
145///
146/// For macOS identity signing the session holds a shared ephemeral keychain
147/// and an exclusive file lock on the keychain search list, amortising the
148/// setup cost across all artifacts and preventing races with concurrent
149/// processes.
150#[derive(Debug)]
151pub enum SigningSession {
152    MacOsIdentity(MacOsSigningSession),
153    MacOsAdHoc,
154    Windows(WindowsSigner),
155}
156
157impl SigningSession {
158    /// Sign a single file.
159    ///
160    /// # Errors
161    ///
162    /// - [`SignError::Codesign`] if macOS `codesign` fails (identity or ad-hoc).
163    /// - [`SignError::Signtool`] if Windows `signtool` fails.
164    pub fn sign(&self, path: &Path) -> Result<(), SignError> {
165        match self {
166            Self::MacOsIdentity(s) => s.sign(path).map_err(SignError::from),
167            Self::MacOsAdHoc => adhoc_sign(path).map_err(SignError::from),
168            Self::Windows(s) => s.sign(path).map_err(SignError::from),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn assert_send<T: Send>() {}
178    fn assert_sync<T: Sync>() {}
179
180    #[test]
181    fn signer_is_send_and_sync() {
182        assert_send::<Signer>();
183        assert_sync::<Signer>();
184    }
185
186    #[test]
187    fn errors_are_send_and_sync() {
188        assert_send::<SignError>();
189        assert_sync::<SignError>();
190        assert_send::<SignConfigError>();
191        assert_sync::<SignConfigError>();
192    }
193}