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    match config
454        .get_bool("commit.gpgsign")
455        .or_else(|| config.get_bool("commit.gpgSign"))
456    {
457        Some(Ok(v)) => Ok(v),
458        Some(Err(e)) => Err(Error::Config(format!("commit.gpgsign: {e}").into())),
459        None => Ok(false),
460    }
461}
462
463/// Whether a new annotated tag object should be signed.
464///
465/// Honors `tag.gpgSign` (and `tag.gpgsign`) when `sign_override` is `None`.
466/// When `sign_override` is `Some(true)` or `Some(false)`, that choice wins over config.
467///
468/// # Errors
469///
470/// Returns [`Error::Config`] when `tag.gpgSign` holds a non-boolean value.
471pub fn should_sign_tag(config: &ConfigSet, sign_override: Option<bool>) -> Result<bool> {
472    if let Some(force) = sign_override {
473        return Ok(force);
474    }
475    match config
476        .get_bool("tag.gpgSign")
477        .or_else(|| config.get_bool("tag.gpgsign"))
478    {
479        Some(Ok(v)) => Ok(v),
480        Some(Err(e)) => Err(Error::Config(format!("tag.gpgSign: {e}").into())),
481        None => Ok(false),
482    }
483}
484
485/// Header name for an embedded commit signature for `hash_algo`.
486#[must_use]
487pub fn commit_gpg_sig_header(hash_algo: HashAlgo) -> &'static str {
488    match hash_algo {
489        HashAlgo::Sha256 => GPG_SIG_HEADER_SHA256,
490        HashAlgo::Sha1 => GPG_SIG_HEADER_SHA1,
491    }
492}
493
494/// Sign a serialized commit object (without `gpgsig`) and splice the signature header.
495///
496/// Uses [`sign_buffer`] on `unsigned_bytes` and [`add_header_signature`] with the
497/// header from [`commit_gpg_sig_header`]. The signing key comes from
498/// `signing_key_override`, else `user.signingkey`, else
499/// [`committer_signing_default`] applied to `committer_ident`.
500///
501/// # Errors
502///
503/// Returns [`Error::Signing`] when the signing program fails, and [`Error::Config`]
504/// when `GpgConfig` cannot be built from the repository config (callers typically
505/// construct [`GpgConfig`] first).
506pub fn sign_serialized_commit(
507    cfg: &GpgConfig,
508    unsigned_bytes: &[u8],
509    committer_ident: &str,
510    signing_key_override: Option<&str>,
511    hash_algo: HashAlgo,
512) -> Result<Vec<u8>> {
513    let committer_key = committer_signing_default(committer_ident);
514    let signing_key = cfg.resolve_signing_key(signing_key_override, &committer_key);
515    let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
516    let header = commit_gpg_sig_header(hash_algo);
517    Ok(add_header_signature(unsigned_bytes, &sig, header))
518}
519
520/// Append an armored detached signature to a serialized tag object.
521///
522/// Tag signatures are stored after the tag body (unlike commits, which use a
523/// `gpgsig` header). See [`parse_signed_buffer`].
524#[must_use]
525pub fn append_tag_signature(unsigned_bytes: &[u8], sig: &[u8]) -> Vec<u8> {
526    let mut out = Vec::with_capacity(unsigned_bytes.len() + sig.len());
527    out.extend_from_slice(unsigned_bytes);
528    out.extend_from_slice(sig);
529    out
530}
531
532/// Sign a serialized tag object (without an appended signature).
533///
534/// Uses [`sign_buffer`] on `unsigned_bytes` and [`append_tag_signature`]. The
535/// signing key comes from `signing_key_override`, else `user.signingkey`, else
536/// [`committer_signing_default`] applied to `tagger_ident`.
537///
538/// # Errors
539///
540/// Returns [`Error::Signing`] when the signing program fails.
541pub fn sign_serialized_tag(
542    cfg: &GpgConfig,
543    unsigned_bytes: &[u8],
544    tagger_ident: &str,
545    signing_key_override: Option<&str>,
546) -> Result<Vec<u8>> {
547    let tagger_key = committer_signing_default(tagger_ident);
548    let signing_key = cfg.resolve_signing_key(signing_key_override, &tagger_key);
549    let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
550    Ok(append_tag_signature(unsigned_bytes, &sig))
551}
552
553/// Sign `payload` with `signing_key` using the configured program.
554///
555/// Spawns `<program> --status-fd=2 -bsau <signing_key>`, writes `payload` to
556/// stdin, and returns the armored detached signature from stdout.  Fails if the
557/// child exits non-zero or does not emit a `[GNUPG:] SIG_CREATED` status line —
558/// in either case the program's stderr is surfaced in the error (the
559/// `LET_GPG_PROGRAM_FAIL`/`zOMG` path of subtest 28).
560///
561/// # Errors
562///
563/// Returns [`Error::Signing`] when the program cannot be spawned, exits
564/// non-zero, or fails to produce a signature.
565pub fn sign_buffer(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
566    if cfg.format == GpgFormat::Ssh {
567        return sign_buffer_ssh(cfg, payload, signing_key);
568    }
569
570    let program = cfg.resolve_program_path()?;
571
572    let spec = CommandSpec {
573        program: program.clone(),
574        args: vec![
575            OsString::from("--status-fd=2"),
576            OsString::from("-bsau"),
577            OsString::from(signing_key),
578        ],
579        shell: None,
580        cwd: None,
581        env: CommandEnvironment::inherit_process_only(),
582        stdin: stdin_pipe(payload),
583        stdout: CommandStdio::Pipe,
584        stderr: CommandStdio::Pipe,
585    };
586    let output = signing_spawn(cfg, spec, None)?;
587
588    let status_text = String::from_utf8_lossy(&output.stderr);
589
590    if !output.status.success || !has_sig_created(&status_text) {
591        let detail = if status_text.trim().is_empty() {
592            "(no gpg output)".to_owned()
593        } else {
594            status_text.into_owned()
595        };
596        return Err(Error::Signing(format!(
597            "gpg failed to sign the data:\n{detail}"
598        )));
599    }
600
601    Ok(output.stdout)
602}
603
604/// True when a `[GNUPG:] SIG_CREATED ` status line is present at the start of a
605/// line (Git's `sign_buffer_gpg` SIG_CREATED scan).
606fn has_sig_created(status: &str) -> bool {
607    status
608        .lines()
609        .any(|line| line.starts_with("[GNUPG:] SIG_CREATED "))
610}
611
612/// Detect a literal ssh key (Git `gpg-interface.c:is_literal_ssh_key`).
613///
614/// Returns `Some(rest)` for `key::<rest>`, `Some(s)` for a value starting with
615/// `ssh-`, else `None`.
616fn is_literal_ssh_key(s: &str) -> Option<&str> {
617    if let Some(rest) = s.strip_prefix("key::") {
618        return Some(rest);
619    }
620    if s.starts_with("ssh-") {
621        return Some(s);
622    }
623    None
624}
625
626/// Sign `payload` with an ssh key using `ssh-keygen -Y sign`.
627///
628/// Port of Git's `gpg-interface.c:sign_buffer_ssh`.  `signing_key` is either a
629/// literal public key (`key::...` or `ssh-...`) or a path to a key file
630/// (`~/` expanded).  Returns the armored `-----BEGIN SSH SIGNATURE-----` blob.
631///
632/// # Errors
633///
634/// Returns [`Error::Signing`] when `signing_key` is empty, a temp file cannot be
635/// written, `ssh-keygen` cannot be run or exits non-zero, or the `.sig` output
636/// cannot be read.
637fn sign_buffer_ssh(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
638    if signing_key.is_empty() {
639        return Err(Error::Signing(
640            "user.signingKey needs to be set for ssh signing".to_owned(),
641        ));
642    }
643
644    let program = cfg.resolve_program_path()?;
645
646    // Resolve the key file: either a literal key written to a temp file (with
647    // the `-U` flag), or a path on disk.
648    let mut literal_key_tmp: Option<PathBuf> = None;
649    let (key_file, literal): (String, bool) = match is_literal_ssh_key(signing_key) {
650        Some(literal_key) => {
651            let path = write_temp_file_named(literal_key.as_bytes(), "git_signing_key")?;
652            let p = path.to_string_lossy().into_owned();
653            literal_key_tmp = Some(path);
654            (p, true)
655        }
656        None => (expand_tilde(signing_key), false),
657    };
658
659    // Write the payload to a temp buffer file; ssh-keygen reads it as the file
660    // to sign and writes `<file>.sig` alongside it.
661    let buffer_path = match write_temp_file_named(payload, "git_signing_buffer") {
662        Ok(p) => p,
663        Err(e) => {
664            if let Some(p) = &literal_key_tmp {
665                let _ = std::fs::remove_file(p);
666            }
667            return Err(e);
668        }
669    };
670
671    let mut args = vec![
672        OsString::from("-Y"),
673        OsString::from("sign"),
674        OsString::from("-n"),
675        OsString::from("git"),
676        OsString::from("-f"),
677        OsString::from(key_file.as_str()),
678    ];
679    if literal {
680        args.push(OsString::from("-U"));
681    }
682    args.push(OsString::from(buffer_path.to_string_lossy().as_ref()));
683    let spec = CommandSpec {
684        program: program.clone(),
685        args,
686        shell: None,
687        cwd: None,
688        env: CommandEnvironment::inherit_process_only(),
689        stdin: CommandStdin::Null,
690        stdout: CommandStdio::Pipe,
691        stderr: CommandStdio::Pipe,
692    };
693
694    let cleanup = |literal_key_tmp: &Option<PathBuf>, buffer_path: &Path| {
695        if let Some(p) = literal_key_tmp {
696            let _ = std::fs::remove_file(p);
697        }
698        let _ = std::fs::remove_file(buffer_path);
699        let _ = std::fs::remove_file(sig_sibling(buffer_path));
700    };
701
702    let output = match signing_spawn(cfg, spec, None) {
703        Ok(o) => o,
704        Err(e) => {
705            cleanup(&literal_key_tmp, &buffer_path);
706            return Err(e);
707        }
708    };
709
710    if !output.status.success {
711        let stderr = String::from_utf8_lossy(&output.stderr);
712        cleanup(&literal_key_tmp, &buffer_path);
713        if stderr.contains("usage:") {
714            return Err(Error::Signing(
715                "ssh-keygen -Y sign is needed for ssh signing (available in openssh version 8.2p1+)"
716                    .to_owned(),
717            ));
718        }
719        return Err(Error::Signing(stderr.into_owned()));
720    }
721
722    let sig_path = sig_sibling(&buffer_path);
723    let result = std::fs::read(&sig_path).map_err(|e| {
724        Error::Signing(format!(
725            "failed reading ssh signing data buffer from '{}': {e}",
726            sig_path.display()
727        ))
728    });
729    cleanup(&literal_key_tmp, &buffer_path);
730
731    let mut sig = result?;
732    // Strip a trailing CR (Windows line endings) from each line, mirroring
733    // Git's `remove_cr_after`.
734    strip_cr(&mut sig);
735    Ok(sig)
736}
737
738/// The `<path>.sig` sibling file produced by `ssh-keygen -Y sign`.
739fn sig_sibling(buffer_path: &Path) -> PathBuf {
740    let mut s = buffer_path.as_os_str().to_owned();
741    s.push(".sig");
742    PathBuf::from(s)
743}
744
745/// Remove `\r` characters that immediately precede a `\n` (Git `remove_cr_after`).
746fn strip_cr(buf: &mut Vec<u8>) {
747    let mut out = Vec::with_capacity(buf.len());
748    let mut i = 0;
749    while i < buf.len() {
750        if buf[i] == b'\r' && buf.get(i + 1) == Some(&b'\n') {
751            i += 1;
752            continue;
753        }
754        out.push(buf[i]);
755        i += 1;
756    }
757    *buf = out;
758}
759
760/// Splice a signature into a serialized commit object as a `gpgsig` header.
761///
762/// Port of Git's `commit.c:add_header_signature`: find the first `\n\n`, insert
763/// the header at the position right after that first `\n`, prefix the first
764/// signature line with `<header> ` and every subsequent line with a single
765/// space.  `header` is [`GPG_SIG_HEADER_SHA1`] or [`GPG_SIG_HEADER_SHA256`].
766pub fn add_header_signature(buf: &[u8], sig: &[u8], header: &str) -> Vec<u8> {
767    // Find end of header (first occurrence of "\n\n"); inspos is just past the
768    // first '\n'. If absent, append at the end.
769    let inspos = find_double_newline(buf).map(|p| p + 1).unwrap_or(buf.len());
770
771    let mut out = Vec::with_capacity(buf.len() + sig.len() + header.len() + 16);
772    out.extend_from_slice(&buf[..inspos]);
773
774    let mut first = true;
775    let mut copypos = 0usize;
776    while copypos < sig.len() {
777        let bol = copypos;
778        // End of this line, including the trailing '\n' when present.
779        let end = match memchr(sig, copypos, b'\n') {
780            Some(idx) => idx + 1,
781            None => sig.len(),
782        };
783
784        if first {
785            out.extend_from_slice(header.as_bytes());
786            first = false;
787        }
788        out.push(b' ');
789        out.extend_from_slice(&sig[bol..end]);
790        copypos = end;
791    }
792
793    out.extend_from_slice(&buf[inspos..]);
794    out
795}
796
797/// Find the first `\n\n` in `buf`, returning the index of the first `\n`.
798fn find_double_newline(buf: &[u8]) -> Option<usize> {
799    let mut i = 0;
800    while i + 1 < buf.len() {
801        if buf[i] == b'\n' && buf[i + 1] == b'\n' {
802            return Some(i);
803        }
804        i += 1;
805    }
806    None
807}
808
809/// Find the byte `needle` in `buf` starting at `from`.
810fn memchr(buf: &[u8], from: usize, needle: u8) -> Option<usize> {
811    buf.get(from..)
812        .and_then(|s| s.iter().position(|&b| b == needle))
813        .map(|p| p + from)
814}
815
816/// A parsed signature check result.
817#[derive(Debug, Clone, Default)]
818pub struct SignatureCheck {
819    /// The detached armored signature extracted from the object.
820    pub signature: Vec<u8>,
821    /// The signed payload (object with `gpgsig` header removed).
822    pub payload: Vec<u8>,
823    /// `%G?` result: `G` good, `B` bad, `U` good+untrusted, `E` error,
824    /// `N` no signature, `X`/`Y`/`R` expired/expired-key/revoked.
825    pub result: char,
826    /// `%GT` trust level.
827    pub trust_level: TrustLevel,
828    /// `%GK` key id.
829    pub key: Option<String>,
830    /// `%GS` signer (uid).
831    pub signer: Option<String>,
832    /// `%GF` signing key fingerprint.
833    pub fingerprint: Option<String>,
834    /// `%GP` primary key fingerprint.
835    pub primary_key_fingerprint: Option<String>,
836    /// Human-readable gpg output (stderr); shown by `--show-signature`.
837    pub output: String,
838    /// Raw `[GNUPG:]` status lines; shown by `verify-commit --raw`.
839    pub gpg_status: String,
840    /// True when the underlying verifier reported failure regardless of the
841    /// parsed `%G?` result.  For ssh this captures Git's
842    /// `verify_ssh_signed_buffer` return code (e.g. an untrusted key that still
843    /// produces a `Good "git" signature with ...` line must fail verification).
844    pub verifier_failed: bool,
845}
846
847impl SignatureCheck {
848    /// Construct the "no signature" result (`%G?` -> `N`).
849    pub fn default_none() -> SignatureCheck {
850        SignatureCheck {
851            result: 'N',
852            trust_level: TrustLevel::Undefined,
853            ..Default::default()
854        }
855    }
856
857    /// True when the signature verified as good (`G`) or good-but-expired-key
858    /// (`Y`) — Git's success criterion in `check_signature`.
859    pub fn is_good(&self) -> bool {
860        self.result == 'G' || self.result == 'Y'
861    }
862
863    /// Overall verification result honoring `min_trust_level`: `Ok(())` when the
864    /// signature is good and meets the configured minimum trust level.
865    pub fn verify_status(&self, min_trust_level: Option<TrustLevel>) -> bool {
866        if self.verifier_failed {
867            return false;
868        }
869        if !self.is_good() {
870            return false;
871        }
872        if let Some(min) = min_trust_level {
873            if self.trust_level < min {
874                return false;
875            }
876        }
877        true
878    }
879}
880
881/// Extract the `gpgsig` (or `gpgsig-sha256`) header value and the signed
882/// payload from a raw commit object.
883///
884/// Returns `(payload, signature)` where `payload` is the commit object with the
885/// `gpgsig` header removed (the bytes that were actually signed), and
886/// `signature` is the de-indented armored signature.  Returns `None` when the
887/// object carries no signature header.
888pub fn extract_signed_payload(raw_commit: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
889    // Header region ends at first "\n\n".
890    let header_end = find_double_newline(raw_commit)?;
891    let header = &raw_commit[..=header_end]; // includes trailing first '\n'
892    let body = &raw_commit[header_end + 1..]; // remaining (starts with '\n')
893
894    let mut payload = Vec::with_capacity(raw_commit.len());
895    let mut signature = Vec::new();
896    let mut found = false;
897
898    let mut idx = 0;
899    while idx < header.len() {
900        let line_end = memchr(header, idx, b'\n')
901            .map(|p| p + 1)
902            .unwrap_or(header.len());
903        let line = &header[idx..line_end];
904
905        let Some((_, prefix_len)) = gpg_sig_header_line(line) else {
906            payload.extend_from_slice(line);
907            idx = line_end;
908            continue;
909        };
910
911        if !found {
912            found = true;
913            signature.extend_from_slice(&line[prefix_len..]);
914            idx = line_end;
915            // Subsequent continuation lines (leading space) belong to the sig.
916            while idx < header.len() {
917                let cont_end = memchr(header, idx, b'\n')
918                    .map(|p| p + 1)
919                    .unwrap_or(header.len());
920                let cont = &header[idx..cont_end];
921                if cont.first() == Some(&b' ') {
922                    signature.extend_from_slice(&cont[1..]);
923                    idx = cont_end;
924                } else {
925                    break;
926                }
927            }
928            continue;
929        }
930
931        payload.extend_from_slice(line);
932        idx = line_end;
933    }
934
935    if !found {
936        return None;
937    }
938
939    payload.extend_from_slice(body);
940    Some((payload, signature))
941}
942
943/// Remove embedded commit signature headers from [`crate::objects::CommitData::extra_headers`].
944///
945/// Used when amending: the rewritten object must not carry the replaced tip's
946/// `gpgsig` / `gpgsig-sha256` block; signing (if any) adds a fresh header later.
947#[must_use]
948pub fn extra_headers_without_commit_signatures(extra: &[u8]) -> Vec<u8> {
949    let mut out = Vec::with_capacity(extra.len());
950    let mut idx = 0;
951    while idx < extra.len() {
952        let line_end = memchr(extra, idx, b'\n')
953            .map(|p| p + 1)
954            .unwrap_or(extra.len());
955        let line = &extra[idx..line_end];
956
957        if gpg_sig_header_line(line).is_some() {
958            idx = line_end;
959            while idx < extra.len() {
960                let cont_end = memchr(extra, idx, b'\n')
961                    .map(|p| p + 1)
962                    .unwrap_or(extra.len());
963                let cont = &extra[idx..cont_end];
964                if cont.first() == Some(&b' ') {
965                    idx = cont_end;
966                } else {
967                    break;
968                }
969            }
970            continue;
971        }
972
973        out.extend_from_slice(line);
974        idx = line_end;
975    }
976    out
977}
978
979/// If `line` begins a `gpgsig` / `gpgsig-sha256` header, return the header name and
980/// the byte offset after `"<header> "`.
981fn gpg_sig_header_line(line: &[u8]) -> Option<(&'static str, usize)> {
982    for header in [GPG_SIG_HEADER_SHA256, GPG_SIG_HEADER_SHA1] {
983        if line.starts_with(header.as_bytes()) && line.get(header.len()) == Some(&b' ') {
984            return Some((header, header.len() + 1));
985        }
986    }
987    None
988}
989
990/// Verify a raw commit object's embedded signature.
991///
992/// Extracts the payload + signature, then (for gpg-based formats) writes the
993/// signature to a temp file and runs `<program> --status-fd=1 <verify_args>
994/// --verify <sigfile> -`, feeding the payload on stdin, and parses the
995/// `[GNUPG:]` status lines.
996pub fn verify_commit(cfg: &GpgConfig, raw_commit: &[u8]) -> Result<SignatureCheck> {
997    let (payload, signature) = match extract_signed_payload(raw_commit) {
998        Some(parts) => parts,
999        None => return Ok(SignatureCheck::default_none()),
1000    };
1001
1002    // Git picks the verifier from the *signature* armor (`get_format_by_sig`),
1003    // not from `gpg.format`: a `git verify-commit` over an ssh-signed commit must
1004    // use ssh-keygen even when `gpg.format` is unset/openpgp, and vice-versa.
1005    let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
1006
1007    if detected_format == GpgFormat::Ssh {
1008        return verify_ssh_signed_buffer(cfg, payload, signature);
1009    }
1010
1011    let program = resolve_program(&cfg.program_for(detected_format))?;
1012
1013    // Write the detached signature to a temp file.
1014    let sig_path = write_temp_file(&signature)?;
1015
1016    let mut args = vec![OsString::from("--status-fd=1")];
1017    for a in detected_format.verify_args() {
1018        args.push(OsString::from(a));
1019    }
1020    args.push(OsString::from("--verify"));
1021    args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1022    args.push(OsString::from("-"));
1023    let spec = CommandSpec {
1024        program: program.clone(),
1025        args,
1026        shell: None,
1027        cwd: None,
1028        env: CommandEnvironment::inherit_process_only(),
1029        stdin: stdin_pipe(&payload),
1030        stdout: CommandStdio::Pipe,
1031        stderr: CommandStdio::Pipe,
1032    };
1033
1034    let output = match signing_spawn(cfg, spec, None) {
1035        Ok(o) => o,
1036        Err(e) => {
1037            let _ = std::fs::remove_file(&sig_path);
1038            return Err(e);
1039        }
1040    };
1041    let _ = std::fs::remove_file(&sig_path);
1042
1043    // status-fd=1 routes GNUPG status to stdout; human-readable goes to stderr.
1044    let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1045    let human = String::from_utf8_lossy(&output.stderr).into_owned();
1046
1047    let mut sigc = SignatureCheck {
1048        signature,
1049        payload,
1050        result: 'N',
1051        trust_level: TrustLevel::Undefined,
1052        gpg_status: gpg_status.clone(),
1053        output: human,
1054        ..Default::default()
1055    };
1056
1057    parse_gpg_output(&mut sigc, &gpg_status);
1058
1059    Ok(sigc)
1060}
1061
1062/// Parse `ssh-keygen -Y verify` human output into `sigc`
1063/// (produces the same parse as Git's `gpg-interface.c:parse_ssh_output`).
1064///
1065/// Expected first line of `sigc.output`:
1066/// * `Good "git" signature for PRINCIPAL with ... key SHA256:FINGERPRINT`
1067///   -> result `G`, trust `Fully`, signer = PRINCIPAL.
1068/// * `Good "git" signature with ... key SHA256:FINGERPRINT`
1069///   -> result `G`, trust `Undefined` (unknown key, signer unset).
1070/// * anything else -> result `B`, trust `Never`.
1071///
1072/// In the two good cases the substring after `key ` becomes both the
1073/// fingerprint (`%GF`) and the key (`%GK`); `%GP` is never set.
1074fn parse_ssh_output(sigc: &mut SignatureCheck) {
1075    sigc.result = 'B';
1076    sigc.trust_level = TrustLevel::Never;
1077    sigc.key = None;
1078    sigc.signer = None;
1079    sigc.fingerprint = None;
1080    sigc.primary_key_fingerprint = None;
1081
1082    let first_line = sigc.output.split('\n').next().unwrap_or("");
1083
1084    let after_key;
1085    if let Some(rest) = first_line.strip_prefix("Good \"git\" signature for ") {
1086        // The principal can contain whitespace; the trailing
1087        // ` with <algo> key <fpr>` is fixed, so split on the *last* " with ".
1088        match rest.rfind(" with ") {
1089            Some(idx) => {
1090                let principal = &rest[..idx];
1091                if principal.is_empty() {
1092                    return;
1093                }
1094                sigc.result = 'G';
1095                sigc.trust_level = TrustLevel::Fully;
1096                sigc.signer = Some(principal.to_owned());
1097                after_key = &rest[idx + " with ".len()..];
1098            }
1099            None => return,
1100        }
1101    } else if let Some(rest) = first_line.strip_prefix("Good \"git\" signature with ") {
1102        sigc.result = 'G';
1103        sigc.trust_level = TrustLevel::Undefined;
1104        after_key = rest;
1105    } else {
1106        return;
1107    }
1108
1109    // The fingerprint follows the literal `key ` token.
1110    match after_key.find("key ") {
1111        Some(pos) => {
1112            let fpr = after_key[pos + "key ".len()..].to_owned();
1113            sigc.fingerprint = Some(fpr.clone());
1114            sigc.key = Some(fpr);
1115        }
1116        None => {
1117            // Output did not match what we expected: treat as bad.
1118            sigc.result = 'B';
1119        }
1120    }
1121}
1122
1123/// Extract the committer (or tagger) unix timestamp from a signed payload,
1124/// porting Git's `parse_payload_metadata` for the `committer`/`tagger` header.
1125fn payload_committer_timestamp(payload: &[u8]) -> Option<u64> {
1126    let ident_line =
1127        find_header_line(payload, b"committer").or_else(|| find_header_line(payload, b"tagger"))?;
1128    let line = std::str::from_utf8(ident_line).ok()?;
1129    // Ident line: "Name <email> <timestamp> <tz>"; the timestamp is the
1130    // second-to-last whitespace-separated token.
1131    let mut it = line.split_whitespace().rev();
1132    let _tz = it.next()?;
1133    let ts = it.next()?;
1134    ts.parse::<u64>().ok()
1135}
1136
1137/// Return the bytes after `"<name> "` for the first header line matching `name`
1138/// (within the header region, i.e. before the first blank line).
1139fn find_header_line<'a>(payload: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
1140    let mut idx = 0;
1141    while idx < payload.len() {
1142        let line_end = memchr(payload, idx, b'\n').unwrap_or(payload.len());
1143        let line = &payload[idx..line_end];
1144        if line.is_empty() {
1145            // End of header region.
1146            return None;
1147        }
1148        if line.len() > name.len() + 1 && &line[..name.len()] == name && line[name.len()] == b' ' {
1149            return Some(&line[name.len() + 1..]);
1150        }
1151        idx = line_end + 1;
1152    }
1153    None
1154}
1155
1156/// Format a `-Overify-time=YYYYMMDDhhmmss` argument from a unix timestamp using
1157/// the local timezone, mirroring Git's `verify_date_mode` (DATE_STRFTIME, local).
1158fn verify_time_arg(timestamp: u64) -> String {
1159    use crate::git_date::show::{show_date, DateMode, DateModeType};
1160    let mut mode = DateMode {
1161        ty: DateModeType::Strftime,
1162        local: true,
1163        strftime_fmt: Some("%Y%m%d%H%M%S".to_owned()),
1164    };
1165    let formatted = show_date(timestamp, 0, &mut mode);
1166    format!("-Overify-time={formatted}")
1167}
1168
1169/// Verify an ssh-signed `payload` against `signature` using `ssh-keygen -Y`.
1170///
1171/// Port of Git's `gpg-interface.c:verify_ssh_signed_buffer`.  Requires
1172/// `gpg.ssh.allowedSignersFile`; runs `find-principals`, then either
1173/// `check-novalidate` (no principal matched -> untrusted) or `verify` for each
1174/// matched principal.  Populates `sigc.output`/`sigc.gpg_status` and parses them
1175/// via [`parse_ssh_output`].
1176fn verify_ssh_signed_buffer(
1177    cfg: &GpgConfig,
1178    payload: Vec<u8>,
1179    signature: Vec<u8>,
1180) -> Result<SignatureCheck> {
1181    let mut sigc = SignatureCheck {
1182        signature: signature.clone(),
1183        payload: payload.clone(),
1184        result: 'N',
1185        trust_level: TrustLevel::Undefined,
1186        ..Default::default()
1187    };
1188
1189    let allowed = match &cfg.ssh_allowed_signers {
1190        Some(a) if !a.is_empty() => a.clone(),
1191        _ => {
1192            sigc.result = 'B';
1193            sigc.trust_level = TrustLevel::Never;
1194            sigc.output = "gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification".to_owned();
1195            sigc.gpg_status = sigc.output.clone();
1196            return Ok(sigc);
1197        }
1198    };
1199
1200    // The format here is detected from the signature armor, which may differ
1201    // from `cfg.format`; always resolve the ssh program (`gpg.ssh.program` /
1202    // `gpg.program` / `ssh-keygen`).
1203    let program = resolve_program(&cfg.program_for(GpgFormat::Ssh))?;
1204
1205    // Write the detached signature to a temp `.git_vtag` file.
1206    let sig_path = write_temp_file_named(&signature, "git_vtag")?;
1207
1208    let verify_time = payload_committer_timestamp(&payload).map(verify_time_arg);
1209
1210    // 1. find-principals: which allowed principals can verify this signature?
1211    let mut find_args = vec![
1212        OsString::from("-Y"),
1213        OsString::from("find-principals"),
1214        OsString::from("-f"),
1215        OsString::from(allowed.as_str()),
1216        OsString::from("-s"),
1217        OsString::from(sig_path.to_string_lossy().as_ref()),
1218    ];
1219    if let Some(vt) = &verify_time {
1220        find_args.push(OsString::from(vt.as_str()));
1221    }
1222    let find_spec = CommandSpec {
1223        program: program.clone(),
1224        args: find_args,
1225        shell: None,
1226        cwd: None,
1227        env: CommandEnvironment::inherit_process_only(),
1228        stdin: CommandStdin::Null,
1229        stdout: CommandStdio::Pipe,
1230        stderr: CommandStdio::Pipe,
1231    };
1232    let find_out = signing_spawn(cfg, find_spec, None).inspect_err(|_| {
1233        let _ = std::fs::remove_file(&sig_path);
1234    })?;
1235
1236    let find_stdout = String::from_utf8_lossy(&find_out.stdout).into_owned();
1237    let find_stderr = String::from_utf8_lossy(&find_out.stderr).into_owned();
1238
1239    if !find_out.status.success && find_stderr.contains("usage:") {
1240        let _ = std::fs::remove_file(&sig_path);
1241        return Err(Error::Signing(
1242            "ssh-keygen -Y find-principals/verify is needed for ssh signature verification (available in openssh version 8.2p1+)"
1243                .to_owned(),
1244        ));
1245    }
1246
1247    let mut verify_stdout = String::new();
1248    let mut verify_stderr = String::new();
1249    // Tracks Git's `ret` in verify_ssh_signed_buffer: true means failure.
1250    let mut verifier_failed;
1251
1252    if !find_out.status.success || find_stdout.trim().is_empty() {
1253        // No matching principal: run check-novalidate to surface signature info,
1254        // but treat as untrusted (Git forces ret = -1).
1255        let mut check_args = vec![
1256            OsString::from("-Y"),
1257            OsString::from("check-novalidate"),
1258            OsString::from("-n"),
1259            OsString::from("git"),
1260            OsString::from("-s"),
1261            OsString::from(sig_path.to_string_lossy().as_ref()),
1262        ];
1263        if let Some(vt) = &verify_time {
1264            check_args.push(OsString::from(vt.as_str()));
1265        }
1266        let (out, err) = run_with_stdin(cfg, &program, check_args, &payload);
1267        verify_stdout = out;
1268        verify_stderr = err;
1269        verifier_failed = true;
1270    } else {
1271        // Try each matched principal until one verifies as Good.
1272        verifier_failed = true;
1273        for principal in find_stdout.lines() {
1274            let principal = principal.trim_end_matches('\r');
1275            if principal.is_empty() {
1276                continue;
1277            }
1278            let mut verify_args = vec![
1279                OsString::from("-Y"),
1280                OsString::from("verify"),
1281                OsString::from("-n"),
1282                OsString::from("git"),
1283                OsString::from("-f"),
1284                OsString::from(allowed.as_str()),
1285                OsString::from("-I"),
1286                OsString::from(principal),
1287                OsString::from("-s"),
1288                OsString::from(sig_path.to_string_lossy().as_ref()),
1289            ];
1290            if let Some(vt) = &verify_time {
1291                verify_args.push(OsString::from(vt.as_str()));
1292            }
1293            if let Some(rev) = &cfg.ssh_revocation_file {
1294                if Path::new(rev).exists() {
1295                    verify_args.push(OsString::from("-r"));
1296                    verify_args.push(OsString::from(rev.as_str()));
1297                }
1298            }
1299            let (out, err, ok) = run_with_stdin_status(cfg, &program, verify_args, &payload);
1300            verify_stdout = out;
1301            verify_stderr = err;
1302            // Git: ret = !ok; if !ret { ret = !starts_with("Good"); }
1303            verifier_failed = !(ok && verify_stdout.starts_with("Good"));
1304            if !verifier_failed {
1305                break;
1306            }
1307        }
1308    }
1309
1310    let _ = std::fs::remove_file(&sig_path);
1311
1312    // Build sigc.output exactly as Git: stripspace the ssh stdout and stderr
1313    // (each non-empty line keeps a trailing newline), then append the
1314    // find-principals stderr and the verify/check stderr (gpg-interface.c
1315    // 601-608). The trailing newline left by stripspace keeps the `Good "..."`
1316    // line separate from any appended `No principal matched.` text so
1317    // parse_ssh_output sees a clean first line.
1318    let mut output = stripspace(&verify_stdout);
1319    let verify_stderr = stripspace(&verify_stderr);
1320    output.push_str(&find_stderr);
1321    output.push_str(&verify_stderr);
1322
1323    sigc.output = output;
1324    sigc.gpg_status = sigc.output.clone();
1325    parse_ssh_output(&mut sigc);
1326    // Git combines the verifier return code with the parsed result/trust; the
1327    // parse already drives result/trust, so just carry the verifier failure.
1328    sigc.verifier_failed = verifier_failed;
1329
1330    Ok(sigc)
1331}
1332
1333/// Run `program` with `args`, feeding `input` on stdin.
1334fn run_with_stdin(
1335    cfg: &GpgConfig,
1336    program: &Path,
1337    args: Vec<OsString>,
1338    input: &[u8],
1339) -> (String, String) {
1340    let (out, err, _ok) = run_with_stdin_status(cfg, program, args, input);
1341    (out, err)
1342}
1343
1344/// Like [`run_with_stdin`] but also returns whether the child exited zero.
1345fn run_with_stdin_status(
1346    cfg: &GpgConfig,
1347    program: &Path,
1348    args: Vec<OsString>,
1349    input: &[u8],
1350) -> (String, String, bool) {
1351    let spec = CommandSpec {
1352        program: program.to_path_buf(),
1353        args,
1354        shell: None,
1355        cwd: None,
1356        env: CommandEnvironment::inherit_process_only(),
1357        stdin: stdin_pipe(input),
1358        stdout: CommandStdio::Pipe,
1359        stderr: CommandStdio::Pipe,
1360    };
1361    match signing_spawn(cfg, spec, None) {
1362        Ok(o) => (
1363            String::from_utf8_lossy(&o.stdout).into_owned(),
1364            String::from_utf8_lossy(&o.stderr).into_owned(),
1365            o.status.success,
1366        ),
1367        Err(_) => (String::new(), String::new(), false),
1368    }
1369}
1370
1371/// Port of Git's `strbuf_stripspace` (without comment handling): trim trailing
1372/// whitespace from each line, collapse runs of blank lines to a single blank
1373/// line, and terminate every non-empty line with a single `\n`. The retained
1374/// trailing newline is what keeps the ssh `Good "..."` line separate from the
1375/// appended `No principal matched.` stderr.
1376fn stripspace(s: &str) -> String {
1377    let mut out = String::with_capacity(s.len());
1378    let mut pending_empties = 0usize;
1379    let mut wrote_any = false;
1380    for line in s.split('\n') {
1381        let trimmed = line.trim_end_matches([' ', '\t', '\r']);
1382        if trimmed.is_empty() {
1383            pending_empties += 1;
1384            continue;
1385        }
1386        if pending_empties > 0 && wrote_any {
1387            out.push('\n');
1388        }
1389        pending_empties = 0;
1390        out.push_str(trimmed);
1391        out.push('\n');
1392        wrote_any = true;
1393    }
1394    out
1395}
1396
1397/// Parse `[GNUPG:]` status lines into `sigc` (port of `parse_gpg_output`).
1398fn parse_gpg_output(sigc: &mut SignatureCheck, status: &str) {
1399    // (result-char, prefix, exclusive, keyid, uid, fingerprint, trust)
1400    struct Entry {
1401        result: Option<char>,
1402        check: &'static str,
1403        exclusive: bool,
1404        keyid: bool,
1405        uid: bool,
1406        fingerprint: bool,
1407        trust: bool,
1408    }
1409    const TABLE: &[Entry] = &[
1410        Entry {
1411            result: Some('G'),
1412            check: "GOODSIG ",
1413            exclusive: true,
1414            keyid: true,
1415            uid: true,
1416            fingerprint: false,
1417            trust: false,
1418        },
1419        Entry {
1420            result: Some('B'),
1421            check: "BADSIG ",
1422            exclusive: true,
1423            keyid: true,
1424            uid: true,
1425            fingerprint: false,
1426            trust: false,
1427        },
1428        Entry {
1429            result: Some('E'),
1430            check: "ERRSIG ",
1431            exclusive: true,
1432            keyid: true,
1433            uid: false,
1434            fingerprint: false,
1435            trust: false,
1436        },
1437        Entry {
1438            result: Some('X'),
1439            check: "EXPSIG ",
1440            exclusive: true,
1441            keyid: true,
1442            uid: true,
1443            fingerprint: false,
1444            trust: false,
1445        },
1446        Entry {
1447            result: Some('Y'),
1448            check: "EXPKEYSIG ",
1449            exclusive: true,
1450            keyid: true,
1451            uid: true,
1452            fingerprint: false,
1453            trust: false,
1454        },
1455        Entry {
1456            result: Some('R'),
1457            check: "REVKEYSIG ",
1458            exclusive: true,
1459            keyid: true,
1460            uid: true,
1461            fingerprint: false,
1462            trust: false,
1463        },
1464        Entry {
1465            result: None,
1466            check: "VALIDSIG ",
1467            exclusive: false,
1468            keyid: false,
1469            uid: false,
1470            fingerprint: true,
1471            trust: false,
1472        },
1473        Entry {
1474            result: None,
1475            check: "TRUST_",
1476            exclusive: false,
1477            keyid: false,
1478            uid: false,
1479            fingerprint: false,
1480            trust: true,
1481        },
1482    ];
1483
1484    let mut seen_exclusive = false;
1485
1486    for raw_line in status.lines() {
1487        let line = match raw_line.strip_prefix("[GNUPG:] ") {
1488            Some(l) => l,
1489            None => continue,
1490        };
1491
1492        for entry in TABLE {
1493            let rest = match line.strip_prefix(entry.check) {
1494                Some(r) => r,
1495                None => continue,
1496            };
1497
1498            if entry.exclusive {
1499                if seen_exclusive {
1500                    // Multiple exclusive statuses => multiple signatures: reject.
1501                    error_reset(sigc);
1502                    return;
1503                }
1504                seen_exclusive = true;
1505            }
1506
1507            if let Some(r) = entry.result {
1508                sigc.result = r;
1509            }
1510
1511            let mut cursor = rest;
1512
1513            if entry.keyid {
1514                let (key, after) = split_at_space(cursor);
1515                sigc.key = Some(key.to_owned());
1516                if entry.uid && !after.is_empty() {
1517                    // signer is the rest of the line.
1518                    let signer = after.split('\n').next().unwrap_or("");
1519                    sigc.signer = Some(signer.to_owned());
1520                }
1521            }
1522
1523            if entry.trust {
1524                let level: String = cursor
1525                    .chars()
1526                    .take_while(|&c| c != ' ' && c != '\n')
1527                    .collect();
1528                match TrustLevel::from_status(&level) {
1529                    Some(t) => sigc.trust_level = t,
1530                    None => {
1531                        error_reset(sigc);
1532                        return;
1533                    }
1534                }
1535            }
1536
1537            if entry.fingerprint {
1538                // VALIDSIG <fingerprint> ... <primary-fingerprint>
1539                let (fpr, mut after) = split_at_space(cursor);
1540                sigc.fingerprint = Some(fpr.to_owned());
1541                // Skip 9 interim fields to reach the primary fingerprint.
1542                cursor = after;
1543                let mut remaining = 9;
1544                while remaining > 0 && !cursor.is_empty() {
1545                    let (_, next) = split_at_space(cursor);
1546                    after = next;
1547                    if after.is_empty() {
1548                        break;
1549                    }
1550                    cursor = after;
1551                    remaining -= 1;
1552                }
1553                if remaining == 0 {
1554                    let primary = cursor.split('\n').next().unwrap_or("");
1555                    sigc.primary_key_fingerprint = Some(primary.to_owned());
1556                }
1557            }
1558
1559            break;
1560        }
1561    }
1562}
1563
1564/// Reset `sigc` to the error state, clearing partial fields.
1565fn error_reset(sigc: &mut SignatureCheck) {
1566    sigc.result = 'E';
1567    sigc.primary_key_fingerprint = None;
1568    sigc.fingerprint = None;
1569    sigc.signer = None;
1570    sigc.key = None;
1571}
1572
1573/// Split `s` at the first space, returning `(before, after_space)`.
1574fn split_at_space(s: &str) -> (&str, &str) {
1575    match s.find(' ') {
1576        Some(i) => (&s[..i], &s[i + 1..]),
1577        None => (s, ""),
1578    }
1579}
1580
1581/// Write `data` to a fresh temp file and return its path.
1582fn write_temp_file(data: &[u8]) -> Result<PathBuf> {
1583    write_temp_file_named(data, "git_vtag")
1584}
1585
1586/// Build a fresh, reasonably unique temp path with the given name stem (without
1587/// creating the file).
1588fn temp_file_path(stem: &str) -> PathBuf {
1589    let dir = std::env::temp_dir();
1590    let unique = format!("{stem}_{}_{}", std::process::id(), next_temp_counter());
1591    dir.join(unique)
1592}
1593
1594/// Write `data` to a fresh temp file named with `stem` and return its path.
1595fn write_temp_file_named(data: &[u8], stem: &str) -> Result<PathBuf> {
1596    let path = temp_file_path(stem);
1597    let mut f = std::fs::File::create(&path)
1598        .map_err(|e| Error::Signing(format!("could not create temporary file: {e}")))?;
1599    f.write_all(data)
1600        .map_err(|e| Error::Signing(format!("failed writing to temporary file: {e}")))?;
1601    Ok(path)
1602}
1603
1604/// Monotonic counter used to disambiguate temp file names within a process.
1605fn next_temp_counter() -> u64 {
1606    use std::sync::atomic::{AtomicU64, Ordering};
1607    static COUNTER: AtomicU64 = AtomicU64::new(0);
1608    let now = crate::git_date::tm::process_wall_clock_sec() as u64;
1609    now ^ COUNTER.fetch_add(1, Ordering::Relaxed)
1610}
1611
1612/// Build the committer-info default signing key (Git's
1613/// `git_committer_info(IDENT_STRICT | IDENT_NO_DATE)` — `"Name <email>"`).
1614///
1615/// `committer_ident` is a full ident line (`"Name <email> <ts> <tz>"`); this
1616/// trims the trailing timestamp/timezone.
1617pub fn committer_signing_default(committer_ident: &str) -> String {
1618    if let Some(angle_end) = committer_ident.find('>') {
1619        committer_ident[..=angle_end].to_owned()
1620    } else {
1621        committer_ident.to_owned()
1622    }
1623}
1624
1625/// Split a signed buffer (e.g. a tag object) into `(payload, signature)`.
1626///
1627/// Port of Git's `gpg-interface.c:parse_signed_buffer`/`parse_signature`: scans
1628/// the buffer line by line and records the offset of the *last* line that begins
1629/// a recognized signature armor ([`GpgFormat::from_signature`]).  The payload is
1630/// everything before that offset and the signature is everything from it to the
1631/// end.  Unlike commits, a signed tag appends the armored signature directly
1632/// after the tag body with no `gpgsig` header and no per-line indentation.
1633///
1634/// Returns `None` when no armor line is found (the buffer is unsigned).
1635pub fn parse_signed_buffer(buf: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
1636    let size = buf.len();
1637    let mut len = 0usize;
1638    let mut matched: Option<usize> = None;
1639    while len < size {
1640        if GpgFormat::from_signature(&buf[len..]).is_some() {
1641            matched = Some(len);
1642        }
1643        let eol = memchr(buf, len, b'\n');
1644        len = match eol {
1645            Some(p) => p + 1,
1646            None => size,
1647        };
1648    }
1649    let m = matched?;
1650    Some((buf[..m].to_vec(), buf[m..].to_vec()))
1651}
1652
1653/// Verify a raw tag object's appended signature.
1654///
1655/// Mirrors [`verify_commit`] but uses [`parse_signed_buffer`] (tag signatures are
1656/// appended, not stored in a `gpgsig` header).  The verifier is chosen from the
1657/// signature armor (`get_format_by_sig`), reusing the internal `verify_ssh_signed_buffer` helper
1658/// for ssh and the gpg/gpgsm path otherwise.  Returns the "no signature" result
1659/// when the tag carries no signature.
1660pub fn verify_tag(cfg: &GpgConfig, raw_tag: &[u8]) -> Result<SignatureCheck> {
1661    let (payload, signature) = match parse_signed_buffer(raw_tag) {
1662        Some(parts) => parts,
1663        None => return Ok(SignatureCheck::default_none()),
1664    };
1665
1666    let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
1667
1668    if detected_format == GpgFormat::Ssh {
1669        return verify_ssh_signed_buffer(cfg, payload, signature);
1670    }
1671
1672    let program = resolve_program(&cfg.program_for(detected_format))?;
1673
1674    let sig_path = write_temp_file(&signature)?;
1675
1676    let mut args = vec![OsString::from("--status-fd=1")];
1677    for a in detected_format.verify_args() {
1678        args.push(OsString::from(a));
1679    }
1680    args.push(OsString::from("--verify"));
1681    args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1682    args.push(OsString::from("-"));
1683    let spec = CommandSpec {
1684        program: program.clone(),
1685        args,
1686        shell: None,
1687        cwd: None,
1688        env: CommandEnvironment::inherit_process_only(),
1689        stdin: stdin_pipe(&payload),
1690        stdout: CommandStdio::Pipe,
1691        stderr: CommandStdio::Pipe,
1692    };
1693
1694    let output = match signing_spawn(cfg, spec, None) {
1695        Ok(o) => o,
1696        Err(e) => {
1697            let _ = std::fs::remove_file(&sig_path);
1698            return Err(e);
1699        }
1700    };
1701    let _ = std::fs::remove_file(&sig_path);
1702
1703    let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1704    let human = String::from_utf8_lossy(&output.stderr).into_owned();
1705
1706    let mut sigc = SignatureCheck {
1707        signature,
1708        payload,
1709        result: 'N',
1710        trust_level: TrustLevel::Undefined,
1711        gpg_status: gpg_status.clone(),
1712        output: human,
1713        ..Default::default()
1714    };
1715
1716    parse_gpg_output(&mut sigc, &gpg_status);
1717
1718    Ok(sigc)
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724
1725    #[test]
1726    fn format_name_is_case_sensitive() {
1727        assert_eq!(GpgFormat::from_name("openpgp"), Some(GpgFormat::OpenPgp));
1728        assert_eq!(GpgFormat::from_name("x509"), Some(GpgFormat::X509));
1729        assert_eq!(GpgFormat::from_name("ssh"), Some(GpgFormat::Ssh));
1730        assert_eq!(GpgFormat::from_name("OpEnPgP"), None);
1731        assert_eq!(GpgFormat::from_name("OPENPGP"), None);
1732    }
1733
1734    #[test]
1735    fn add_header_signature_splices_gpgsig() {
1736        let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1737        let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1738        let out = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1739        let text = String::from_utf8(out).unwrap();
1740        assert!(text.contains("\ncommitter c\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n\nmessage\n"));
1741    }
1742
1743    #[test]
1744    fn extract_round_trips_signature() {
1745        let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1746        let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1747        let signed = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1748        let (payload, extracted) = extract_signed_payload(&signed).unwrap();
1749        assert_eq!(payload, commit);
1750        assert_eq!(extracted, sig);
1751    }
1752
1753    #[test]
1754    fn extract_none_when_unsigned() {
1755        let commit = b"tree 0123\ncommitter c\n\nmsg\n";
1756        assert!(extract_signed_payload(commit).is_none());
1757    }
1758
1759    #[test]
1760    fn extra_headers_without_commit_signatures_drops_gpgsig_block() {
1761        let extra = b"mergetag foo\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n";
1762        let stripped = extra_headers_without_commit_signatures(extra);
1763        assert_eq!(stripped, b"mergetag foo\n");
1764    }
1765
1766    #[test]
1767    fn parse_signed_buffer_splits_appended_tag_signature() {
1768        // A signed tag appends the armored signature directly after the body
1769        // with no `gpgsig` header and no per-line indentation.
1770        let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1771        let sig = b"-----BEGIN SSH SIGNATURE-----\nAAAA\n-----END SSH SIGNATURE-----\n";
1772        let mut tag = body.to_vec();
1773        tag.extend_from_slice(sig);
1774        let (payload, signature) = parse_signed_buffer(&tag).expect("should split");
1775        assert_eq!(payload, body);
1776        assert_eq!(signature, sig);
1777    }
1778
1779    #[test]
1780    fn parse_signed_buffer_none_when_unsigned() {
1781        let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1782        assert!(parse_signed_buffer(body).is_none());
1783    }
1784
1785    #[test]
1786    fn parse_goodsig_and_trust() {
1787        let status = "\
1788[GNUPG:] NEWSIG\n\
1789[GNUPG:] GOODSIG 73D758744BE721698EC54E8713D758744BE7216 C O Mitter <committer@example.com>\n\
1790[GNUPG:] VALIDSIG FINGERPRINT 2010-04-01 1270074988 0 4 0 17 2 00 PRIMARYFPR\n\
1791[GNUPG:] TRUST_ULTIMATE 0 pgp\n";
1792        let mut sigc = SignatureCheck::default_none();
1793        parse_gpg_output(&mut sigc, status);
1794        assert_eq!(sigc.result, 'G');
1795        assert_eq!(sigc.trust_level, TrustLevel::Ultimate);
1796        assert_eq!(
1797            sigc.signer.as_deref(),
1798            Some("C O Mitter <committer@example.com>")
1799        );
1800        assert_eq!(
1801            sigc.key.as_deref(),
1802            Some("73D758744BE721698EC54E8713D758744BE7216")
1803        );
1804        assert!(sigc.verify_status(None));
1805        assert!(sigc.verify_status(Some(TrustLevel::Ultimate)));
1806        assert!(sigc.verify_status(Some(TrustLevel::Marginal)));
1807    }
1808
1809    #[test]
1810    fn parse_badsig() {
1811        let status = "[GNUPG:] BADSIG KEYID Some Signer <s@example.com>\n";
1812        let mut sigc = SignatureCheck::default_none();
1813        parse_gpg_output(&mut sigc, status);
1814        assert_eq!(sigc.result, 'B');
1815        assert!(!sigc.is_good());
1816    }
1817
1818    #[test]
1819    fn double_exclusive_status_is_error() {
1820        let status = "[GNUPG:] GOODSIG K1 A <a@x>\n[GNUPG:] BADSIG K2 B <b@x>\n";
1821        let mut sigc = SignatureCheck::default_none();
1822        parse_gpg_output(&mut sigc, status);
1823        assert_eq!(sigc.result, 'E');
1824    }
1825
1826    #[test]
1827    fn min_trust_level_from_config_is_case_insensitive() {
1828        assert_eq!(
1829            TrustLevel::from_config("marginal"),
1830            Some(TrustLevel::Marginal)
1831        );
1832        assert_eq!(TrustLevel::from_config("FULLY"), Some(TrustLevel::Fully));
1833        assert_eq!(TrustLevel::from_config("bogus"), None);
1834    }
1835
1836    #[test]
1837    fn format_detected_from_signature_armor() {
1838        assert_eq!(
1839            GpgFormat::from_signature(b"-----BEGIN SSH SIGNATURE-----\nABC\n"),
1840            Some(GpgFormat::Ssh)
1841        );
1842        assert_eq!(
1843            GpgFormat::from_signature(b"-----BEGIN PGP SIGNATURE-----\n"),
1844            Some(GpgFormat::OpenPgp)
1845        );
1846        assert_eq!(
1847            GpgFormat::from_signature(b"-----BEGIN PGP MESSAGE-----\n"),
1848            Some(GpgFormat::OpenPgp)
1849        );
1850        assert_eq!(
1851            GpgFormat::from_signature(b"-----BEGIN SIGNED MESSAGE-----\n"),
1852            Some(GpgFormat::X509)
1853        );
1854        assert_eq!(GpgFormat::from_signature(b"garbage"), None);
1855    }
1856
1857    #[test]
1858    fn should_sign_commit_honors_override_and_config() {
1859        let mut cfg = ConfigSet::default();
1860        assert!(!should_sign_commit(&cfg, None).unwrap());
1861        assert!(should_sign_commit(&cfg, Some(true)).unwrap());
1862        assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1863
1864        cfg.add_command_override("commit.gpgsign", "true").unwrap();
1865        assert!(should_sign_commit(&cfg, None).unwrap());
1866        assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1867    }
1868
1869    #[test]
1870    fn should_sign_tag_honors_override_and_config() {
1871        let mut cfg = ConfigSet::default();
1872        assert!(!should_sign_tag(&cfg, None).unwrap());
1873        assert!(should_sign_tag(&cfg, Some(true)).unwrap());
1874        assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1875
1876        cfg.add_command_override("tag.gpgSign", "true").unwrap();
1877        assert!(should_sign_tag(&cfg, None).unwrap());
1878        assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1879    }
1880
1881    #[test]
1882    fn append_tag_signature_concatenates_armor() {
1883        let body = b"object 0\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmsg\n";
1884        let sig = b"-----BEGIN SSH SIGNATURE-----\nX\n-----END SSH SIGNATURE-----\n";
1885        let out = append_tag_signature(body, sig);
1886        assert_eq!(out.len(), body.len() + sig.len());
1887        assert_eq!(&out[..body.len()], body);
1888        assert_eq!(&out[body.len()..], sig);
1889    }
1890
1891    #[test]
1892    fn committer_signing_default_strips_timestamp() {
1893        assert_eq!(
1894            committer_signing_default("T <t@example.com> 1700000000 +0000"),
1895            "T <t@example.com>"
1896        );
1897    }
1898
1899    #[test]
1900    fn resolve_signing_key_uses_no_date_committer_default() {
1901        let cfg = GpgConfig::from_config(&ConfigSet::default()).expect("gpg config");
1902        let key = cfg.resolve_signing_key(
1903            None,
1904            &committer_signing_default("T <t@example.com> 1700000000 +0000"),
1905        );
1906        assert_eq!(key, "T <t@example.com>");
1907    }
1908
1909    #[test]
1910    fn literal_ssh_key_detection() {
1911        assert_eq!(
1912            is_literal_ssh_key("key::ssh-ed25519 AAAA"),
1913            Some("ssh-ed25519 AAAA")
1914        );
1915        assert_eq!(
1916            is_literal_ssh_key("ssh-ed25519 AAAA"),
1917            Some("ssh-ed25519 AAAA")
1918        );
1919        assert_eq!(is_literal_ssh_key("/home/u/.ssh/id_ed25519"), None);
1920    }
1921
1922    #[test]
1923    fn parse_ssh_output_trusted_principal() {
1924        let mut sigc = SignatureCheck::default_none();
1925        sigc.output =
1926            "Good \"git\" signature for principal with number 1 with ED25519 key SHA256:ABC\n"
1927                .to_owned();
1928        parse_ssh_output(&mut sigc);
1929        assert_eq!(sigc.result, 'G');
1930        assert_eq!(sigc.trust_level, TrustLevel::Fully);
1931        assert_eq!(sigc.signer.as_deref(), Some("principal with number 1"));
1932        assert_eq!(sigc.key.as_deref(), Some("SHA256:ABC"));
1933        assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:ABC"));
1934        assert!(sigc.primary_key_fingerprint.is_none());
1935    }
1936
1937    #[test]
1938    fn parse_ssh_output_untrusted_unknown_key() {
1939        let mut sigc = SignatureCheck::default_none();
1940        // The trailing `No principal matched.` is appended on its own line by
1941        // stripspace; only the first line should be parsed.
1942        sigc.output = "Good \"git\" signature with ED25519 key SHA256:XYZ\nNo principal matched.\n"
1943            .to_owned();
1944        parse_ssh_output(&mut sigc);
1945        assert_eq!(sigc.result, 'G');
1946        assert_eq!(sigc.trust_level, TrustLevel::Undefined);
1947        assert!(sigc.signer.is_none());
1948        assert_eq!(sigc.key.as_deref(), Some("SHA256:XYZ"));
1949        assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:XYZ"));
1950    }
1951
1952    #[test]
1953    fn parse_ssh_output_bad_signature() {
1954        let mut sigc = SignatureCheck::default_none();
1955        sigc.output = "Signature verification failed: incorrect signature\n".to_owned();
1956        parse_ssh_output(&mut sigc);
1957        assert_eq!(sigc.result, 'B');
1958        assert_eq!(sigc.trust_level, TrustLevel::Never);
1959        assert!(sigc.key.is_none());
1960    }
1961
1962    #[test]
1963    fn stripspace_keeps_line_terminators() {
1964        // Each non-empty line keeps a trailing newline; trailing blanks dropped.
1965        assert_eq!(stripspace("Good ... SHA256:FPR\n"), "Good ... SHA256:FPR\n");
1966        assert_eq!(stripspace("a  \n\n\nb\n\n"), "a\n\nb\n");
1967        assert_eq!(stripspace(""), "");
1968        // Concatenating a stripspaced line with following stderr keeps them on
1969        // separate lines.
1970        let mut out = stripspace("Good ... SHA256:FPR\n");
1971        out.push_str("No principal matched.\n");
1972        assert_eq!(out.lines().next(), Some("Good ... SHA256:FPR"));
1973    }
1974
1975    #[test]
1976    fn payload_committer_timestamp_parsed() {
1977        let payload =
1978            b"tree 0123\nauthor A <a@x> 1112912173 -0700\ncommitter C <c@x> 1112912273 +0200\n\nmsg\n";
1979        assert_eq!(payload_committer_timestamp(payload), Some(1112912273));
1980        // Falls back to tagger for tag payloads.
1981        let tag = b"object 0123\ntype commit\ntag v1\ntagger T <t@x> 1112912000 -0500\n\nmsg\n";
1982        assert_eq!(payload_committer_timestamp(tag), Some(1112912000));
1983        // No ident header.
1984        assert_eq!(payload_committer_timestamp(b"tree 0123\n\nmsg\n"), None);
1985    }
1986}