1use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26
27use crate::config::ConfigSet;
28use crate::error::{Error, Result};
29
30pub const GPG_SIG_HEADER_SHA1: &str = "gpgsig";
32pub const GPG_SIG_HEADER_SHA256: &str = "gpgsig-sha256";
34
35#[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 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 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 pub fn from_config(value: &str) -> Option<TrustLevel> {
74 TrustLevel::from_status(&value.to_ascii_uppercase())
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum GpgFormat {
81 OpenPgp,
82 X509,
83 Ssh,
84}
85
86impl GpgFormat {
87 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 fn name(self) -> &'static str {
101 match self {
102 GpgFormat::OpenPgp => "openpgp",
103 GpgFormat::X509 => "x509",
104 GpgFormat::Ssh => "ssh",
105 }
106 }
107
108 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 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 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#[derive(Debug, Clone)]
157pub struct GpgConfig {
158 pub format: GpgFormat,
160 pub program: String,
163 pub generic_program: Option<String>,
165 pub openpgp_program: Option<String>,
167 pub x509_program: Option<String>,
169 pub ssh_program: Option<String>,
171 pub signing_key: Option<String>,
173 pub min_trust_level: Option<TrustLevel>,
175 pub ssh_allowed_signers: Option<String>,
177 pub ssh_revocation_file: Option<String>,
179}
180
181impl GpgConfig {
182 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 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 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 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 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 pub fn resolve_program_path(&self) -> Result<PathBuf> {
282 resolve_program(&self.program)
283 }
284}
285
286fn 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
300fn resolve_program(program: &str) -> Result<PathBuf> {
302 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 if path.is_absolute() || program.contains('/') {
317 return Ok(path.to_path_buf());
318 }
319
320 if let Some(found) = search_path(program) {
322 return Ok(found);
323 }
324
325 Ok(path.to_path_buf())
328}
329
330fn 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
359fn home_dir() -> Option<PathBuf> {
361 std::env::var_os("HOME").map(PathBuf::from)
362}
363
364fn 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
380pub 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 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
441fn has_sig_created(status: &str) -> bool {
444 status
445 .lines()
446 .any(|line| line.starts_with("[GNUPG:] SIG_CREATED "))
447}
448
449fn 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
463fn 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 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 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_cr(&mut sig);
567 Ok(sig)
568}
569
570fn 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
577fn 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
592pub fn add_header_signature(buf: &[u8], sig: &[u8], header: &str) -> Vec<u8> {
599 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 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
629fn 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
641fn 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#[derive(Debug, Clone, Default)]
650pub struct SignatureCheck {
651 pub signature: Vec<u8>,
653 pub payload: Vec<u8>,
655 pub result: char,
658 pub trust_level: TrustLevel,
660 pub key: Option<String>,
662 pub signer: Option<String>,
664 pub fingerprint: Option<String>,
666 pub primary_key_fingerprint: Option<String>,
668 pub output: String,
670 pub gpg_status: String,
672 pub verifier_failed: bool,
677}
678
679impl SignatureCheck {
680 pub fn default_none() -> SignatureCheck {
682 SignatureCheck {
683 result: 'N',
684 trust_level: TrustLevel::Undefined,
685 ..Default::default()
686 }
687 }
688
689 pub fn is_good(&self) -> bool {
692 self.result == 'G' || self.result == 'Y'
693 }
694
695 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
713pub fn extract_signed_payload(raw_commit: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
721 let header_end = find_double_newline(raw_commit)?;
723 let header = &raw_commit[..=header_end]; let body = &raw_commit[header_end + 1..]; 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 let prefix_len = GPG_SIG_HEADER_SHA1.len() + 1;
747 signature.extend_from_slice(&line[prefix_len..]);
748 idx = line_end;
749 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
777pub 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 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 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 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
852fn 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 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 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 sigc.result = 'B';
909 }
910 }
911}
912
913fn 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 let mut it = line.split_whitespace().rev();
922 let _tz = it.next()?;
923 let ts = it.next()?;
924 ts.parse::<u64>().ok()
925}
926
927fn 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 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
946fn 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
959fn 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 let program = resolve_program(&cfg.program_for(GpgFormat::Ssh))?;
994
995 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 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 let mut verifier_failed;
1040
1041 if !find_out.status.success() || find_stdout.trim().is_empty() {
1042 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 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 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 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 sigc.verifier_failed = verifier_failed;
1117
1118 Ok(sigc)
1119}
1120
1121fn 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
1128fn 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
1151fn 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
1177fn parse_gpg_output(sigc: &mut SignatureCheck, status: &str) {
1179 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 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 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 let (fpr, mut after) = split_at_space(cursor);
1320 sigc.fingerprint = Some(fpr.to_owned());
1321 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
1344fn 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
1353fn 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
1361fn write_temp_file(data: &[u8]) -> Result<PathBuf> {
1363 write_temp_file_named(data, "git_vtag")
1364}
1365
1366fn 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
1374fn 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
1384fn 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
1395pub 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
1408pub 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
1436pub 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 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 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 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 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 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 assert_eq!(payload_committer_timestamp(b"tree 0123\n\nmsg\n"), None);
1712 }
1713}