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