Skip to main content

grit_lib/
signing.rs

1//! Commit/tag GPG (and gpgsm/ssh) signing and signature verification.
2//!
3//! This is a port of the parts of Git's `gpg-interface.c` and `commit.c`
4//! signature handling that the grit CLI needs:
5//!
6//! * read `gpg.format`, `gpg.<fmt>.program` / `gpg.program`, `user.signingkey`
7//!   and `gpg.minTrustLevel`,
8//! * resolve the signing program (handling a leading `~`, absolute paths, and
9//!   bare names looked up on `$PATH`),
10//! * [`sign_buffer`] — spawn `<program> --status-fd=2 -bsau <key>` and capture
11//!   the armored detached signature,
12//! * [`add_header_signature`] — splice a `gpgsig` header into a serialized
13//!   commit object (Git `commit.c:add_header_signature`),
14//! * [`extract_signed_payload`] / [`verify_commit`] — strip the `gpgsig` header
15//!   to rebuild the signed payload and run `<program> --verify` over it,
16//!   parsing the `[GNUPG:]` status lines into a [`SignatureCheck`].
17//!
18//! OpenPGP, X.509 (`gpgsm`), and SSH (`ssh-keygen -Y sign`) commit signing are
19//! supported when porcelain commit creation honors `commit.gpgsign`.
20
21use std::ffi::OsString;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use crate::command_runner::{
27    system_command_runner, CommandEnvironment, CommandOutput, CommandRunner, CommandSpec,
28    CommandStdin, CommandStdio,
29};
30use crate::config::ConfigSet;
31use crate::error::{Error, Result};
32use crate::objects::HashAlgo;
33
34/// The hash header label for a sha1 repository.
35pub const GPG_SIG_HEADER_SHA1: &str = "gpgsig";
36/// The hash header label for a sha256 repository.
37pub const GPG_SIG_HEADER_SHA256: &str = "gpgsig-sha256";
38
39/// Signature trust level, as reported for a verified signature.
40///
41/// The numeric ordering matters: `gpg.minTrustLevel` comparisons use it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
43pub enum TrustLevel {
44    #[default]
45    /// `Undefined = 0` variant.
46    Undefined = 0,
47    /// `Never = 1` variant.
48    Never = 1,
49    /// `Marginal = 2` variant.
50    Marginal = 2,
51    /// `Fully = 3` variant.
52    Fully = 3,
53    /// `Ultimate = 4` variant.
54    Ultimate = 4,
55}
56
57impl TrustLevel {
58    /// The `%GT` display string (`undefined`, `never`, ...).
59    pub fn display_key(self) -> &'static str {
60        match self {
61            TrustLevel::Undefined => "undefined",
62            TrustLevel::Never => "never",
63            TrustLevel::Marginal => "marginal",
64            TrustLevel::Fully => "fully",
65            TrustLevel::Ultimate => "ultimate",
66        }
67    }
68
69    /// Parse an uppercase GNUPG `TRUST_<LEVEL>` suffix.
70    fn from_status(level: &str) -> Option<TrustLevel> {
71        match level {
72            "UNDEFINED" => Some(TrustLevel::Undefined),
73            "NEVER" => Some(TrustLevel::Never),
74            "MARGINAL" => Some(TrustLevel::Marginal),
75            "FULLY" => Some(TrustLevel::Fully),
76            "ULTIMATE" => Some(TrustLevel::Ultimate),
77            _ => None,
78        }
79    }
80
81    /// Parse a configured `gpg.minTrustLevel` value (case-insensitive).
82    pub fn from_config(value: &str) -> Option<TrustLevel> {
83        TrustLevel::from_status(&value.to_ascii_uppercase())
84    }
85}
86
87/// The signature format selected via `gpg.format`.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum GpgFormat {
90    /// `OpenPgp` variant.
91    OpenPgp,
92    /// `X509` variant.
93    X509,
94    /// `Ssh` variant.
95    Ssh,
96}
97
98impl GpgFormat {
99    /// Resolve a `gpg.format` value case-sensitively (Git `get_format_by_name`).
100    ///
101    /// `openpgp` is valid; `OpEnPgP` is not (matches subtest 24).
102    pub fn from_name(name: &str) -> Option<GpgFormat> {
103        match name {
104            "openpgp" => Some(GpgFormat::OpenPgp),
105            "x509" => Some(GpgFormat::X509),
106            "ssh" => Some(GpgFormat::Ssh),
107            _ => None,
108        }
109    }
110
111    /// The format name used in `gpg.<fmt>.program`.
112    fn name(self) -> &'static str {
113        match self {
114            GpgFormat::OpenPgp => "openpgp",
115            GpgFormat::X509 => "x509",
116            GpgFormat::Ssh => "ssh",
117        }
118    }
119
120    /// Default program for this format.
121    fn default_program(self) -> &'static str {
122        match self {
123            GpgFormat::OpenPgp => "gpg",
124            GpgFormat::X509 => "gpgsm",
125            GpgFormat::Ssh => "ssh-keygen",
126        }
127    }
128
129    /// Detect the format from a signature's armor header
130    /// (Git `gpg-interface.c:get_format_by_sig`). Returns `None` for an
131    /// unrecognized signature.
132    pub fn from_signature(sig: &[u8]) -> Option<GpgFormat> {
133        const OPENPGP: &[&[u8]] = &[
134            b"-----BEGIN PGP SIGNATURE-----",
135            b"-----BEGIN PGP MESSAGE-----",
136        ];
137        const X509: &[&[u8]] = &[b"-----BEGIN SIGNED MESSAGE-----"];
138        const SSH: &[&[u8]] = &[b"-----BEGIN SSH SIGNATURE-----"];
139        for prefix in OPENPGP {
140            if sig.starts_with(prefix) {
141                return Some(GpgFormat::OpenPgp);
142            }
143        }
144        for prefix in X509 {
145            if sig.starts_with(prefix) {
146                return Some(GpgFormat::X509);
147            }
148        }
149        for prefix in SSH {
150            if sig.starts_with(prefix) {
151                return Some(GpgFormat::Ssh);
152            }
153        }
154        None
155    }
156
157    /// Extra arguments passed before `--verify` for this format.
158    fn verify_args(self) -> &'static [&'static str] {
159        match self {
160            GpgFormat::OpenPgp => &["--keyid-format=long"],
161            GpgFormat::X509 => &["--keyid-format=long"],
162            GpgFormat::Ssh => &[],
163        }
164    }
165}
166
167/// Resolved signing/verification configuration.
168#[derive(Clone)]
169pub struct GpgConfig {
170    /// The selected format.
171    pub format: GpgFormat,
172    /// The resolved program command for [`Self::format`] (used for signing; may
173    /// be a bare name to look up on `$PATH`).
174    pub program: String,
175    /// `gpg.program` (the format-agnostic fallback), if set.
176    pub generic_program: Option<String>,
177    /// `gpg.openpgp.program`, if set.
178    pub openpgp_program: Option<String>,
179    /// `gpg.x509.program`, if set.
180    pub x509_program: Option<String>,
181    /// `gpg.ssh.program`, if set.
182    pub ssh_program: Option<String>,
183    /// `user.signingkey`, if set.
184    pub signing_key: Option<String>,
185    /// `gpg.minTrustLevel`, if set.
186    pub min_trust_level: Option<TrustLevel>,
187    /// `gpg.ssh.allowedSignersFile`, if set (path; leading `~/` expanded).
188    pub ssh_allowed_signers: Option<String>,
189    /// `gpg.ssh.revocationFile`, if set (path; leading `~/` expanded).
190    pub ssh_revocation_file: Option<String>,
191    /// Subprocess runner for gpg/gpgsm/ssh-signing helpers.
192    pub command_runner: Arc<dyn CommandRunner>,
193}
194
195impl std::fmt::Debug for GpgConfig {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.debug_struct("GpgConfig")
198            .field("format", &self.format)
199            .field("program", &self.program)
200            .finish_non_exhaustive()
201    }
202}
203
204fn signing_spawn(
205    cfg: &GpgConfig,
206    spec: CommandSpec,
207    payload: Option<&[u8]>,
208) -> Result<CommandOutput> {
209    let mut running = cfg
210        .command_runner
211        .spawn(&spec)
212        .map_err(|e| Error::Signing(format!("could not run signing program: {e}")))?;
213    if let Some(data) = payload {
214        let Some(stdin) = running.stdin_mut() else {
215            return Err(Error::Signing(
216                "could not write payload to signing program stdin".into(),
217            ));
218        };
219        stdin.write_all(data).map_err(|e| {
220            Error::Signing(format!("could not write payload to signing program: {e}"))
221        })?;
222    }
223    running
224        .wait_with_output()
225        .map_err(|e| Error::Signing(format!("failed waiting for signing program: {e}")))
226}
227
228fn stdin_pipe(data: &[u8]) -> CommandStdin {
229    CommandStdin::Pipe(data.to_vec())
230}
231
232impl GpgConfig {
233    /// Read the signing configuration from a [`ConfigSet`].
234    ///
235    /// # Errors
236    ///
237    /// Returns [`Error::Config`] when `gpg.format` holds an unrecognized
238    /// value (Git rejects this case-sensitively).
239    pub fn from_config(config: &ConfigSet) -> Result<GpgConfig> {
240        let format = match config.get("gpg.format") {
241            Some(raw) => GpgFormat::from_name(&raw).ok_or_else(|| {
242                Error::Config(format!("invalid value for 'gpg.format': '{raw}'").into())
243            })?,
244            None => GpgFormat::OpenPgp,
245        };
246
247        let nonempty = |k: &str| config.get(k).filter(|p| !p.is_empty());
248        let generic_program = nonempty("gpg.program");
249        let fmt_program = |f: GpgFormat| nonempty(&format!("gpg.{}.program", f.name()));
250        let openpgp_program = fmt_program(GpgFormat::OpenPgp);
251        let x509_program = fmt_program(GpgFormat::X509);
252        let ssh_program = fmt_program(GpgFormat::Ssh);
253
254        // `gpg.<fmt>.program` takes precedence over `gpg.program`.
255        let program = resolve_program_for_format(
256            format,
257            generic_program.as_deref(),
258            match format {
259                GpgFormat::OpenPgp => openpgp_program.as_deref(),
260                GpgFormat::X509 => x509_program.as_deref(),
261                GpgFormat::Ssh => ssh_program.as_deref(),
262            },
263        );
264
265        let signing_key = config.get("user.signingkey").filter(|k| !k.is_empty());
266
267        let min_trust_level = config
268            .get("gpg.mintrustlevel")
269            .and_then(|v| TrustLevel::from_config(&v));
270
271        // Path values: Git uses `git_config_pathname`, which expands a leading
272        // `~/` relative to $HOME. `ConfigSet` lowercases section/variable names.
273        let ssh_allowed_signers = config
274            .get("gpg.ssh.allowedsignersfile")
275            .filter(|p| !p.is_empty())
276            .map(|p| expand_tilde(&p));
277        let ssh_revocation_file = config
278            .get("gpg.ssh.revocationfile")
279            .filter(|p| !p.is_empty())
280            .map(|p| expand_tilde(&p));
281
282        Ok(GpgConfig {
283            format,
284            program,
285            generic_program,
286            openpgp_program,
287            x509_program,
288            ssh_program,
289            signing_key,
290            min_trust_level,
291            ssh_allowed_signers,
292            ssh_revocation_file,
293            command_runner: system_command_runner(),
294        })
295    }
296
297    /// Override the subprocess runner (tests and embedders).
298    #[must_use]
299    pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
300        self.command_runner = runner;
301        self
302    }
303
304    /// Resolve the program for a specific format (honoring `gpg.<fmt>.program`
305    /// then `gpg.program`, falling back to the format default). Used by
306    /// verification, where the format is detected from the signature armor and
307    /// may differ from the configured [`Self::format`].
308    fn program_for(&self, format: GpgFormat) -> String {
309        let fmt_program = match format {
310            GpgFormat::OpenPgp => self.openpgp_program.as_deref(),
311            GpgFormat::X509 => self.x509_program.as_deref(),
312            GpgFormat::Ssh => self.ssh_program.as_deref(),
313        };
314        resolve_program_for_format(format, self.generic_program.as_deref(), fmt_program)
315    }
316
317    /// The signing key to use: the explicit `key_override`, else
318    /// `user.signingkey`, else the supplied committer identity (Git passes
319    /// `git_committer_info(IDENT_STRICT | IDENT_NO_DATE)`).
320    pub fn resolve_signing_key(
321        &self,
322        key_override: Option<&str>,
323        committer_default: &str,
324    ) -> String {
325        if let Some(k) = key_override {
326            if !k.is_empty() {
327                return k.to_owned();
328            }
329        }
330        if let Some(k) = &self.signing_key {
331            return k.clone();
332        }
333        committer_default.to_owned()
334    }
335
336    /// Resolve [`Self::program`] to an executable path.
337    ///
338    /// Mirrors Git's program resolution: a leading `~/` expands to `$HOME`, an
339    /// absolute path is used verbatim, and a bare name is searched on `$PATH`.
340    pub fn resolve_program_path(&self) -> Result<PathBuf> {
341        resolve_program(&self.program)
342    }
343}
344
345/// Resolve the program *string* for `format`: `gpg.<fmt>.program` (if set),
346/// else `gpg.program` (if set), else the format's built-in default.
347fn resolve_program_for_format(
348    format: GpgFormat,
349    generic_program: Option<&str>,
350    fmt_program: Option<&str>,
351) -> String {
352    fmt_program
353        .or(generic_program)
354        .filter(|p| !p.is_empty())
355        .map(|p| p.to_owned())
356        .unwrap_or_else(|| format.default_program().to_owned())
357}
358
359/// Resolve a program string to an executable path.
360fn resolve_program(program: &str) -> Result<PathBuf> {
361    // `~` / `~/...` expansion relative to $HOME.
362    if program == "~" {
363        if let Some(home) = home_dir() {
364            return Ok(home);
365        }
366    }
367    if let Some(rest) = program.strip_prefix("~/") {
368        if let Some(home) = home_dir() {
369            return Ok(home.join(rest));
370        }
371    }
372
373    let path = Path::new(program);
374    // Absolute path, or any relative path that contains a separator: use as-is.
375    if path.is_absolute() || program.contains('/') {
376        return Ok(path.to_path_buf());
377    }
378
379    // Bare name: search $PATH.
380    if let Some(found) = search_path(program) {
381        return Ok(found);
382    }
383
384    // Fall back to the bare name and let the OS resolve it (preserves Git's
385    // behavior of handing the name straight to exec when not found on PATH).
386    Ok(path.to_path_buf())
387}
388
389/// Look up a bare program name on `$PATH`.
390fn search_path(name: &str) -> Option<PathBuf> {
391    let paths = crate::environment::Environment::empty().var_os("PATH")?;
392    for dir in paths.to_string_lossy().split(':').map(PathBuf::from) {
393        if dir.as_os_str().is_empty() {
394            continue;
395        }
396        let candidate = dir.join(name);
397        if is_executable_file(&candidate) {
398            return Some(candidate);
399        }
400    }
401    None
402}
403
404#[cfg(unix)]
405fn is_executable_file(path: &Path) -> bool {
406    use std::os::unix::fs::PermissionsExt;
407    match std::fs::metadata(path) {
408        Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111 != 0),
409        Err(_) => false,
410    }
411}
412
413#[cfg(not(unix))]
414fn is_executable_file(path: &Path) -> bool {
415    path.is_file()
416}
417
418/// The user's home directory (`$HOME`).
419fn home_dir() -> Option<PathBuf> {
420    crate::environment::Environment::empty()
421        .var_os("HOME")
422        .map(PathBuf::from)
423}
424
425/// Expand a leading `~/` (and a bare `~`) relative to `$HOME`, like Git's
426/// `interpolate_path` / `git_config_pathname` for the simple home case.
427fn expand_tilde(path: &str) -> String {
428    if path == "~" {
429        if let Some(home) = home_dir() {
430            return home.to_string_lossy().into_owned();
431        }
432    }
433    if let Some(rest) = path.strip_prefix("~/") {
434        if let Some(home) = home_dir() {
435            return home.join(rest).to_string_lossy().into_owned();
436        }
437    }
438    path.to_owned()
439}
440
441/// Whether a new commit object should be signed.
442///
443/// Honors `commit.gpgsign` (and `commit.gpgSign`) when `sign_override` is `None`.
444/// When `sign_override` is `Some(true)` or `Some(false)`, that choice wins over config.
445///
446/// # Errors
447///
448/// Returns [`Error::Config`] when `commit.gpgsign` holds a non-boolean value.
449pub fn should_sign_commit(config: &ConfigSet, sign_override: Option<bool>) -> Result<bool> {
450    if let Some(force) = sign_override {
451        return Ok(force);
452    }
453    Ok(config
454        .get_bool("commit.gpgSign")
455        .transpose()?
456        .unwrap_or(false))
457}
458
459/// Whether a new annotated tag object should be signed.
460///
461/// Honors `tag.gpgSign` (and `tag.gpgsign`) when `sign_override` is `None`.
462/// When `sign_override` is `Some(true)` or `Some(false)`, that choice wins over config.
463///
464/// # Errors
465///
466/// Returns [`Error::Config`] when `tag.gpgSign` holds a non-boolean value.
467pub fn should_sign_tag(config: &ConfigSet, sign_override: Option<bool>) -> Result<bool> {
468    if let Some(force) = sign_override {
469        return Ok(force);
470    }
471    Ok(config.get_bool("tag.gpgSign").transpose()?.unwrap_or(false))
472}
473
474/// Header name for an embedded commit signature for `hash_algo`.
475#[must_use]
476pub fn commit_gpg_sig_header(hash_algo: HashAlgo) -> &'static str {
477    match hash_algo {
478        HashAlgo::Sha256 => GPG_SIG_HEADER_SHA256,
479        HashAlgo::Sha1 => GPG_SIG_HEADER_SHA1,
480    }
481}
482
483/// Sign a serialized commit object (without `gpgsig`) and splice the signature header.
484///
485/// Uses [`sign_buffer`] on `unsigned_bytes` and [`add_header_signature`] with the
486/// header from [`commit_gpg_sig_header`]. The signing key comes from
487/// `signing_key_override`, else `user.signingkey`, else
488/// [`committer_signing_default`] applied to `committer_ident`.
489///
490/// # Errors
491///
492/// Returns [`Error::Signing`] when the signing program fails, and [`Error::Config`]
493/// when `GpgConfig` cannot be built from the repository config (callers typically
494/// construct [`GpgConfig`] first).
495pub fn sign_serialized_commit(
496    cfg: &GpgConfig,
497    unsigned_bytes: &[u8],
498    committer_ident: &str,
499    signing_key_override: Option<&str>,
500    hash_algo: HashAlgo,
501) -> Result<Vec<u8>> {
502    let committer_key = committer_signing_default(committer_ident);
503    let signing_key = cfg.resolve_signing_key(signing_key_override, &committer_key);
504    let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
505    let header = commit_gpg_sig_header(hash_algo);
506    Ok(add_header_signature(unsigned_bytes, &sig, header))
507}
508
509/// Append an armored detached signature to a serialized tag object.
510///
511/// Tag signatures are stored after the tag body (unlike commits, which use a
512/// `gpgsig` header). See [`parse_signed_buffer`].
513#[must_use]
514pub fn append_tag_signature(unsigned_bytes: &[u8], sig: &[u8]) -> Vec<u8> {
515    let mut out = Vec::with_capacity(unsigned_bytes.len() + sig.len());
516    out.extend_from_slice(unsigned_bytes);
517    out.extend_from_slice(sig);
518    out
519}
520
521/// Sign a serialized tag object (without an appended signature).
522///
523/// Uses [`sign_buffer`] on `unsigned_bytes` and [`append_tag_signature`]. The
524/// signing key comes from `signing_key_override`, else `user.signingkey`, else
525/// [`committer_signing_default`] applied to `tagger_ident`.
526///
527/// # Errors
528///
529/// Returns [`Error::Signing`] when the signing program fails.
530pub fn sign_serialized_tag(
531    cfg: &GpgConfig,
532    unsigned_bytes: &[u8],
533    tagger_ident: &str,
534    signing_key_override: Option<&str>,
535) -> Result<Vec<u8>> {
536    let tagger_key = committer_signing_default(tagger_ident);
537    let signing_key = cfg.resolve_signing_key(signing_key_override, &tagger_key);
538    let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
539    Ok(append_tag_signature(unsigned_bytes, &sig))
540}
541
542/// Sign `payload` with `signing_key` using the configured program.
543///
544/// Spawns `<program> --status-fd=2 -bsau <signing_key>`, writes `payload` to
545/// stdin, and returns the armored detached signature from stdout.  Fails if the
546/// child exits non-zero or does not emit a `[GNUPG:] SIG_CREATED` status line —
547/// in either case the program's stderr is surfaced in the error (the
548/// `LET_GPG_PROGRAM_FAIL`/`zOMG` path of subtest 28).
549///
550/// # Errors
551///
552/// Returns [`Error::Signing`] when the program cannot be spawned, exits
553/// non-zero, or fails to produce a signature.
554pub fn sign_buffer(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
555    if cfg.format == GpgFormat::Ssh {
556        return sign_buffer_ssh(cfg, payload, signing_key);
557    }
558
559    let program = cfg.resolve_program_path()?;
560
561    let spec = CommandSpec {
562        program: program.clone(),
563        args: vec![
564            OsString::from("--status-fd=2"),
565            OsString::from("-bsau"),
566            OsString::from(signing_key),
567        ],
568        shell: None,
569        cwd: None,
570        env: CommandEnvironment::inherit_process_only(),
571        stdin: stdin_pipe(payload),
572        stdout: CommandStdio::Pipe,
573        stderr: CommandStdio::Pipe,
574    };
575    let output = signing_spawn(cfg, spec, None)?;
576
577    let status_text = String::from_utf8_lossy(&output.stderr);
578
579    if !output.status.success || !has_sig_created(&status_text) {
580        let detail = if status_text.trim().is_empty() {
581            "(no gpg output)".to_owned()
582        } else {
583            status_text.into_owned()
584        };
585        return Err(Error::Signing(format!(
586            "gpg failed to sign the data:\n{detail}"
587        )));
588    }
589
590    Ok(output.stdout)
591}
592
593/// True when a `[GNUPG:] SIG_CREATED ` status line is present at the start of a
594/// line (Git's `sign_buffer_gpg` SIG_CREATED scan).
595fn has_sig_created(status: &str) -> bool {
596    status
597        .lines()
598        .any(|line| line.starts_with("[GNUPG:] SIG_CREATED "))
599}
600
601/// Detect a literal ssh key (Git `gpg-interface.c:is_literal_ssh_key`).
602///
603/// Returns `Some(rest)` for `key::<rest>`, `Some(s)` for a value starting with
604/// `ssh-`, else `None`.
605fn is_literal_ssh_key(s: &str) -> Option<&str> {
606    if let Some(rest) = s.strip_prefix("key::") {
607        return Some(rest);
608    }
609    if s.starts_with("ssh-") {
610        return Some(s);
611    }
612    None
613}
614
615/// Sign `payload` with an ssh key using `ssh-keygen -Y sign`.
616///
617/// Port of Git's `gpg-interface.c:sign_buffer_ssh`.  `signing_key` is either a
618/// literal public key (`key::...` or `ssh-...`) or a path to a key file
619/// (`~/` expanded).  Returns the armored `-----BEGIN SSH SIGNATURE-----` blob.
620///
621/// # Errors
622///
623/// Returns [`Error::Signing`] when `signing_key` is empty, a temp file cannot be
624/// written, `ssh-keygen` cannot be run or exits non-zero, or the `.sig` output
625/// cannot be read.
626fn sign_buffer_ssh(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
627    if signing_key.is_empty() {
628        return Err(Error::Signing(
629            "user.signingKey needs to be set for ssh signing".to_owned(),
630        ));
631    }
632
633    let program = cfg.resolve_program_path()?;
634
635    // Resolve the key file: either a literal key written to a temp file (with
636    // the `-U` flag), or a path on disk.
637    let mut literal_key_tmp: Option<PathBuf> = None;
638    let (key_file, literal): (String, bool) = match is_literal_ssh_key(signing_key) {
639        Some(literal_key) => {
640            let path = write_temp_file_named(literal_key.as_bytes(), "git_signing_key")?;
641            let p = path.to_string_lossy().into_owned();
642            literal_key_tmp = Some(path);
643            (p, true)
644        }
645        None => (expand_tilde(signing_key), false),
646    };
647
648    // Write the payload to a temp buffer file; ssh-keygen reads it as the file
649    // to sign and writes `<file>.sig` alongside it.
650    let buffer_path = match write_temp_file_named(payload, "git_signing_buffer") {
651        Ok(p) => p,
652        Err(e) => {
653            if let Some(p) = &literal_key_tmp {
654                let _ = std::fs::remove_file(p);
655            }
656            return Err(e);
657        }
658    };
659
660    let mut args = vec![
661        OsString::from("-Y"),
662        OsString::from("sign"),
663        OsString::from("-n"),
664        OsString::from("git"),
665        OsString::from("-f"),
666        OsString::from(key_file.as_str()),
667    ];
668    if literal {
669        args.push(OsString::from("-U"));
670    }
671    args.push(OsString::from(buffer_path.to_string_lossy().as_ref()));
672    let spec = CommandSpec {
673        program: program.clone(),
674        args,
675        shell: None,
676        cwd: None,
677        env: CommandEnvironment::inherit_process_only(),
678        stdin: CommandStdin::Null,
679        stdout: CommandStdio::Pipe,
680        stderr: CommandStdio::Pipe,
681    };
682
683    let cleanup = |literal_key_tmp: &Option<PathBuf>, buffer_path: &Path| {
684        if let Some(p) = literal_key_tmp {
685            let _ = std::fs::remove_file(p);
686        }
687        let _ = std::fs::remove_file(buffer_path);
688        let _ = std::fs::remove_file(sig_sibling(buffer_path));
689    };
690
691    let output = match signing_spawn(cfg, spec, None) {
692        Ok(o) => o,
693        Err(e) => {
694            cleanup(&literal_key_tmp, &buffer_path);
695            return Err(e);
696        }
697    };
698
699    if !output.status.success {
700        let stderr = String::from_utf8_lossy(&output.stderr);
701        cleanup(&literal_key_tmp, &buffer_path);
702        if stderr.contains("usage:") {
703            return Err(Error::Signing(
704                "ssh-keygen -Y sign is needed for ssh signing (available in openssh version 8.2p1+)"
705                    .to_owned(),
706            ));
707        }
708        return Err(Error::Signing(stderr.into_owned()));
709    }
710
711    let sig_path = sig_sibling(&buffer_path);
712    let result = std::fs::read(&sig_path).map_err(|e| {
713        Error::Signing(format!(
714            "failed reading ssh signing data buffer from '{}': {e}",
715            sig_path.display()
716        ))
717    });
718    cleanup(&literal_key_tmp, &buffer_path);
719
720    let mut sig = result?;
721    // Strip a trailing CR (Windows line endings) from each line, mirroring
722    // Git's `remove_cr_after`.
723    strip_cr(&mut sig);
724    Ok(sig)
725}
726
727/// The `<path>.sig` sibling file produced by `ssh-keygen -Y sign`.
728fn sig_sibling(buffer_path: &Path) -> PathBuf {
729    let mut s = buffer_path.as_os_str().to_owned();
730    s.push(".sig");
731    PathBuf::from(s)
732}
733
734/// Remove `\r` characters that immediately precede a `\n` (Git `remove_cr_after`).
735fn strip_cr(buf: &mut Vec<u8>) {
736    let mut out = Vec::with_capacity(buf.len());
737    let mut i = 0;
738    while i < buf.len() {
739        if buf[i] == b'\r' && buf.get(i + 1) == Some(&b'\n') {
740            i += 1;
741            continue;
742        }
743        out.push(buf[i]);
744        i += 1;
745    }
746    *buf = out;
747}
748
749/// Splice a signature into a serialized commit object as a `gpgsig` header.
750///
751/// Port of Git's `commit.c:add_header_signature`: find the first `\n\n`, insert
752/// the header at the position right after that first `\n`, prefix the first
753/// signature line with `<header> ` and every subsequent line with a single
754/// space.  `header` is [`GPG_SIG_HEADER_SHA1`] or [`GPG_SIG_HEADER_SHA256`].
755pub fn add_header_signature(buf: &[u8], sig: &[u8], header: &str) -> Vec<u8> {
756    // Find end of header (first occurrence of "\n\n"); inspos is just past the
757    // first '\n'. If absent, append at the end.
758    let inspos = find_double_newline(buf).map(|p| p + 1).unwrap_or(buf.len());
759
760    let mut out = Vec::with_capacity(buf.len() + sig.len() + header.len() + 16);
761    out.extend_from_slice(&buf[..inspos]);
762
763    let mut first = true;
764    let mut copypos = 0usize;
765    while copypos < sig.len() {
766        let bol = copypos;
767        // End of this line, including the trailing '\n' when present.
768        let end = match memchr(sig, copypos, b'\n') {
769            Some(idx) => idx + 1,
770            None => sig.len(),
771        };
772
773        if first {
774            out.extend_from_slice(header.as_bytes());
775            first = false;
776        }
777        out.push(b' ');
778        out.extend_from_slice(&sig[bol..end]);
779        copypos = end;
780    }
781
782    out.extend_from_slice(&buf[inspos..]);
783    out
784}
785
786/// Find the first `\n\n` in `buf`, returning the index of the first `\n`.
787fn find_double_newline(buf: &[u8]) -> Option<usize> {
788    let mut i = 0;
789    while i + 1 < buf.len() {
790        if buf[i] == b'\n' && buf[i + 1] == b'\n' {
791            return Some(i);
792        }
793        i += 1;
794    }
795    None
796}
797
798/// Find the byte `needle` in `buf` starting at `from`.
799fn memchr(buf: &[u8], from: usize, needle: u8) -> Option<usize> {
800    buf.get(from..)
801        .and_then(|s| s.iter().position(|&b| b == needle))
802        .map(|p| p + from)
803}
804
805/// A parsed signature check result.
806#[derive(Debug, Clone, Default)]
807pub struct SignatureCheck {
808    /// The detached armored signature extracted from the object.
809    pub signature: Vec<u8>,
810    /// The signed payload (object with `gpgsig` header removed).
811    pub payload: Vec<u8>,
812    /// `%G?` result: `G` good, `B` bad, `U` good+untrusted, `E` error,
813    /// `N` no signature, `X`/`Y`/`R` expired/expired-key/revoked.
814    pub result: char,
815    /// `%GT` trust level.
816    pub trust_level: TrustLevel,
817    /// `%GK` key id.
818    pub key: Option<String>,
819    /// `%GS` signer (uid).
820    pub signer: Option<String>,
821    /// `%GF` signing key fingerprint.
822    pub fingerprint: Option<String>,
823    /// `%GP` primary key fingerprint.
824    pub primary_key_fingerprint: Option<String>,
825    /// Human-readable gpg output (stderr); shown by `--show-signature`.
826    pub output: String,
827    /// Raw `[GNUPG:]` status lines; shown by `verify-commit --raw`.
828    pub gpg_status: String,
829    /// True when the underlying verifier reported failure regardless of the
830    /// parsed `%G?` result.  For ssh this captures Git's
831    /// `verify_ssh_signed_buffer` return code (e.g. an untrusted key that still
832    /// produces a `Good "git" signature with ...` line must fail verification).
833    pub verifier_failed: bool,
834}
835
836impl SignatureCheck {
837    /// Construct the "no signature" result (`%G?` -> `N`).
838    pub fn default_none() -> SignatureCheck {
839        SignatureCheck {
840            result: 'N',
841            trust_level: TrustLevel::Undefined,
842            ..Default::default()
843        }
844    }
845
846    /// True when the signature verified as good (`G`) or good-but-expired-key
847    /// (`Y`) — Git's success criterion in `check_signature`.
848    pub fn is_good(&self) -> bool {
849        self.result == 'G' || self.result == 'Y'
850    }
851
852    /// Overall verification result honoring `min_trust_level`: `Ok(())` when the
853    /// signature is good and meets the configured minimum trust level.
854    pub fn verify_status(&self, min_trust_level: Option<TrustLevel>) -> bool {
855        if self.verifier_failed {
856            return false;
857        }
858        if !self.is_good() {
859            return false;
860        }
861        if let Some(min) = min_trust_level {
862            if self.trust_level < min {
863                return false;
864            }
865        }
866        true
867    }
868}
869
870/// Extract the `gpgsig` (or `gpgsig-sha256`) header value and the signed
871/// payload from a raw commit object.
872///
873/// Returns `(payload, signature)` where `payload` is the commit object with the
874/// `gpgsig` header removed (the bytes that were actually signed), and
875/// `signature` is the de-indented armored signature.  Returns `None` when the
876/// object carries no signature header.
877pub fn extract_signed_payload(raw_commit: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
878    // Header region ends at first "\n\n".
879    let header_end = find_double_newline(raw_commit)?;
880    let header = &raw_commit[..=header_end]; // includes trailing first '\n'
881    let body = &raw_commit[header_end + 1..]; // remaining (starts with '\n')
882
883    let mut payload = Vec::with_capacity(raw_commit.len());
884    let mut signature = Vec::new();
885    let mut found = false;
886
887    let mut idx = 0;
888    while idx < header.len() {
889        let line_end = memchr(header, idx, b'\n')
890            .map(|p| p + 1)
891            .unwrap_or(header.len());
892        let line = &header[idx..line_end];
893
894        let Some((_, prefix_len)) = gpg_sig_header_line(line) else {
895            payload.extend_from_slice(line);
896            idx = line_end;
897            continue;
898        };
899
900        if !found {
901            found = true;
902            signature.extend_from_slice(&line[prefix_len..]);
903            idx = line_end;
904            // Subsequent continuation lines (leading space) belong to the sig.
905            while idx < header.len() {
906                let cont_end = memchr(header, idx, b'\n')
907                    .map(|p| p + 1)
908                    .unwrap_or(header.len());
909                let cont = &header[idx..cont_end];
910                if cont.first() == Some(&b' ') {
911                    signature.extend_from_slice(&cont[1..]);
912                    idx = cont_end;
913                } else {
914                    break;
915                }
916            }
917            continue;
918        }
919
920        payload.extend_from_slice(line);
921        idx = line_end;
922    }
923
924    if !found {
925        return None;
926    }
927
928    payload.extend_from_slice(body);
929    Some((payload, signature))
930}
931
932/// Remove embedded commit signature headers from [`crate::objects::CommitData::extra_headers`].
933///
934/// Used when amending: the rewritten object must not carry the replaced tip's
935/// `gpgsig` / `gpgsig-sha256` block; signing (if any) adds a fresh header later.
936#[must_use]
937pub fn extra_headers_without_commit_signatures(extra: &[u8]) -> Vec<u8> {
938    let mut out = Vec::with_capacity(extra.len());
939    let mut idx = 0;
940    while idx < extra.len() {
941        let line_end = memchr(extra, idx, b'\n')
942            .map(|p| p + 1)
943            .unwrap_or(extra.len());
944        let line = &extra[idx..line_end];
945
946        if gpg_sig_header_line(line).is_some() {
947            idx = line_end;
948            while idx < extra.len() {
949                let cont_end = memchr(extra, idx, b'\n')
950                    .map(|p| p + 1)
951                    .unwrap_or(extra.len());
952                let cont = &extra[idx..cont_end];
953                if cont.first() == Some(&b' ') {
954                    idx = cont_end;
955                } else {
956                    break;
957                }
958            }
959            continue;
960        }
961
962        out.extend_from_slice(line);
963        idx = line_end;
964    }
965    out
966}
967
968/// If `line` begins a `gpgsig` / `gpgsig-sha256` header, return the header name and
969/// the byte offset after `"<header> "`.
970fn gpg_sig_header_line(line: &[u8]) -> Option<(&'static str, usize)> {
971    for header in [GPG_SIG_HEADER_SHA256, GPG_SIG_HEADER_SHA1] {
972        if line.starts_with(header.as_bytes()) && line.get(header.len()) == Some(&b' ') {
973            return Some((header, header.len() + 1));
974        }
975    }
976    None
977}
978
979/// Verify a raw commit object's embedded signature.
980///
981/// Extracts the payload + signature, then (for gpg-based formats) writes the
982/// signature to a temp file and runs `<program> --status-fd=1 <verify_args>
983/// --verify <sigfile> -`, feeding the payload on stdin, and parses the
984/// `[GNUPG:]` status lines.
985pub fn verify_commit(cfg: &GpgConfig, raw_commit: &[u8]) -> Result<SignatureCheck> {
986    let (payload, signature) = match extract_signed_payload(raw_commit) {
987        Some(parts) => parts,
988        None => return Ok(SignatureCheck::default_none()),
989    };
990
991    // Git picks the verifier from the *signature* armor (`get_format_by_sig`),
992    // not from `gpg.format`: a `git verify-commit` over an ssh-signed commit must
993    // use ssh-keygen even when `gpg.format` is unset/openpgp, and vice-versa.
994    let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
995
996    if detected_format == GpgFormat::Ssh {
997        return verify_ssh_signed_buffer(cfg, payload, signature);
998    }
999
1000    let program = resolve_program(&cfg.program_for(detected_format))?;
1001
1002    // Write the detached signature to a temp file.
1003    let sig_path = write_temp_file(&signature)?;
1004
1005    let mut args = vec![OsString::from("--status-fd=1")];
1006    for a in detected_format.verify_args() {
1007        args.push(OsString::from(a));
1008    }
1009    args.push(OsString::from("--verify"));
1010    args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1011    args.push(OsString::from("-"));
1012    let spec = CommandSpec {
1013        program: program.clone(),
1014        args,
1015        shell: None,
1016        cwd: None,
1017        env: CommandEnvironment::inherit_process_only(),
1018        stdin: stdin_pipe(&payload),
1019        stdout: CommandStdio::Pipe,
1020        stderr: CommandStdio::Pipe,
1021    };
1022
1023    let output = match signing_spawn(cfg, spec, None) {
1024        Ok(o) => o,
1025        Err(e) => {
1026            let _ = std::fs::remove_file(&sig_path);
1027            return Err(e);
1028        }
1029    };
1030    let _ = std::fs::remove_file(&sig_path);
1031
1032    // status-fd=1 routes GNUPG status to stdout; human-readable goes to stderr.
1033    let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1034    let human = String::from_utf8_lossy(&output.stderr).into_owned();
1035
1036    let mut sigc = SignatureCheck {
1037        signature,
1038        payload,
1039        result: 'N',
1040        trust_level: TrustLevel::Undefined,
1041        gpg_status: gpg_status.clone(),
1042        output: human,
1043        ..Default::default()
1044    };
1045
1046    parse_gpg_output(&mut sigc, &gpg_status);
1047
1048    Ok(sigc)
1049}
1050
1051/// Parse `ssh-keygen -Y verify` human output into `sigc`
1052/// (produces the same parse as Git's `gpg-interface.c:parse_ssh_output`).
1053///
1054/// Expected first line of `sigc.output`:
1055/// * `Good "git" signature for PRINCIPAL with ... key SHA256:FINGERPRINT`
1056///   -> result `G`, trust `Fully`, signer = PRINCIPAL.
1057/// * `Good "git" signature with ... key SHA256:FINGERPRINT`
1058///   -> result `G`, trust `Undefined` (unknown key, signer unset).
1059/// * anything else -> result `B`, trust `Never`.
1060///
1061/// In the two good cases the substring after `key ` becomes both the
1062/// fingerprint (`%GF`) and the key (`%GK`); `%GP` is never set.
1063fn parse_ssh_output(sigc: &mut SignatureCheck) {
1064    sigc.result = 'B';
1065    sigc.trust_level = TrustLevel::Never;
1066    sigc.key = None;
1067    sigc.signer = None;
1068    sigc.fingerprint = None;
1069    sigc.primary_key_fingerprint = None;
1070
1071    let first_line = sigc.output.split('\n').next().unwrap_or("");
1072
1073    let after_key;
1074    if let Some(rest) = first_line.strip_prefix("Good \"git\" signature for ") {
1075        // The principal can contain whitespace; the trailing
1076        // ` with <algo> key <fpr>` is fixed, so split on the *last* " with ".
1077        match rest.rfind(" with ") {
1078            Some(idx) => {
1079                let principal = &rest[..idx];
1080                if principal.is_empty() {
1081                    return;
1082                }
1083                sigc.result = 'G';
1084                sigc.trust_level = TrustLevel::Fully;
1085                sigc.signer = Some(principal.to_owned());
1086                after_key = &rest[idx + " with ".len()..];
1087            }
1088            None => return,
1089        }
1090    } else if let Some(rest) = first_line.strip_prefix("Good \"git\" signature with ") {
1091        sigc.result = 'G';
1092        sigc.trust_level = TrustLevel::Undefined;
1093        after_key = rest;
1094    } else {
1095        return;
1096    }
1097
1098    // The fingerprint follows the literal `key ` token.
1099    match after_key.find("key ") {
1100        Some(pos) => {
1101            let fpr = after_key[pos + "key ".len()..].to_owned();
1102            sigc.fingerprint = Some(fpr.clone());
1103            sigc.key = Some(fpr);
1104        }
1105        None => {
1106            // Output did not match what we expected: treat as bad.
1107            sigc.result = 'B';
1108        }
1109    }
1110}
1111
1112/// Extract the committer (or tagger) unix timestamp from a signed payload,
1113/// porting Git's `parse_payload_metadata` for the `committer`/`tagger` header.
1114fn payload_committer_timestamp(payload: &[u8]) -> Option<u64> {
1115    let ident_line =
1116        find_header_line(payload, b"committer").or_else(|| find_header_line(payload, b"tagger"))?;
1117    let line = std::str::from_utf8(ident_line).ok()?;
1118    // Ident line: "Name <email> <timestamp> <tz>"; the timestamp is the
1119    // second-to-last whitespace-separated token.
1120    let mut it = line.split_whitespace().rev();
1121    let _tz = it.next()?;
1122    let ts = it.next()?;
1123    ts.parse::<u64>().ok()
1124}
1125
1126/// Return the bytes after `"<name> "` for the first header line matching `name`
1127/// (within the header region, i.e. before the first blank line).
1128fn find_header_line<'a>(payload: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
1129    let mut idx = 0;
1130    while idx < payload.len() {
1131        let line_end = memchr(payload, idx, b'\n').unwrap_or(payload.len());
1132        let line = &payload[idx..line_end];
1133        if line.is_empty() {
1134            // End of header region.
1135            return None;
1136        }
1137        if line.len() > name.len() + 1 && &line[..name.len()] == name && line[name.len()] == b' ' {
1138            return Some(&line[name.len() + 1..]);
1139        }
1140        idx = line_end + 1;
1141    }
1142    None
1143}
1144
1145/// Format a `-Overify-time=YYYYMMDDhhmmss` argument from a unix timestamp using
1146/// the local timezone, mirroring Git's `verify_date_mode` (DATE_STRFTIME, local).
1147fn verify_time_arg(timestamp: u64) -> String {
1148    use crate::git_date::show::{show_date, DateMode, DateModeType};
1149    let mut mode = DateMode {
1150        ty: DateModeType::Strftime,
1151        local: true,
1152        strftime_fmt: Some("%Y%m%d%H%M%S".to_owned()),
1153    };
1154    let formatted = show_date(timestamp, 0, &mut mode);
1155    format!("-Overify-time={formatted}")
1156}
1157
1158/// Verify an ssh-signed `payload` against `signature` using `ssh-keygen -Y`.
1159///
1160/// Port of Git's `gpg-interface.c:verify_ssh_signed_buffer`.  Requires
1161/// `gpg.ssh.allowedSignersFile`; runs `find-principals`, then either
1162/// `check-novalidate` (no principal matched -> untrusted) or `verify` for each
1163/// matched principal.  Populates `sigc.output`/`sigc.gpg_status` and parses them
1164/// via [`parse_ssh_output`].
1165fn verify_ssh_signed_buffer(
1166    cfg: &GpgConfig,
1167    payload: Vec<u8>,
1168    signature: Vec<u8>,
1169) -> Result<SignatureCheck> {
1170    let mut sigc = SignatureCheck {
1171        signature: signature.clone(),
1172        payload: payload.clone(),
1173        result: 'N',
1174        trust_level: TrustLevel::Undefined,
1175        ..Default::default()
1176    };
1177
1178    let allowed = match &cfg.ssh_allowed_signers {
1179        Some(a) if !a.is_empty() => a.clone(),
1180        _ => {
1181            sigc.result = 'B';
1182            sigc.trust_level = TrustLevel::Never;
1183            sigc.output = "gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification".to_owned();
1184            sigc.gpg_status = sigc.output.clone();
1185            return Ok(sigc);
1186        }
1187    };
1188
1189    // The format here is detected from the signature armor, which may differ
1190    // from `cfg.format`; always resolve the ssh program (`gpg.ssh.program` /
1191    // `gpg.program` / `ssh-keygen`).
1192    let program = resolve_program(&cfg.program_for(GpgFormat::Ssh))?;
1193
1194    // Write the detached signature to a temp `.git_vtag` file.
1195    let sig_path = write_temp_file_named(&signature, "git_vtag")?;
1196
1197    let verify_time = payload_committer_timestamp(&payload).map(verify_time_arg);
1198
1199    // 1. find-principals: which allowed principals can verify this signature?
1200    let mut find_args = vec![
1201        OsString::from("-Y"),
1202        OsString::from("find-principals"),
1203        OsString::from("-f"),
1204        OsString::from(allowed.as_str()),
1205        OsString::from("-s"),
1206        OsString::from(sig_path.to_string_lossy().as_ref()),
1207    ];
1208    if let Some(vt) = &verify_time {
1209        find_args.push(OsString::from(vt.as_str()));
1210    }
1211    let find_spec = CommandSpec {
1212        program: program.clone(),
1213        args: find_args,
1214        shell: None,
1215        cwd: None,
1216        env: CommandEnvironment::inherit_process_only(),
1217        stdin: CommandStdin::Null,
1218        stdout: CommandStdio::Pipe,
1219        stderr: CommandStdio::Pipe,
1220    };
1221    let find_out = signing_spawn(cfg, find_spec, None).inspect_err(|_| {
1222        let _ = std::fs::remove_file(&sig_path);
1223    })?;
1224
1225    let find_stdout = String::from_utf8_lossy(&find_out.stdout).into_owned();
1226    let find_stderr = String::from_utf8_lossy(&find_out.stderr).into_owned();
1227
1228    if !find_out.status.success && find_stderr.contains("usage:") {
1229        let _ = std::fs::remove_file(&sig_path);
1230        return Err(Error::Signing(
1231            "ssh-keygen -Y find-principals/verify is needed for ssh signature verification (available in openssh version 8.2p1+)"
1232                .to_owned(),
1233        ));
1234    }
1235
1236    let mut verify_stdout = String::new();
1237    let mut verify_stderr = String::new();
1238    // Tracks Git's `ret` in verify_ssh_signed_buffer: true means failure.
1239    let mut verifier_failed;
1240
1241    if !find_out.status.success || find_stdout.trim().is_empty() {
1242        // No matching principal: run check-novalidate to surface signature info,
1243        // but treat as untrusted (Git forces ret = -1).
1244        let mut check_args = vec![
1245            OsString::from("-Y"),
1246            OsString::from("check-novalidate"),
1247            OsString::from("-n"),
1248            OsString::from("git"),
1249            OsString::from("-s"),
1250            OsString::from(sig_path.to_string_lossy().as_ref()),
1251        ];
1252        if let Some(vt) = &verify_time {
1253            check_args.push(OsString::from(vt.as_str()));
1254        }
1255        let (out, err) = run_with_stdin(cfg, &program, check_args, &payload);
1256        verify_stdout = out;
1257        verify_stderr = err;
1258        verifier_failed = true;
1259    } else {
1260        // Try each matched principal until one verifies as Good.
1261        verifier_failed = true;
1262        for principal in find_stdout.lines() {
1263            let principal = principal.trim_end_matches('\r');
1264            if principal.is_empty() {
1265                continue;
1266            }
1267            let mut verify_args = vec![
1268                OsString::from("-Y"),
1269                OsString::from("verify"),
1270                OsString::from("-n"),
1271                OsString::from("git"),
1272                OsString::from("-f"),
1273                OsString::from(allowed.as_str()),
1274                OsString::from("-I"),
1275                OsString::from(principal),
1276                OsString::from("-s"),
1277                OsString::from(sig_path.to_string_lossy().as_ref()),
1278            ];
1279            if let Some(vt) = &verify_time {
1280                verify_args.push(OsString::from(vt.as_str()));
1281            }
1282            if let Some(rev) = &cfg.ssh_revocation_file {
1283                if Path::new(rev).exists() {
1284                    verify_args.push(OsString::from("-r"));
1285                    verify_args.push(OsString::from(rev.as_str()));
1286                }
1287            }
1288            let (out, err, ok) = run_with_stdin_status(cfg, &program, verify_args, &payload);
1289            verify_stdout = out;
1290            verify_stderr = err;
1291            // Git: ret = !ok; if !ret { ret = !starts_with("Good"); }
1292            verifier_failed = !(ok && verify_stdout.starts_with("Good"));
1293            if !verifier_failed {
1294                break;
1295            }
1296        }
1297    }
1298
1299    let _ = std::fs::remove_file(&sig_path);
1300
1301    // Build sigc.output exactly as Git: stripspace the ssh stdout and stderr
1302    // (each non-empty line keeps a trailing newline), then append the
1303    // find-principals stderr and the verify/check stderr (gpg-interface.c
1304    // 601-608). The trailing newline left by stripspace keeps the `Good "..."`
1305    // line separate from any appended `No principal matched.` text so
1306    // parse_ssh_output sees a clean first line.
1307    let mut output = stripspace(&verify_stdout);
1308    let verify_stderr = stripspace(&verify_stderr);
1309    output.push_str(&find_stderr);
1310    output.push_str(&verify_stderr);
1311
1312    sigc.output = output;
1313    sigc.gpg_status = sigc.output.clone();
1314    parse_ssh_output(&mut sigc);
1315    // Git combines the verifier return code with the parsed result/trust; the
1316    // parse already drives result/trust, so just carry the verifier failure.
1317    sigc.verifier_failed = verifier_failed;
1318
1319    Ok(sigc)
1320}
1321
1322/// Run `program` with `args`, feeding `input` on stdin.
1323fn run_with_stdin(
1324    cfg: &GpgConfig,
1325    program: &Path,
1326    args: Vec<OsString>,
1327    input: &[u8],
1328) -> (String, String) {
1329    let (out, err, _ok) = run_with_stdin_status(cfg, program, args, input);
1330    (out, err)
1331}
1332
1333/// Like [`run_with_stdin`] but also returns whether the child exited zero.
1334fn run_with_stdin_status(
1335    cfg: &GpgConfig,
1336    program: &Path,
1337    args: Vec<OsString>,
1338    input: &[u8],
1339) -> (String, String, bool) {
1340    let spec = CommandSpec {
1341        program: program.to_path_buf(),
1342        args,
1343        shell: None,
1344        cwd: None,
1345        env: CommandEnvironment::inherit_process_only(),
1346        stdin: stdin_pipe(input),
1347        stdout: CommandStdio::Pipe,
1348        stderr: CommandStdio::Pipe,
1349    };
1350    match signing_spawn(cfg, spec, None) {
1351        Ok(o) => (
1352            String::from_utf8_lossy(&o.stdout).into_owned(),
1353            String::from_utf8_lossy(&o.stderr).into_owned(),
1354            o.status.success,
1355        ),
1356        Err(_) => (String::new(), String::new(), false),
1357    }
1358}
1359
1360/// Port of Git's `strbuf_stripspace` (without comment handling): trim trailing
1361/// whitespace from each line, collapse runs of blank lines to a single blank
1362/// line, and terminate every non-empty line with a single `\n`. The retained
1363/// trailing newline is what keeps the ssh `Good "..."` line separate from the
1364/// appended `No principal matched.` stderr.
1365fn stripspace(s: &str) -> String {
1366    let mut out = String::with_capacity(s.len());
1367    let mut pending_empties = 0usize;
1368    let mut wrote_any = false;
1369    for line in s.split('\n') {
1370        let trimmed = line.trim_end_matches([' ', '\t', '\r']);
1371        if trimmed.is_empty() {
1372            pending_empties += 1;
1373            continue;
1374        }
1375        if pending_empties > 0 && wrote_any {
1376            out.push('\n');
1377        }
1378        pending_empties = 0;
1379        out.push_str(trimmed);
1380        out.push('\n');
1381        wrote_any = true;
1382    }
1383    out
1384}
1385
1386/// Parse `[GNUPG:]` status lines into `sigc` (port of `parse_gpg_output`).
1387fn parse_gpg_output(sigc: &mut SignatureCheck, status: &str) {
1388    // (result-char, prefix, exclusive, keyid, uid, fingerprint, trust)
1389    struct Entry {
1390        result: Option<char>,
1391        check: &'static str,
1392        exclusive: bool,
1393        keyid: bool,
1394        uid: bool,
1395        fingerprint: bool,
1396        trust: bool,
1397    }
1398    const TABLE: &[Entry] = &[
1399        Entry {
1400            result: Some('G'),
1401            check: "GOODSIG ",
1402            exclusive: true,
1403            keyid: true,
1404            uid: true,
1405            fingerprint: false,
1406            trust: false,
1407        },
1408        Entry {
1409            result: Some('B'),
1410            check: "BADSIG ",
1411            exclusive: true,
1412            keyid: true,
1413            uid: true,
1414            fingerprint: false,
1415            trust: false,
1416        },
1417        Entry {
1418            result: Some('E'),
1419            check: "ERRSIG ",
1420            exclusive: true,
1421            keyid: true,
1422            uid: false,
1423            fingerprint: false,
1424            trust: false,
1425        },
1426        Entry {
1427            result: Some('X'),
1428            check: "EXPSIG ",
1429            exclusive: true,
1430            keyid: true,
1431            uid: true,
1432            fingerprint: false,
1433            trust: false,
1434        },
1435        Entry {
1436            result: Some('Y'),
1437            check: "EXPKEYSIG ",
1438            exclusive: true,
1439            keyid: true,
1440            uid: true,
1441            fingerprint: false,
1442            trust: false,
1443        },
1444        Entry {
1445            result: Some('R'),
1446            check: "REVKEYSIG ",
1447            exclusive: true,
1448            keyid: true,
1449            uid: true,
1450            fingerprint: false,
1451            trust: false,
1452        },
1453        Entry {
1454            result: None,
1455            check: "VALIDSIG ",
1456            exclusive: false,
1457            keyid: false,
1458            uid: false,
1459            fingerprint: true,
1460            trust: false,
1461        },
1462        Entry {
1463            result: None,
1464            check: "TRUST_",
1465            exclusive: false,
1466            keyid: false,
1467            uid: false,
1468            fingerprint: false,
1469            trust: true,
1470        },
1471    ];
1472
1473    let mut seen_exclusive = false;
1474
1475    for raw_line in status.lines() {
1476        let line = match raw_line.strip_prefix("[GNUPG:] ") {
1477            Some(l) => l,
1478            None => continue,
1479        };
1480
1481        for entry in TABLE {
1482            let rest = match line.strip_prefix(entry.check) {
1483                Some(r) => r,
1484                None => continue,
1485            };
1486
1487            if entry.exclusive {
1488                if seen_exclusive {
1489                    // Multiple exclusive statuses => multiple signatures: reject.
1490                    error_reset(sigc);
1491                    return;
1492                }
1493                seen_exclusive = true;
1494            }
1495
1496            if let Some(r) = entry.result {
1497                sigc.result = r;
1498            }
1499
1500            let mut cursor = rest;
1501
1502            if entry.keyid {
1503                let (key, after) = split_at_space(cursor);
1504                sigc.key = Some(key.to_owned());
1505                if entry.uid && !after.is_empty() {
1506                    // signer is the rest of the line.
1507                    let signer = after.split('\n').next().unwrap_or("");
1508                    sigc.signer = Some(signer.to_owned());
1509                }
1510            }
1511
1512            if entry.trust {
1513                let level: String = cursor
1514                    .chars()
1515                    .take_while(|&c| c != ' ' && c != '\n')
1516                    .collect();
1517                match TrustLevel::from_status(&level) {
1518                    Some(t) => sigc.trust_level = t,
1519                    None => {
1520                        error_reset(sigc);
1521                        return;
1522                    }
1523                }
1524            }
1525
1526            if entry.fingerprint {
1527                // VALIDSIG <fingerprint> ... <primary-fingerprint>
1528                let (fpr, mut after) = split_at_space(cursor);
1529                sigc.fingerprint = Some(fpr.to_owned());
1530                // Skip 9 interim fields to reach the primary fingerprint.
1531                cursor = after;
1532                let mut remaining = 9;
1533                while remaining > 0 && !cursor.is_empty() {
1534                    let (_, next) = split_at_space(cursor);
1535                    after = next;
1536                    if after.is_empty() {
1537                        break;
1538                    }
1539                    cursor = after;
1540                    remaining -= 1;
1541                }
1542                if remaining == 0 {
1543                    let primary = cursor.split('\n').next().unwrap_or("");
1544                    sigc.primary_key_fingerprint = Some(primary.to_owned());
1545                }
1546            }
1547
1548            break;
1549        }
1550    }
1551}
1552
1553/// Reset `sigc` to the error state, clearing partial fields.
1554fn error_reset(sigc: &mut SignatureCheck) {
1555    sigc.result = 'E';
1556    sigc.primary_key_fingerprint = None;
1557    sigc.fingerprint = None;
1558    sigc.signer = None;
1559    sigc.key = None;
1560}
1561
1562/// Split `s` at the first space, returning `(before, after_space)`.
1563fn split_at_space(s: &str) -> (&str, &str) {
1564    match s.find(' ') {
1565        Some(i) => (&s[..i], &s[i + 1..]),
1566        None => (s, ""),
1567    }
1568}
1569
1570/// Write `data` to a fresh temp file and return its path.
1571fn write_temp_file(data: &[u8]) -> Result<PathBuf> {
1572    write_temp_file_named(data, "git_vtag")
1573}
1574
1575/// Build a fresh, reasonably unique temp path with the given name stem (without
1576/// creating the file).
1577fn temp_file_path(stem: &str) -> PathBuf {
1578    let dir = std::env::temp_dir();
1579    let unique = format!("{stem}_{}_{}", std::process::id(), next_temp_counter());
1580    dir.join(unique)
1581}
1582
1583/// Write `data` to a fresh temp file named with `stem` and return its path.
1584fn write_temp_file_named(data: &[u8], stem: &str) -> Result<PathBuf> {
1585    let path = temp_file_path(stem);
1586    let mut f = std::fs::File::create(&path)
1587        .map_err(|e| Error::Signing(format!("could not create temporary file: {e}")))?;
1588    f.write_all(data)
1589        .map_err(|e| Error::Signing(format!("failed writing to temporary file: {e}")))?;
1590    Ok(path)
1591}
1592
1593/// Monotonic counter used to disambiguate temp file names within a process.
1594fn next_temp_counter() -> u64 {
1595    use std::sync::atomic::{AtomicU64, Ordering};
1596    static COUNTER: AtomicU64 = AtomicU64::new(0);
1597    let now = crate::git_date::tm::process_wall_clock_sec() as u64;
1598    now ^ COUNTER.fetch_add(1, Ordering::Relaxed)
1599}
1600
1601/// Build the committer-info default signing key (Git's
1602/// `git_committer_info(IDENT_STRICT | IDENT_NO_DATE)` — `"Name <email>"`).
1603///
1604/// `committer_ident` is a full ident line (`"Name <email> <ts> <tz>"`); this
1605/// trims the trailing timestamp/timezone.
1606pub fn committer_signing_default(committer_ident: &str) -> String {
1607    if let Some(angle_end) = committer_ident.find('>') {
1608        committer_ident[..=angle_end].to_owned()
1609    } else {
1610        committer_ident.to_owned()
1611    }
1612}
1613
1614/// Split a signed buffer (e.g. a tag object) into `(payload, signature)`.
1615///
1616/// Port of Git's `gpg-interface.c:parse_signed_buffer`/`parse_signature`: scans
1617/// the buffer line by line and records the offset of the *last* line that begins
1618/// a recognized signature armor ([`GpgFormat::from_signature`]).  The payload is
1619/// everything before that offset and the signature is everything from it to the
1620/// end.  Unlike commits, a signed tag appends the armored signature directly
1621/// after the tag body with no `gpgsig` header and no per-line indentation.
1622///
1623/// Returns `None` when no armor line is found (the buffer is unsigned).
1624pub fn parse_signed_buffer(buf: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
1625    let size = buf.len();
1626    let mut len = 0usize;
1627    let mut matched: Option<usize> = None;
1628    while len < size {
1629        if GpgFormat::from_signature(&buf[len..]).is_some() {
1630            matched = Some(len);
1631        }
1632        let eol = memchr(buf, len, b'\n');
1633        len = match eol {
1634            Some(p) => p + 1,
1635            None => size,
1636        };
1637    }
1638    let m = matched?;
1639    Some((buf[..m].to_vec(), buf[m..].to_vec()))
1640}
1641
1642/// Verify a raw tag object's appended signature.
1643///
1644/// Mirrors [`verify_commit`] but uses [`parse_signed_buffer`] (tag signatures are
1645/// appended, not stored in a `gpgsig` header).  The verifier is chosen from the
1646/// signature armor (`get_format_by_sig`), reusing the internal `verify_ssh_signed_buffer` helper
1647/// for ssh and the gpg/gpgsm path otherwise.  Returns the "no signature" result
1648/// when the tag carries no signature.
1649pub fn verify_tag(cfg: &GpgConfig, raw_tag: &[u8]) -> Result<SignatureCheck> {
1650    let (payload, signature) = match parse_signed_buffer(raw_tag) {
1651        Some(parts) => parts,
1652        None => return Ok(SignatureCheck::default_none()),
1653    };
1654
1655    let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
1656
1657    if detected_format == GpgFormat::Ssh {
1658        return verify_ssh_signed_buffer(cfg, payload, signature);
1659    }
1660
1661    let program = resolve_program(&cfg.program_for(detected_format))?;
1662
1663    let sig_path = write_temp_file(&signature)?;
1664
1665    let mut args = vec![OsString::from("--status-fd=1")];
1666    for a in detected_format.verify_args() {
1667        args.push(OsString::from(a));
1668    }
1669    args.push(OsString::from("--verify"));
1670    args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1671    args.push(OsString::from("-"));
1672    let spec = CommandSpec {
1673        program: program.clone(),
1674        args,
1675        shell: None,
1676        cwd: None,
1677        env: CommandEnvironment::inherit_process_only(),
1678        stdin: stdin_pipe(&payload),
1679        stdout: CommandStdio::Pipe,
1680        stderr: CommandStdio::Pipe,
1681    };
1682
1683    let output = match signing_spawn(cfg, spec, None) {
1684        Ok(o) => o,
1685        Err(e) => {
1686            let _ = std::fs::remove_file(&sig_path);
1687            return Err(e);
1688        }
1689    };
1690    let _ = std::fs::remove_file(&sig_path);
1691
1692    let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1693    let human = String::from_utf8_lossy(&output.stderr).into_owned();
1694
1695    let mut sigc = SignatureCheck {
1696        signature,
1697        payload,
1698        result: 'N',
1699        trust_level: TrustLevel::Undefined,
1700        gpg_status: gpg_status.clone(),
1701        output: human,
1702        ..Default::default()
1703    };
1704
1705    parse_gpg_output(&mut sigc, &gpg_status);
1706
1707    Ok(sigc)
1708}
1709
1710#[cfg(test)]
1711mod tests {
1712    use super::*;
1713
1714    #[test]
1715    fn format_name_is_case_sensitive() {
1716        assert_eq!(GpgFormat::from_name("openpgp"), Some(GpgFormat::OpenPgp));
1717        assert_eq!(GpgFormat::from_name("x509"), Some(GpgFormat::X509));
1718        assert_eq!(GpgFormat::from_name("ssh"), Some(GpgFormat::Ssh));
1719        assert_eq!(GpgFormat::from_name("OpEnPgP"), None);
1720        assert_eq!(GpgFormat::from_name("OPENPGP"), None);
1721    }
1722
1723    #[test]
1724    fn add_header_signature_splices_gpgsig() {
1725        let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1726        let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1727        let out = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1728        let text = String::from_utf8(out).unwrap();
1729        assert!(text.contains("\ncommitter c\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n\nmessage\n"));
1730    }
1731
1732    #[test]
1733    fn extract_round_trips_signature() {
1734        let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1735        let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1736        let signed = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1737        let (payload, extracted) = extract_signed_payload(&signed).unwrap();
1738        assert_eq!(payload, commit);
1739        assert_eq!(extracted, sig);
1740    }
1741
1742    #[test]
1743    fn extract_none_when_unsigned() {
1744        let commit = b"tree 0123\ncommitter c\n\nmsg\n";
1745        assert!(extract_signed_payload(commit).is_none());
1746    }
1747
1748    #[test]
1749    fn extra_headers_without_commit_signatures_drops_gpgsig_block() {
1750        let extra = b"mergetag foo\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n";
1751        let stripped = extra_headers_without_commit_signatures(extra);
1752        assert_eq!(stripped, b"mergetag foo\n");
1753    }
1754
1755    #[test]
1756    fn parse_signed_buffer_splits_appended_tag_signature() {
1757        // A signed tag appends the armored signature directly after the body
1758        // with no `gpgsig` header and no per-line indentation.
1759        let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1760        let sig = b"-----BEGIN SSH SIGNATURE-----\nAAAA\n-----END SSH SIGNATURE-----\n";
1761        let mut tag = body.to_vec();
1762        tag.extend_from_slice(sig);
1763        let (payload, signature) = parse_signed_buffer(&tag).expect("should split");
1764        assert_eq!(payload, body);
1765        assert_eq!(signature, sig);
1766    }
1767
1768    #[test]
1769    fn parse_signed_buffer_none_when_unsigned() {
1770        let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1771        assert!(parse_signed_buffer(body).is_none());
1772    }
1773
1774    #[test]
1775    fn parse_goodsig_and_trust() {
1776        let status = "\
1777[GNUPG:] NEWSIG\n\
1778[GNUPG:] GOODSIG 73D758744BE721698EC54E8713D758744BE7216 C O Mitter <committer@example.com>\n\
1779[GNUPG:] VALIDSIG FINGERPRINT 2010-04-01 1270074988 0 4 0 17 2 00 PRIMARYFPR\n\
1780[GNUPG:] TRUST_ULTIMATE 0 pgp\n";
1781        let mut sigc = SignatureCheck::default_none();
1782        parse_gpg_output(&mut sigc, status);
1783        assert_eq!(sigc.result, 'G');
1784        assert_eq!(sigc.trust_level, TrustLevel::Ultimate);
1785        assert_eq!(
1786            sigc.signer.as_deref(),
1787            Some("C O Mitter <committer@example.com>")
1788        );
1789        assert_eq!(
1790            sigc.key.as_deref(),
1791            Some("73D758744BE721698EC54E8713D758744BE7216")
1792        );
1793        assert!(sigc.verify_status(None));
1794        assert!(sigc.verify_status(Some(TrustLevel::Ultimate)));
1795        assert!(sigc.verify_status(Some(TrustLevel::Marginal)));
1796    }
1797
1798    #[test]
1799    fn parse_badsig() {
1800        let status = "[GNUPG:] BADSIG KEYID Some Signer <s@example.com>\n";
1801        let mut sigc = SignatureCheck::default_none();
1802        parse_gpg_output(&mut sigc, status);
1803        assert_eq!(sigc.result, 'B');
1804        assert!(!sigc.is_good());
1805    }
1806
1807    #[test]
1808    fn double_exclusive_status_is_error() {
1809        let status = "[GNUPG:] GOODSIG K1 A <a@x>\n[GNUPG:] BADSIG K2 B <b@x>\n";
1810        let mut sigc = SignatureCheck::default_none();
1811        parse_gpg_output(&mut sigc, status);
1812        assert_eq!(sigc.result, 'E');
1813    }
1814
1815    #[test]
1816    fn min_trust_level_from_config_is_case_insensitive() {
1817        assert_eq!(
1818            TrustLevel::from_config("marginal"),
1819            Some(TrustLevel::Marginal)
1820        );
1821        assert_eq!(TrustLevel::from_config("FULLY"), Some(TrustLevel::Fully));
1822        assert_eq!(TrustLevel::from_config("bogus"), None);
1823    }
1824
1825    #[test]
1826    fn format_detected_from_signature_armor() {
1827        assert_eq!(
1828            GpgFormat::from_signature(b"-----BEGIN SSH SIGNATURE-----\nABC\n"),
1829            Some(GpgFormat::Ssh)
1830        );
1831        assert_eq!(
1832            GpgFormat::from_signature(b"-----BEGIN PGP SIGNATURE-----\n"),
1833            Some(GpgFormat::OpenPgp)
1834        );
1835        assert_eq!(
1836            GpgFormat::from_signature(b"-----BEGIN PGP MESSAGE-----\n"),
1837            Some(GpgFormat::OpenPgp)
1838        );
1839        assert_eq!(
1840            GpgFormat::from_signature(b"-----BEGIN SIGNED MESSAGE-----\n"),
1841            Some(GpgFormat::X509)
1842        );
1843        assert_eq!(GpgFormat::from_signature(b"garbage"), None);
1844    }
1845
1846    #[test]
1847    fn should_sign_commit_honors_override_and_config() {
1848        let mut cfg = ConfigSet::default();
1849        assert!(!should_sign_commit(&cfg, None).unwrap());
1850        assert!(should_sign_commit(&cfg, Some(true)).unwrap());
1851        assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1852
1853        cfg.add_command_override("commit.gpgsign", "true").unwrap();
1854        assert!(should_sign_commit(&cfg, None).unwrap());
1855        assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1856    }
1857
1858    #[test]
1859    fn should_sign_tag_honors_override_and_config() {
1860        let mut cfg = ConfigSet::default();
1861        assert!(!should_sign_tag(&cfg, None).unwrap());
1862        assert!(should_sign_tag(&cfg, Some(true)).unwrap());
1863        assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1864
1865        cfg.add_command_override("tag.gpgSign", "true").unwrap();
1866        assert!(should_sign_tag(&cfg, None).unwrap());
1867        assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1868    }
1869
1870    #[test]
1871    fn should_sign_rejects_a_non_boolean_naming_the_key() {
1872        let mut cfg = ConfigSet::default();
1873        cfg.add_command_override("tag.gpgsign", "sometimes")
1874            .unwrap();
1875        let err = should_sign_tag(&cfg, None).unwrap_err();
1876        assert!(matches!(
1877            err,
1878            Error::Config(crate::error::ConfigError::InvalidValue { ref key, .. }) if key == "tag.gpgSign"
1879        ));
1880        assert!(should_sign_tag(&cfg, Some(true)).unwrap());
1881    }
1882
1883    #[test]
1884    fn append_tag_signature_concatenates_armor() {
1885        let body = b"object 0\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmsg\n";
1886        let sig = b"-----BEGIN SSH SIGNATURE-----\nX\n-----END SSH SIGNATURE-----\n";
1887        let out = append_tag_signature(body, sig);
1888        assert_eq!(out.len(), body.len() + sig.len());
1889        assert_eq!(&out[..body.len()], body);
1890        assert_eq!(&out[body.len()..], sig);
1891    }
1892
1893    #[test]
1894    fn committer_signing_default_strips_timestamp() {
1895        assert_eq!(
1896            committer_signing_default("T <t@example.com> 1700000000 +0000"),
1897            "T <t@example.com>"
1898        );
1899    }
1900
1901    #[test]
1902    fn resolve_signing_key_uses_no_date_committer_default() {
1903        let cfg = GpgConfig::from_config(&ConfigSet::default()).expect("gpg config");
1904        let key = cfg.resolve_signing_key(
1905            None,
1906            &committer_signing_default("T <t@example.com> 1700000000 +0000"),
1907        );
1908        assert_eq!(key, "T <t@example.com>");
1909    }
1910
1911    #[test]
1912    fn literal_ssh_key_detection() {
1913        assert_eq!(
1914            is_literal_ssh_key("key::ssh-ed25519 AAAA"),
1915            Some("ssh-ed25519 AAAA")
1916        );
1917        assert_eq!(
1918            is_literal_ssh_key("ssh-ed25519 AAAA"),
1919            Some("ssh-ed25519 AAAA")
1920        );
1921        assert_eq!(is_literal_ssh_key("/home/u/.ssh/id_ed25519"), None);
1922    }
1923
1924    #[test]
1925    fn parse_ssh_output_trusted_principal() {
1926        let mut sigc = SignatureCheck::default_none();
1927        sigc.output =
1928            "Good \"git\" signature for principal with number 1 with ED25519 key SHA256:ABC\n"
1929                .to_owned();
1930        parse_ssh_output(&mut sigc);
1931        assert_eq!(sigc.result, 'G');
1932        assert_eq!(sigc.trust_level, TrustLevel::Fully);
1933        assert_eq!(sigc.signer.as_deref(), Some("principal with number 1"));
1934        assert_eq!(sigc.key.as_deref(), Some("SHA256:ABC"));
1935        assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:ABC"));
1936        assert!(sigc.primary_key_fingerprint.is_none());
1937    }
1938
1939    #[test]
1940    fn parse_ssh_output_untrusted_unknown_key() {
1941        let mut sigc = SignatureCheck::default_none();
1942        // The trailing `No principal matched.` is appended on its own line by
1943        // stripspace; only the first line should be parsed.
1944        sigc.output = "Good \"git\" signature with ED25519 key SHA256:XYZ\nNo principal matched.\n"
1945            .to_owned();
1946        parse_ssh_output(&mut sigc);
1947        assert_eq!(sigc.result, 'G');
1948        assert_eq!(sigc.trust_level, TrustLevel::Undefined);
1949        assert!(sigc.signer.is_none());
1950        assert_eq!(sigc.key.as_deref(), Some("SHA256:XYZ"));
1951        assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:XYZ"));
1952    }
1953
1954    #[test]
1955    fn parse_ssh_output_bad_signature() {
1956        let mut sigc = SignatureCheck::default_none();
1957        sigc.output = "Signature verification failed: incorrect signature\n".to_owned();
1958        parse_ssh_output(&mut sigc);
1959        assert_eq!(sigc.result, 'B');
1960        assert_eq!(sigc.trust_level, TrustLevel::Never);
1961        assert!(sigc.key.is_none());
1962    }
1963
1964    #[test]
1965    fn stripspace_keeps_line_terminators() {
1966        // Each non-empty line keeps a trailing newline; trailing blanks dropped.
1967        assert_eq!(stripspace("Good ... SHA256:FPR\n"), "Good ... SHA256:FPR\n");
1968        assert_eq!(stripspace("a  \n\n\nb\n\n"), "a\n\nb\n");
1969        assert_eq!(stripspace(""), "");
1970        // Concatenating a stripspaced line with following stderr keeps them on
1971        // separate lines.
1972        let mut out = stripspace("Good ... SHA256:FPR\n");
1973        out.push_str("No principal matched.\n");
1974        assert_eq!(out.lines().next(), Some("Good ... SHA256:FPR"));
1975    }
1976
1977    #[test]
1978    fn payload_committer_timestamp_parsed() {
1979        let payload =
1980            b"tree 0123\nauthor A <a@x> 1112912173 -0700\ncommitter C <c@x> 1112912273 +0200\n\nmsg\n";
1981        assert_eq!(payload_committer_timestamp(payload), Some(1112912273));
1982        // Falls back to tagger for tag payloads.
1983        let tag = b"object 0123\ntype commit\ntag v1\ntagger T <t@x> 1112912000 -0500\n\nmsg\n";
1984        assert_eq!(payload_committer_timestamp(tag), Some(1112912000));
1985        // No ident header.
1986        assert_eq!(payload_committer_timestamp(b"tree 0123\n\nmsg\n"), None);
1987    }
1988}