1use std::ffi::OsString;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use crate::command_runner::{
27 system_command_runner, CommandEnvironment, CommandOutput, CommandRunner, CommandSpec,
28 CommandStdin, CommandStdio,
29};
30use crate::config::ConfigSet;
31use crate::error::{Error, Result};
32use crate::objects::HashAlgo;
33
34pub const GPG_SIG_HEADER_SHA1: &str = "gpgsig";
36pub const GPG_SIG_HEADER_SHA256: &str = "gpgsig-sha256";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
43pub enum TrustLevel {
44 #[default]
45 Undefined = 0,
47 Never = 1,
49 Marginal = 2,
51 Fully = 3,
53 Ultimate = 4,
55}
56
57impl TrustLevel {
58 pub fn display_key(self) -> &'static str {
60 match self {
61 TrustLevel::Undefined => "undefined",
62 TrustLevel::Never => "never",
63 TrustLevel::Marginal => "marginal",
64 TrustLevel::Fully => "fully",
65 TrustLevel::Ultimate => "ultimate",
66 }
67 }
68
69 fn from_status(level: &str) -> Option<TrustLevel> {
71 match level {
72 "UNDEFINED" => Some(TrustLevel::Undefined),
73 "NEVER" => Some(TrustLevel::Never),
74 "MARGINAL" => Some(TrustLevel::Marginal),
75 "FULLY" => Some(TrustLevel::Fully),
76 "ULTIMATE" => Some(TrustLevel::Ultimate),
77 _ => None,
78 }
79 }
80
81 pub fn from_config(value: &str) -> Option<TrustLevel> {
83 TrustLevel::from_status(&value.to_ascii_uppercase())
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum GpgFormat {
90 OpenPgp,
92 X509,
94 Ssh,
96}
97
98impl GpgFormat {
99 pub fn from_name(name: &str) -> Option<GpgFormat> {
103 match name {
104 "openpgp" => Some(GpgFormat::OpenPgp),
105 "x509" => Some(GpgFormat::X509),
106 "ssh" => Some(GpgFormat::Ssh),
107 _ => None,
108 }
109 }
110
111 fn name(self) -> &'static str {
113 match self {
114 GpgFormat::OpenPgp => "openpgp",
115 GpgFormat::X509 => "x509",
116 GpgFormat::Ssh => "ssh",
117 }
118 }
119
120 fn default_program(self) -> &'static str {
122 match self {
123 GpgFormat::OpenPgp => "gpg",
124 GpgFormat::X509 => "gpgsm",
125 GpgFormat::Ssh => "ssh-keygen",
126 }
127 }
128
129 pub fn from_signature(sig: &[u8]) -> Option<GpgFormat> {
133 const OPENPGP: &[&[u8]] = &[
134 b"-----BEGIN PGP SIGNATURE-----",
135 b"-----BEGIN PGP MESSAGE-----",
136 ];
137 const X509: &[&[u8]] = &[b"-----BEGIN SIGNED MESSAGE-----"];
138 const SSH: &[&[u8]] = &[b"-----BEGIN SSH SIGNATURE-----"];
139 for prefix in OPENPGP {
140 if sig.starts_with(prefix) {
141 return Some(GpgFormat::OpenPgp);
142 }
143 }
144 for prefix in X509 {
145 if sig.starts_with(prefix) {
146 return Some(GpgFormat::X509);
147 }
148 }
149 for prefix in SSH {
150 if sig.starts_with(prefix) {
151 return Some(GpgFormat::Ssh);
152 }
153 }
154 None
155 }
156
157 fn verify_args(self) -> &'static [&'static str] {
159 match self {
160 GpgFormat::OpenPgp => &["--keyid-format=long"],
161 GpgFormat::X509 => &["--keyid-format=long"],
162 GpgFormat::Ssh => &[],
163 }
164 }
165}
166
167#[derive(Clone)]
169pub struct GpgConfig {
170 pub format: GpgFormat,
172 pub program: String,
175 pub generic_program: Option<String>,
177 pub openpgp_program: Option<String>,
179 pub x509_program: Option<String>,
181 pub ssh_program: Option<String>,
183 pub signing_key: Option<String>,
185 pub min_trust_level: Option<TrustLevel>,
187 pub ssh_allowed_signers: Option<String>,
189 pub ssh_revocation_file: Option<String>,
191 pub command_runner: Arc<dyn CommandRunner>,
193}
194
195impl std::fmt::Debug for GpgConfig {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("GpgConfig")
198 .field("format", &self.format)
199 .field("program", &self.program)
200 .finish_non_exhaustive()
201 }
202}
203
204fn signing_spawn(
205 cfg: &GpgConfig,
206 spec: CommandSpec,
207 payload: Option<&[u8]>,
208) -> Result<CommandOutput> {
209 let mut running = cfg
210 .command_runner
211 .spawn(&spec)
212 .map_err(|e| Error::Signing(format!("could not run signing program: {e}")))?;
213 if let Some(data) = payload {
214 let Some(stdin) = running.stdin_mut() else {
215 return Err(Error::Signing(
216 "could not write payload to signing program stdin".into(),
217 ));
218 };
219 stdin.write_all(data).map_err(|e| {
220 Error::Signing(format!("could not write payload to signing program: {e}"))
221 })?;
222 }
223 running
224 .wait_with_output()
225 .map_err(|e| Error::Signing(format!("failed waiting for signing program: {e}")))
226}
227
228fn stdin_pipe(data: &[u8]) -> CommandStdin {
229 CommandStdin::Pipe(data.to_vec())
230}
231
232impl GpgConfig {
233 pub fn from_config(config: &ConfigSet) -> Result<GpgConfig> {
240 let format = match config.get("gpg.format") {
241 Some(raw) => GpgFormat::from_name(&raw).ok_or_else(|| {
242 Error::Config(format!("invalid value for 'gpg.format': '{raw}'").into())
243 })?,
244 None => GpgFormat::OpenPgp,
245 };
246
247 let nonempty = |k: &str| config.get(k).filter(|p| !p.is_empty());
248 let generic_program = nonempty("gpg.program");
249 let fmt_program = |f: GpgFormat| nonempty(&format!("gpg.{}.program", f.name()));
250 let openpgp_program = fmt_program(GpgFormat::OpenPgp);
251 let x509_program = fmt_program(GpgFormat::X509);
252 let ssh_program = fmt_program(GpgFormat::Ssh);
253
254 let program = resolve_program_for_format(
256 format,
257 generic_program.as_deref(),
258 match format {
259 GpgFormat::OpenPgp => openpgp_program.as_deref(),
260 GpgFormat::X509 => x509_program.as_deref(),
261 GpgFormat::Ssh => ssh_program.as_deref(),
262 },
263 );
264
265 let signing_key = config.get("user.signingkey").filter(|k| !k.is_empty());
266
267 let min_trust_level = config
268 .get("gpg.mintrustlevel")
269 .and_then(|v| TrustLevel::from_config(&v));
270
271 let ssh_allowed_signers = config
274 .get("gpg.ssh.allowedsignersfile")
275 .filter(|p| !p.is_empty())
276 .map(|p| expand_tilde(&p));
277 let ssh_revocation_file = config
278 .get("gpg.ssh.revocationfile")
279 .filter(|p| !p.is_empty())
280 .map(|p| expand_tilde(&p));
281
282 Ok(GpgConfig {
283 format,
284 program,
285 generic_program,
286 openpgp_program,
287 x509_program,
288 ssh_program,
289 signing_key,
290 min_trust_level,
291 ssh_allowed_signers,
292 ssh_revocation_file,
293 command_runner: system_command_runner(),
294 })
295 }
296
297 #[must_use]
299 pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
300 self.command_runner = runner;
301 self
302 }
303
304 fn program_for(&self, format: GpgFormat) -> String {
309 let fmt_program = match format {
310 GpgFormat::OpenPgp => self.openpgp_program.as_deref(),
311 GpgFormat::X509 => self.x509_program.as_deref(),
312 GpgFormat::Ssh => self.ssh_program.as_deref(),
313 };
314 resolve_program_for_format(format, self.generic_program.as_deref(), fmt_program)
315 }
316
317 pub fn resolve_signing_key(
321 &self,
322 key_override: Option<&str>,
323 committer_default: &str,
324 ) -> String {
325 if let Some(k) = key_override {
326 if !k.is_empty() {
327 return k.to_owned();
328 }
329 }
330 if let Some(k) = &self.signing_key {
331 return k.clone();
332 }
333 committer_default.to_owned()
334 }
335
336 pub fn resolve_program_path(&self) -> Result<PathBuf> {
341 resolve_program(&self.program)
342 }
343}
344
345fn resolve_program_for_format(
348 format: GpgFormat,
349 generic_program: Option<&str>,
350 fmt_program: Option<&str>,
351) -> String {
352 fmt_program
353 .or(generic_program)
354 .filter(|p| !p.is_empty())
355 .map(|p| p.to_owned())
356 .unwrap_or_else(|| format.default_program().to_owned())
357}
358
359fn resolve_program(program: &str) -> Result<PathBuf> {
361 if program == "~" {
363 if let Some(home) = home_dir() {
364 return Ok(home);
365 }
366 }
367 if let Some(rest) = program.strip_prefix("~/") {
368 if let Some(home) = home_dir() {
369 return Ok(home.join(rest));
370 }
371 }
372
373 let path = Path::new(program);
374 if path.is_absolute() || program.contains('/') {
376 return Ok(path.to_path_buf());
377 }
378
379 if let Some(found) = search_path(program) {
381 return Ok(found);
382 }
383
384 Ok(path.to_path_buf())
387}
388
389fn search_path(name: &str) -> Option<PathBuf> {
391 let paths = crate::environment::Environment::empty().var_os("PATH")?;
392 for dir in paths.to_string_lossy().split(':').map(PathBuf::from) {
393 if dir.as_os_str().is_empty() {
394 continue;
395 }
396 let candidate = dir.join(name);
397 if is_executable_file(&candidate) {
398 return Some(candidate);
399 }
400 }
401 None
402}
403
404#[cfg(unix)]
405fn is_executable_file(path: &Path) -> bool {
406 use std::os::unix::fs::PermissionsExt;
407 match std::fs::metadata(path) {
408 Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111 != 0),
409 Err(_) => false,
410 }
411}
412
413#[cfg(not(unix))]
414fn is_executable_file(path: &Path) -> bool {
415 path.is_file()
416}
417
418fn home_dir() -> Option<PathBuf> {
420 crate::environment::Environment::empty()
421 .var_os("HOME")
422 .map(PathBuf::from)
423}
424
425fn expand_tilde(path: &str) -> String {
428 if path == "~" {
429 if let Some(home) = home_dir() {
430 return home.to_string_lossy().into_owned();
431 }
432 }
433 if let Some(rest) = path.strip_prefix("~/") {
434 if let Some(home) = home_dir() {
435 return home.join(rest).to_string_lossy().into_owned();
436 }
437 }
438 path.to_owned()
439}
440
441pub fn should_sign_commit(config: &ConfigSet, sign_override: Option<bool>) -> Result<bool> {
450 if let Some(force) = sign_override {
451 return Ok(force);
452 }
453 Ok(config
454 .get_bool("commit.gpgSign")
455 .transpose()?
456 .unwrap_or(false))
457}
458
459pub fn should_sign_tag(config: &ConfigSet, sign_override: Option<bool>) -> Result<bool> {
468 if let Some(force) = sign_override {
469 return Ok(force);
470 }
471 Ok(config.get_bool("tag.gpgSign").transpose()?.unwrap_or(false))
472}
473
474#[must_use]
476pub fn commit_gpg_sig_header(hash_algo: HashAlgo) -> &'static str {
477 match hash_algo {
478 HashAlgo::Sha256 => GPG_SIG_HEADER_SHA256,
479 HashAlgo::Sha1 => GPG_SIG_HEADER_SHA1,
480 }
481}
482
483pub fn sign_serialized_commit(
496 cfg: &GpgConfig,
497 unsigned_bytes: &[u8],
498 committer_ident: &str,
499 signing_key_override: Option<&str>,
500 hash_algo: HashAlgo,
501) -> Result<Vec<u8>> {
502 let committer_key = committer_signing_default(committer_ident);
503 let signing_key = cfg.resolve_signing_key(signing_key_override, &committer_key);
504 let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
505 let header = commit_gpg_sig_header(hash_algo);
506 Ok(add_header_signature(unsigned_bytes, &sig, header))
507}
508
509#[must_use]
514pub fn append_tag_signature(unsigned_bytes: &[u8], sig: &[u8]) -> Vec<u8> {
515 let mut out = Vec::with_capacity(unsigned_bytes.len() + sig.len());
516 out.extend_from_slice(unsigned_bytes);
517 out.extend_from_slice(sig);
518 out
519}
520
521pub fn sign_serialized_tag(
531 cfg: &GpgConfig,
532 unsigned_bytes: &[u8],
533 tagger_ident: &str,
534 signing_key_override: Option<&str>,
535) -> Result<Vec<u8>> {
536 let tagger_key = committer_signing_default(tagger_ident);
537 let signing_key = cfg.resolve_signing_key(signing_key_override, &tagger_key);
538 let sig = sign_buffer(cfg, unsigned_bytes, &signing_key)?;
539 Ok(append_tag_signature(unsigned_bytes, &sig))
540}
541
542pub fn sign_buffer(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
555 if cfg.format == GpgFormat::Ssh {
556 return sign_buffer_ssh(cfg, payload, signing_key);
557 }
558
559 let program = cfg.resolve_program_path()?;
560
561 let spec = CommandSpec {
562 program: program.clone(),
563 args: vec![
564 OsString::from("--status-fd=2"),
565 OsString::from("-bsau"),
566 OsString::from(signing_key),
567 ],
568 shell: None,
569 cwd: None,
570 env: CommandEnvironment::inherit_process_only(),
571 stdin: stdin_pipe(payload),
572 stdout: CommandStdio::Pipe,
573 stderr: CommandStdio::Pipe,
574 };
575 let output = signing_spawn(cfg, spec, None)?;
576
577 let status_text = String::from_utf8_lossy(&output.stderr);
578
579 if !output.status.success || !has_sig_created(&status_text) {
580 let detail = if status_text.trim().is_empty() {
581 "(no gpg output)".to_owned()
582 } else {
583 status_text.into_owned()
584 };
585 return Err(Error::Signing(format!(
586 "gpg failed to sign the data:\n{detail}"
587 )));
588 }
589
590 Ok(output.stdout)
591}
592
593fn has_sig_created(status: &str) -> bool {
596 status
597 .lines()
598 .any(|line| line.starts_with("[GNUPG:] SIG_CREATED "))
599}
600
601fn is_literal_ssh_key(s: &str) -> Option<&str> {
606 if let Some(rest) = s.strip_prefix("key::") {
607 return Some(rest);
608 }
609 if s.starts_with("ssh-") {
610 return Some(s);
611 }
612 None
613}
614
615fn sign_buffer_ssh(cfg: &GpgConfig, payload: &[u8], signing_key: &str) -> Result<Vec<u8>> {
627 if signing_key.is_empty() {
628 return Err(Error::Signing(
629 "user.signingKey needs to be set for ssh signing".to_owned(),
630 ));
631 }
632
633 let program = cfg.resolve_program_path()?;
634
635 let mut literal_key_tmp: Option<PathBuf> = None;
638 let (key_file, literal): (String, bool) = match is_literal_ssh_key(signing_key) {
639 Some(literal_key) => {
640 let path = write_temp_file_named(literal_key.as_bytes(), "git_signing_key")?;
641 let p = path.to_string_lossy().into_owned();
642 literal_key_tmp = Some(path);
643 (p, true)
644 }
645 None => (expand_tilde(signing_key), false),
646 };
647
648 let buffer_path = match write_temp_file_named(payload, "git_signing_buffer") {
651 Ok(p) => p,
652 Err(e) => {
653 if let Some(p) = &literal_key_tmp {
654 let _ = std::fs::remove_file(p);
655 }
656 return Err(e);
657 }
658 };
659
660 let mut args = vec![
661 OsString::from("-Y"),
662 OsString::from("sign"),
663 OsString::from("-n"),
664 OsString::from("git"),
665 OsString::from("-f"),
666 OsString::from(key_file.as_str()),
667 ];
668 if literal {
669 args.push(OsString::from("-U"));
670 }
671 args.push(OsString::from(buffer_path.to_string_lossy().as_ref()));
672 let spec = CommandSpec {
673 program: program.clone(),
674 args,
675 shell: None,
676 cwd: None,
677 env: CommandEnvironment::inherit_process_only(),
678 stdin: CommandStdin::Null,
679 stdout: CommandStdio::Pipe,
680 stderr: CommandStdio::Pipe,
681 };
682
683 let cleanup = |literal_key_tmp: &Option<PathBuf>, buffer_path: &Path| {
684 if let Some(p) = literal_key_tmp {
685 let _ = std::fs::remove_file(p);
686 }
687 let _ = std::fs::remove_file(buffer_path);
688 let _ = std::fs::remove_file(sig_sibling(buffer_path));
689 };
690
691 let output = match signing_spawn(cfg, spec, None) {
692 Ok(o) => o,
693 Err(e) => {
694 cleanup(&literal_key_tmp, &buffer_path);
695 return Err(e);
696 }
697 };
698
699 if !output.status.success {
700 let stderr = String::from_utf8_lossy(&output.stderr);
701 cleanup(&literal_key_tmp, &buffer_path);
702 if stderr.contains("usage:") {
703 return Err(Error::Signing(
704 "ssh-keygen -Y sign is needed for ssh signing (available in openssh version 8.2p1+)"
705 .to_owned(),
706 ));
707 }
708 return Err(Error::Signing(stderr.into_owned()));
709 }
710
711 let sig_path = sig_sibling(&buffer_path);
712 let result = std::fs::read(&sig_path).map_err(|e| {
713 Error::Signing(format!(
714 "failed reading ssh signing data buffer from '{}': {e}",
715 sig_path.display()
716 ))
717 });
718 cleanup(&literal_key_tmp, &buffer_path);
719
720 let mut sig = result?;
721 strip_cr(&mut sig);
724 Ok(sig)
725}
726
727fn sig_sibling(buffer_path: &Path) -> PathBuf {
729 let mut s = buffer_path.as_os_str().to_owned();
730 s.push(".sig");
731 PathBuf::from(s)
732}
733
734fn strip_cr(buf: &mut Vec<u8>) {
736 let mut out = Vec::with_capacity(buf.len());
737 let mut i = 0;
738 while i < buf.len() {
739 if buf[i] == b'\r' && buf.get(i + 1) == Some(&b'\n') {
740 i += 1;
741 continue;
742 }
743 out.push(buf[i]);
744 i += 1;
745 }
746 *buf = out;
747}
748
749pub fn add_header_signature(buf: &[u8], sig: &[u8], header: &str) -> Vec<u8> {
756 let inspos = find_double_newline(buf).map(|p| p + 1).unwrap_or(buf.len());
759
760 let mut out = Vec::with_capacity(buf.len() + sig.len() + header.len() + 16);
761 out.extend_from_slice(&buf[..inspos]);
762
763 let mut first = true;
764 let mut copypos = 0usize;
765 while copypos < sig.len() {
766 let bol = copypos;
767 let end = match memchr(sig, copypos, b'\n') {
769 Some(idx) => idx + 1,
770 None => sig.len(),
771 };
772
773 if first {
774 out.extend_from_slice(header.as_bytes());
775 first = false;
776 }
777 out.push(b' ');
778 out.extend_from_slice(&sig[bol..end]);
779 copypos = end;
780 }
781
782 out.extend_from_slice(&buf[inspos..]);
783 out
784}
785
786fn find_double_newline(buf: &[u8]) -> Option<usize> {
788 let mut i = 0;
789 while i + 1 < buf.len() {
790 if buf[i] == b'\n' && buf[i + 1] == b'\n' {
791 return Some(i);
792 }
793 i += 1;
794 }
795 None
796}
797
798fn memchr(buf: &[u8], from: usize, needle: u8) -> Option<usize> {
800 buf.get(from..)
801 .and_then(|s| s.iter().position(|&b| b == needle))
802 .map(|p| p + from)
803}
804
805#[derive(Debug, Clone, Default)]
807pub struct SignatureCheck {
808 pub signature: Vec<u8>,
810 pub payload: Vec<u8>,
812 pub result: char,
815 pub trust_level: TrustLevel,
817 pub key: Option<String>,
819 pub signer: Option<String>,
821 pub fingerprint: Option<String>,
823 pub primary_key_fingerprint: Option<String>,
825 pub output: String,
827 pub gpg_status: String,
829 pub verifier_failed: bool,
834}
835
836impl SignatureCheck {
837 pub fn default_none() -> SignatureCheck {
839 SignatureCheck {
840 result: 'N',
841 trust_level: TrustLevel::Undefined,
842 ..Default::default()
843 }
844 }
845
846 pub fn is_good(&self) -> bool {
849 self.result == 'G' || self.result == 'Y'
850 }
851
852 pub fn verify_status(&self, min_trust_level: Option<TrustLevel>) -> bool {
855 if self.verifier_failed {
856 return false;
857 }
858 if !self.is_good() {
859 return false;
860 }
861 if let Some(min) = min_trust_level {
862 if self.trust_level < min {
863 return false;
864 }
865 }
866 true
867 }
868}
869
870pub fn extract_signed_payload(raw_commit: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
878 let header_end = find_double_newline(raw_commit)?;
880 let header = &raw_commit[..=header_end]; let body = &raw_commit[header_end + 1..]; let mut payload = Vec::with_capacity(raw_commit.len());
884 let mut signature = Vec::new();
885 let mut found = false;
886
887 let mut idx = 0;
888 while idx < header.len() {
889 let line_end = memchr(header, idx, b'\n')
890 .map(|p| p + 1)
891 .unwrap_or(header.len());
892 let line = &header[idx..line_end];
893
894 let Some((_, prefix_len)) = gpg_sig_header_line(line) else {
895 payload.extend_from_slice(line);
896 idx = line_end;
897 continue;
898 };
899
900 if !found {
901 found = true;
902 signature.extend_from_slice(&line[prefix_len..]);
903 idx = line_end;
904 while idx < header.len() {
906 let cont_end = memchr(header, idx, b'\n')
907 .map(|p| p + 1)
908 .unwrap_or(header.len());
909 let cont = &header[idx..cont_end];
910 if cont.first() == Some(&b' ') {
911 signature.extend_from_slice(&cont[1..]);
912 idx = cont_end;
913 } else {
914 break;
915 }
916 }
917 continue;
918 }
919
920 payload.extend_from_slice(line);
921 idx = line_end;
922 }
923
924 if !found {
925 return None;
926 }
927
928 payload.extend_from_slice(body);
929 Some((payload, signature))
930}
931
932#[must_use]
937pub fn extra_headers_without_commit_signatures(extra: &[u8]) -> Vec<u8> {
938 let mut out = Vec::with_capacity(extra.len());
939 let mut idx = 0;
940 while idx < extra.len() {
941 let line_end = memchr(extra, idx, b'\n')
942 .map(|p| p + 1)
943 .unwrap_or(extra.len());
944 let line = &extra[idx..line_end];
945
946 if gpg_sig_header_line(line).is_some() {
947 idx = line_end;
948 while idx < extra.len() {
949 let cont_end = memchr(extra, idx, b'\n')
950 .map(|p| p + 1)
951 .unwrap_or(extra.len());
952 let cont = &extra[idx..cont_end];
953 if cont.first() == Some(&b' ') {
954 idx = cont_end;
955 } else {
956 break;
957 }
958 }
959 continue;
960 }
961
962 out.extend_from_slice(line);
963 idx = line_end;
964 }
965 out
966}
967
968fn gpg_sig_header_line(line: &[u8]) -> Option<(&'static str, usize)> {
971 for header in [GPG_SIG_HEADER_SHA256, GPG_SIG_HEADER_SHA1] {
972 if line.starts_with(header.as_bytes()) && line.get(header.len()) == Some(&b' ') {
973 return Some((header, header.len() + 1));
974 }
975 }
976 None
977}
978
979pub fn verify_commit(cfg: &GpgConfig, raw_commit: &[u8]) -> Result<SignatureCheck> {
986 let (payload, signature) = match extract_signed_payload(raw_commit) {
987 Some(parts) => parts,
988 None => return Ok(SignatureCheck::default_none()),
989 };
990
991 let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
995
996 if detected_format == GpgFormat::Ssh {
997 return verify_ssh_signed_buffer(cfg, payload, signature);
998 }
999
1000 let program = resolve_program(&cfg.program_for(detected_format))?;
1001
1002 let sig_path = write_temp_file(&signature)?;
1004
1005 let mut args = vec![OsString::from("--status-fd=1")];
1006 for a in detected_format.verify_args() {
1007 args.push(OsString::from(a));
1008 }
1009 args.push(OsString::from("--verify"));
1010 args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1011 args.push(OsString::from("-"));
1012 let spec = CommandSpec {
1013 program: program.clone(),
1014 args,
1015 shell: None,
1016 cwd: None,
1017 env: CommandEnvironment::inherit_process_only(),
1018 stdin: stdin_pipe(&payload),
1019 stdout: CommandStdio::Pipe,
1020 stderr: CommandStdio::Pipe,
1021 };
1022
1023 let output = match signing_spawn(cfg, spec, None) {
1024 Ok(o) => o,
1025 Err(e) => {
1026 let _ = std::fs::remove_file(&sig_path);
1027 return Err(e);
1028 }
1029 };
1030 let _ = std::fs::remove_file(&sig_path);
1031
1032 let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1034 let human = String::from_utf8_lossy(&output.stderr).into_owned();
1035
1036 let mut sigc = SignatureCheck {
1037 signature,
1038 payload,
1039 result: 'N',
1040 trust_level: TrustLevel::Undefined,
1041 gpg_status: gpg_status.clone(),
1042 output: human,
1043 ..Default::default()
1044 };
1045
1046 parse_gpg_output(&mut sigc, &gpg_status);
1047
1048 Ok(sigc)
1049}
1050
1051fn parse_ssh_output(sigc: &mut SignatureCheck) {
1064 sigc.result = 'B';
1065 sigc.trust_level = TrustLevel::Never;
1066 sigc.key = None;
1067 sigc.signer = None;
1068 sigc.fingerprint = None;
1069 sigc.primary_key_fingerprint = None;
1070
1071 let first_line = sigc.output.split('\n').next().unwrap_or("");
1072
1073 let after_key;
1074 if let Some(rest) = first_line.strip_prefix("Good \"git\" signature for ") {
1075 match rest.rfind(" with ") {
1078 Some(idx) => {
1079 let principal = &rest[..idx];
1080 if principal.is_empty() {
1081 return;
1082 }
1083 sigc.result = 'G';
1084 sigc.trust_level = TrustLevel::Fully;
1085 sigc.signer = Some(principal.to_owned());
1086 after_key = &rest[idx + " with ".len()..];
1087 }
1088 None => return,
1089 }
1090 } else if let Some(rest) = first_line.strip_prefix("Good \"git\" signature with ") {
1091 sigc.result = 'G';
1092 sigc.trust_level = TrustLevel::Undefined;
1093 after_key = rest;
1094 } else {
1095 return;
1096 }
1097
1098 match after_key.find("key ") {
1100 Some(pos) => {
1101 let fpr = after_key[pos + "key ".len()..].to_owned();
1102 sigc.fingerprint = Some(fpr.clone());
1103 sigc.key = Some(fpr);
1104 }
1105 None => {
1106 sigc.result = 'B';
1108 }
1109 }
1110}
1111
1112fn payload_committer_timestamp(payload: &[u8]) -> Option<u64> {
1115 let ident_line =
1116 find_header_line(payload, b"committer").or_else(|| find_header_line(payload, b"tagger"))?;
1117 let line = std::str::from_utf8(ident_line).ok()?;
1118 let mut it = line.split_whitespace().rev();
1121 let _tz = it.next()?;
1122 let ts = it.next()?;
1123 ts.parse::<u64>().ok()
1124}
1125
1126fn find_header_line<'a>(payload: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
1129 let mut idx = 0;
1130 while idx < payload.len() {
1131 let line_end = memchr(payload, idx, b'\n').unwrap_or(payload.len());
1132 let line = &payload[idx..line_end];
1133 if line.is_empty() {
1134 return None;
1136 }
1137 if line.len() > name.len() + 1 && &line[..name.len()] == name && line[name.len()] == b' ' {
1138 return Some(&line[name.len() + 1..]);
1139 }
1140 idx = line_end + 1;
1141 }
1142 None
1143}
1144
1145fn verify_time_arg(timestamp: u64) -> String {
1148 use crate::git_date::show::{show_date, DateMode, DateModeType};
1149 let mut mode = DateMode {
1150 ty: DateModeType::Strftime,
1151 local: true,
1152 strftime_fmt: Some("%Y%m%d%H%M%S".to_owned()),
1153 };
1154 let formatted = show_date(timestamp, 0, &mut mode);
1155 format!("-Overify-time={formatted}")
1156}
1157
1158fn verify_ssh_signed_buffer(
1166 cfg: &GpgConfig,
1167 payload: Vec<u8>,
1168 signature: Vec<u8>,
1169) -> Result<SignatureCheck> {
1170 let mut sigc = SignatureCheck {
1171 signature: signature.clone(),
1172 payload: payload.clone(),
1173 result: 'N',
1174 trust_level: TrustLevel::Undefined,
1175 ..Default::default()
1176 };
1177
1178 let allowed = match &cfg.ssh_allowed_signers {
1179 Some(a) if !a.is_empty() => a.clone(),
1180 _ => {
1181 sigc.result = 'B';
1182 sigc.trust_level = TrustLevel::Never;
1183 sigc.output = "gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification".to_owned();
1184 sigc.gpg_status = sigc.output.clone();
1185 return Ok(sigc);
1186 }
1187 };
1188
1189 let program = resolve_program(&cfg.program_for(GpgFormat::Ssh))?;
1193
1194 let sig_path = write_temp_file_named(&signature, "git_vtag")?;
1196
1197 let verify_time = payload_committer_timestamp(&payload).map(verify_time_arg);
1198
1199 let mut find_args = vec![
1201 OsString::from("-Y"),
1202 OsString::from("find-principals"),
1203 OsString::from("-f"),
1204 OsString::from(allowed.as_str()),
1205 OsString::from("-s"),
1206 OsString::from(sig_path.to_string_lossy().as_ref()),
1207 ];
1208 if let Some(vt) = &verify_time {
1209 find_args.push(OsString::from(vt.as_str()));
1210 }
1211 let find_spec = CommandSpec {
1212 program: program.clone(),
1213 args: find_args,
1214 shell: None,
1215 cwd: None,
1216 env: CommandEnvironment::inherit_process_only(),
1217 stdin: CommandStdin::Null,
1218 stdout: CommandStdio::Pipe,
1219 stderr: CommandStdio::Pipe,
1220 };
1221 let find_out = signing_spawn(cfg, find_spec, None).inspect_err(|_| {
1222 let _ = std::fs::remove_file(&sig_path);
1223 })?;
1224
1225 let find_stdout = String::from_utf8_lossy(&find_out.stdout).into_owned();
1226 let find_stderr = String::from_utf8_lossy(&find_out.stderr).into_owned();
1227
1228 if !find_out.status.success && find_stderr.contains("usage:") {
1229 let _ = std::fs::remove_file(&sig_path);
1230 return Err(Error::Signing(
1231 "ssh-keygen -Y find-principals/verify is needed for ssh signature verification (available in openssh version 8.2p1+)"
1232 .to_owned(),
1233 ));
1234 }
1235
1236 let mut verify_stdout = String::new();
1237 let mut verify_stderr = String::new();
1238 let mut verifier_failed;
1240
1241 if !find_out.status.success || find_stdout.trim().is_empty() {
1242 let mut check_args = vec![
1245 OsString::from("-Y"),
1246 OsString::from("check-novalidate"),
1247 OsString::from("-n"),
1248 OsString::from("git"),
1249 OsString::from("-s"),
1250 OsString::from(sig_path.to_string_lossy().as_ref()),
1251 ];
1252 if let Some(vt) = &verify_time {
1253 check_args.push(OsString::from(vt.as_str()));
1254 }
1255 let (out, err) = run_with_stdin(cfg, &program, check_args, &payload);
1256 verify_stdout = out;
1257 verify_stderr = err;
1258 verifier_failed = true;
1259 } else {
1260 verifier_failed = true;
1262 for principal in find_stdout.lines() {
1263 let principal = principal.trim_end_matches('\r');
1264 if principal.is_empty() {
1265 continue;
1266 }
1267 let mut verify_args = vec![
1268 OsString::from("-Y"),
1269 OsString::from("verify"),
1270 OsString::from("-n"),
1271 OsString::from("git"),
1272 OsString::from("-f"),
1273 OsString::from(allowed.as_str()),
1274 OsString::from("-I"),
1275 OsString::from(principal),
1276 OsString::from("-s"),
1277 OsString::from(sig_path.to_string_lossy().as_ref()),
1278 ];
1279 if let Some(vt) = &verify_time {
1280 verify_args.push(OsString::from(vt.as_str()));
1281 }
1282 if let Some(rev) = &cfg.ssh_revocation_file {
1283 if Path::new(rev).exists() {
1284 verify_args.push(OsString::from("-r"));
1285 verify_args.push(OsString::from(rev.as_str()));
1286 }
1287 }
1288 let (out, err, ok) = run_with_stdin_status(cfg, &program, verify_args, &payload);
1289 verify_stdout = out;
1290 verify_stderr = err;
1291 verifier_failed = !(ok && verify_stdout.starts_with("Good"));
1293 if !verifier_failed {
1294 break;
1295 }
1296 }
1297 }
1298
1299 let _ = std::fs::remove_file(&sig_path);
1300
1301 let mut output = stripspace(&verify_stdout);
1308 let verify_stderr = stripspace(&verify_stderr);
1309 output.push_str(&find_stderr);
1310 output.push_str(&verify_stderr);
1311
1312 sigc.output = output;
1313 sigc.gpg_status = sigc.output.clone();
1314 parse_ssh_output(&mut sigc);
1315 sigc.verifier_failed = verifier_failed;
1318
1319 Ok(sigc)
1320}
1321
1322fn run_with_stdin(
1324 cfg: &GpgConfig,
1325 program: &Path,
1326 args: Vec<OsString>,
1327 input: &[u8],
1328) -> (String, String) {
1329 let (out, err, _ok) = run_with_stdin_status(cfg, program, args, input);
1330 (out, err)
1331}
1332
1333fn run_with_stdin_status(
1335 cfg: &GpgConfig,
1336 program: &Path,
1337 args: Vec<OsString>,
1338 input: &[u8],
1339) -> (String, String, bool) {
1340 let spec = CommandSpec {
1341 program: program.to_path_buf(),
1342 args,
1343 shell: None,
1344 cwd: None,
1345 env: CommandEnvironment::inherit_process_only(),
1346 stdin: stdin_pipe(input),
1347 stdout: CommandStdio::Pipe,
1348 stderr: CommandStdio::Pipe,
1349 };
1350 match signing_spawn(cfg, spec, None) {
1351 Ok(o) => (
1352 String::from_utf8_lossy(&o.stdout).into_owned(),
1353 String::from_utf8_lossy(&o.stderr).into_owned(),
1354 o.status.success,
1355 ),
1356 Err(_) => (String::new(), String::new(), false),
1357 }
1358}
1359
1360fn stripspace(s: &str) -> String {
1366 let mut out = String::with_capacity(s.len());
1367 let mut pending_empties = 0usize;
1368 let mut wrote_any = false;
1369 for line in s.split('\n') {
1370 let trimmed = line.trim_end_matches([' ', '\t', '\r']);
1371 if trimmed.is_empty() {
1372 pending_empties += 1;
1373 continue;
1374 }
1375 if pending_empties > 0 && wrote_any {
1376 out.push('\n');
1377 }
1378 pending_empties = 0;
1379 out.push_str(trimmed);
1380 out.push('\n');
1381 wrote_any = true;
1382 }
1383 out
1384}
1385
1386fn parse_gpg_output(sigc: &mut SignatureCheck, status: &str) {
1388 struct Entry {
1390 result: Option<char>,
1391 check: &'static str,
1392 exclusive: bool,
1393 keyid: bool,
1394 uid: bool,
1395 fingerprint: bool,
1396 trust: bool,
1397 }
1398 const TABLE: &[Entry] = &[
1399 Entry {
1400 result: Some('G'),
1401 check: "GOODSIG ",
1402 exclusive: true,
1403 keyid: true,
1404 uid: true,
1405 fingerprint: false,
1406 trust: false,
1407 },
1408 Entry {
1409 result: Some('B'),
1410 check: "BADSIG ",
1411 exclusive: true,
1412 keyid: true,
1413 uid: true,
1414 fingerprint: false,
1415 trust: false,
1416 },
1417 Entry {
1418 result: Some('E'),
1419 check: "ERRSIG ",
1420 exclusive: true,
1421 keyid: true,
1422 uid: false,
1423 fingerprint: false,
1424 trust: false,
1425 },
1426 Entry {
1427 result: Some('X'),
1428 check: "EXPSIG ",
1429 exclusive: true,
1430 keyid: true,
1431 uid: true,
1432 fingerprint: false,
1433 trust: false,
1434 },
1435 Entry {
1436 result: Some('Y'),
1437 check: "EXPKEYSIG ",
1438 exclusive: true,
1439 keyid: true,
1440 uid: true,
1441 fingerprint: false,
1442 trust: false,
1443 },
1444 Entry {
1445 result: Some('R'),
1446 check: "REVKEYSIG ",
1447 exclusive: true,
1448 keyid: true,
1449 uid: true,
1450 fingerprint: false,
1451 trust: false,
1452 },
1453 Entry {
1454 result: None,
1455 check: "VALIDSIG ",
1456 exclusive: false,
1457 keyid: false,
1458 uid: false,
1459 fingerprint: true,
1460 trust: false,
1461 },
1462 Entry {
1463 result: None,
1464 check: "TRUST_",
1465 exclusive: false,
1466 keyid: false,
1467 uid: false,
1468 fingerprint: false,
1469 trust: true,
1470 },
1471 ];
1472
1473 let mut seen_exclusive = false;
1474
1475 for raw_line in status.lines() {
1476 let line = match raw_line.strip_prefix("[GNUPG:] ") {
1477 Some(l) => l,
1478 None => continue,
1479 };
1480
1481 for entry in TABLE {
1482 let rest = match line.strip_prefix(entry.check) {
1483 Some(r) => r,
1484 None => continue,
1485 };
1486
1487 if entry.exclusive {
1488 if seen_exclusive {
1489 error_reset(sigc);
1491 return;
1492 }
1493 seen_exclusive = true;
1494 }
1495
1496 if let Some(r) = entry.result {
1497 sigc.result = r;
1498 }
1499
1500 let mut cursor = rest;
1501
1502 if entry.keyid {
1503 let (key, after) = split_at_space(cursor);
1504 sigc.key = Some(key.to_owned());
1505 if entry.uid && !after.is_empty() {
1506 let signer = after.split('\n').next().unwrap_or("");
1508 sigc.signer = Some(signer.to_owned());
1509 }
1510 }
1511
1512 if entry.trust {
1513 let level: String = cursor
1514 .chars()
1515 .take_while(|&c| c != ' ' && c != '\n')
1516 .collect();
1517 match TrustLevel::from_status(&level) {
1518 Some(t) => sigc.trust_level = t,
1519 None => {
1520 error_reset(sigc);
1521 return;
1522 }
1523 }
1524 }
1525
1526 if entry.fingerprint {
1527 let (fpr, mut after) = split_at_space(cursor);
1529 sigc.fingerprint = Some(fpr.to_owned());
1530 cursor = after;
1532 let mut remaining = 9;
1533 while remaining > 0 && !cursor.is_empty() {
1534 let (_, next) = split_at_space(cursor);
1535 after = next;
1536 if after.is_empty() {
1537 break;
1538 }
1539 cursor = after;
1540 remaining -= 1;
1541 }
1542 if remaining == 0 {
1543 let primary = cursor.split('\n').next().unwrap_or("");
1544 sigc.primary_key_fingerprint = Some(primary.to_owned());
1545 }
1546 }
1547
1548 break;
1549 }
1550 }
1551}
1552
1553fn error_reset(sigc: &mut SignatureCheck) {
1555 sigc.result = 'E';
1556 sigc.primary_key_fingerprint = None;
1557 sigc.fingerprint = None;
1558 sigc.signer = None;
1559 sigc.key = None;
1560}
1561
1562fn split_at_space(s: &str) -> (&str, &str) {
1564 match s.find(' ') {
1565 Some(i) => (&s[..i], &s[i + 1..]),
1566 None => (s, ""),
1567 }
1568}
1569
1570fn write_temp_file(data: &[u8]) -> Result<PathBuf> {
1572 write_temp_file_named(data, "git_vtag")
1573}
1574
1575fn temp_file_path(stem: &str) -> PathBuf {
1578 let dir = std::env::temp_dir();
1579 let unique = format!("{stem}_{}_{}", std::process::id(), next_temp_counter());
1580 dir.join(unique)
1581}
1582
1583fn write_temp_file_named(data: &[u8], stem: &str) -> Result<PathBuf> {
1585 let path = temp_file_path(stem);
1586 let mut f = std::fs::File::create(&path)
1587 .map_err(|e| Error::Signing(format!("could not create temporary file: {e}")))?;
1588 f.write_all(data)
1589 .map_err(|e| Error::Signing(format!("failed writing to temporary file: {e}")))?;
1590 Ok(path)
1591}
1592
1593fn next_temp_counter() -> u64 {
1595 use std::sync::atomic::{AtomicU64, Ordering};
1596 static COUNTER: AtomicU64 = AtomicU64::new(0);
1597 let now = crate::git_date::tm::process_wall_clock_sec() as u64;
1598 now ^ COUNTER.fetch_add(1, Ordering::Relaxed)
1599}
1600
1601pub fn committer_signing_default(committer_ident: &str) -> String {
1607 if let Some(angle_end) = committer_ident.find('>') {
1608 committer_ident[..=angle_end].to_owned()
1609 } else {
1610 committer_ident.to_owned()
1611 }
1612}
1613
1614pub fn parse_signed_buffer(buf: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
1625 let size = buf.len();
1626 let mut len = 0usize;
1627 let mut matched: Option<usize> = None;
1628 while len < size {
1629 if GpgFormat::from_signature(&buf[len..]).is_some() {
1630 matched = Some(len);
1631 }
1632 let eol = memchr(buf, len, b'\n');
1633 len = match eol {
1634 Some(p) => p + 1,
1635 None => size,
1636 };
1637 }
1638 let m = matched?;
1639 Some((buf[..m].to_vec(), buf[m..].to_vec()))
1640}
1641
1642pub fn verify_tag(cfg: &GpgConfig, raw_tag: &[u8]) -> Result<SignatureCheck> {
1650 let (payload, signature) = match parse_signed_buffer(raw_tag) {
1651 Some(parts) => parts,
1652 None => return Ok(SignatureCheck::default_none()),
1653 };
1654
1655 let detected_format = GpgFormat::from_signature(&signature).unwrap_or(cfg.format);
1656
1657 if detected_format == GpgFormat::Ssh {
1658 return verify_ssh_signed_buffer(cfg, payload, signature);
1659 }
1660
1661 let program = resolve_program(&cfg.program_for(detected_format))?;
1662
1663 let sig_path = write_temp_file(&signature)?;
1664
1665 let mut args = vec![OsString::from("--status-fd=1")];
1666 for a in detected_format.verify_args() {
1667 args.push(OsString::from(a));
1668 }
1669 args.push(OsString::from("--verify"));
1670 args.push(OsString::from(sig_path.to_string_lossy().as_ref()));
1671 args.push(OsString::from("-"));
1672 let spec = CommandSpec {
1673 program: program.clone(),
1674 args,
1675 shell: None,
1676 cwd: None,
1677 env: CommandEnvironment::inherit_process_only(),
1678 stdin: stdin_pipe(&payload),
1679 stdout: CommandStdio::Pipe,
1680 stderr: CommandStdio::Pipe,
1681 };
1682
1683 let output = match signing_spawn(cfg, spec, None) {
1684 Ok(o) => o,
1685 Err(e) => {
1686 let _ = std::fs::remove_file(&sig_path);
1687 return Err(e);
1688 }
1689 };
1690 let _ = std::fs::remove_file(&sig_path);
1691
1692 let gpg_status = String::from_utf8_lossy(&output.stdout).into_owned();
1693 let human = String::from_utf8_lossy(&output.stderr).into_owned();
1694
1695 let mut sigc = SignatureCheck {
1696 signature,
1697 payload,
1698 result: 'N',
1699 trust_level: TrustLevel::Undefined,
1700 gpg_status: gpg_status.clone(),
1701 output: human,
1702 ..Default::default()
1703 };
1704
1705 parse_gpg_output(&mut sigc, &gpg_status);
1706
1707 Ok(sigc)
1708}
1709
1710#[cfg(test)]
1711mod tests {
1712 use super::*;
1713
1714 #[test]
1715 fn format_name_is_case_sensitive() {
1716 assert_eq!(GpgFormat::from_name("openpgp"), Some(GpgFormat::OpenPgp));
1717 assert_eq!(GpgFormat::from_name("x509"), Some(GpgFormat::X509));
1718 assert_eq!(GpgFormat::from_name("ssh"), Some(GpgFormat::Ssh));
1719 assert_eq!(GpgFormat::from_name("OpEnPgP"), None);
1720 assert_eq!(GpgFormat::from_name("OPENPGP"), None);
1721 }
1722
1723 #[test]
1724 fn add_header_signature_splices_gpgsig() {
1725 let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1726 let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1727 let out = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1728 let text = String::from_utf8(out).unwrap();
1729 assert!(text.contains("\ncommitter c\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n\nmessage\n"));
1730 }
1731
1732 #[test]
1733 fn extract_round_trips_signature() {
1734 let commit = b"tree 0123\nparent 4567\nauthor a\ncommitter c\n\nmessage\n";
1735 let sig = b"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n";
1736 let signed = add_header_signature(commit, sig, GPG_SIG_HEADER_SHA1);
1737 let (payload, extracted) = extract_signed_payload(&signed).unwrap();
1738 assert_eq!(payload, commit);
1739 assert_eq!(extracted, sig);
1740 }
1741
1742 #[test]
1743 fn extract_none_when_unsigned() {
1744 let commit = b"tree 0123\ncommitter c\n\nmsg\n";
1745 assert!(extract_signed_payload(commit).is_none());
1746 }
1747
1748 #[test]
1749 fn extra_headers_without_commit_signatures_drops_gpgsig_block() {
1750 let extra = b"mergetag foo\ngpgsig -----BEGIN PGP SIGNATURE-----\n ABC\n -----END PGP SIGNATURE-----\n";
1751 let stripped = extra_headers_without_commit_signatures(extra);
1752 assert_eq!(stripped, b"mergetag foo\n");
1753 }
1754
1755 #[test]
1756 fn parse_signed_buffer_splits_appended_tag_signature() {
1757 let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1760 let sig = b"-----BEGIN SSH SIGNATURE-----\nAAAA\n-----END SSH SIGNATURE-----\n";
1761 let mut tag = body.to_vec();
1762 tag.extend_from_slice(sig);
1763 let (payload, signature) = parse_signed_buffer(&tag).expect("should split");
1764 assert_eq!(payload, body);
1765 assert_eq!(signature, sig);
1766 }
1767
1768 #[test]
1769 fn parse_signed_buffer_none_when_unsigned() {
1770 let body = b"object 0123\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmessage\n";
1771 assert!(parse_signed_buffer(body).is_none());
1772 }
1773
1774 #[test]
1775 fn parse_goodsig_and_trust() {
1776 let status = "\
1777[GNUPG:] NEWSIG\n\
1778[GNUPG:] GOODSIG 73D758744BE721698EC54E8713D758744BE7216 C O Mitter <committer@example.com>\n\
1779[GNUPG:] VALIDSIG FINGERPRINT 2010-04-01 1270074988 0 4 0 17 2 00 PRIMARYFPR\n\
1780[GNUPG:] TRUST_ULTIMATE 0 pgp\n";
1781 let mut sigc = SignatureCheck::default_none();
1782 parse_gpg_output(&mut sigc, status);
1783 assert_eq!(sigc.result, 'G');
1784 assert_eq!(sigc.trust_level, TrustLevel::Ultimate);
1785 assert_eq!(
1786 sigc.signer.as_deref(),
1787 Some("C O Mitter <committer@example.com>")
1788 );
1789 assert_eq!(
1790 sigc.key.as_deref(),
1791 Some("73D758744BE721698EC54E8713D758744BE7216")
1792 );
1793 assert!(sigc.verify_status(None));
1794 assert!(sigc.verify_status(Some(TrustLevel::Ultimate)));
1795 assert!(sigc.verify_status(Some(TrustLevel::Marginal)));
1796 }
1797
1798 #[test]
1799 fn parse_badsig() {
1800 let status = "[GNUPG:] BADSIG KEYID Some Signer <s@example.com>\n";
1801 let mut sigc = SignatureCheck::default_none();
1802 parse_gpg_output(&mut sigc, status);
1803 assert_eq!(sigc.result, 'B');
1804 assert!(!sigc.is_good());
1805 }
1806
1807 #[test]
1808 fn double_exclusive_status_is_error() {
1809 let status = "[GNUPG:] GOODSIG K1 A <a@x>\n[GNUPG:] BADSIG K2 B <b@x>\n";
1810 let mut sigc = SignatureCheck::default_none();
1811 parse_gpg_output(&mut sigc, status);
1812 assert_eq!(sigc.result, 'E');
1813 }
1814
1815 #[test]
1816 fn min_trust_level_from_config_is_case_insensitive() {
1817 assert_eq!(
1818 TrustLevel::from_config("marginal"),
1819 Some(TrustLevel::Marginal)
1820 );
1821 assert_eq!(TrustLevel::from_config("FULLY"), Some(TrustLevel::Fully));
1822 assert_eq!(TrustLevel::from_config("bogus"), None);
1823 }
1824
1825 #[test]
1826 fn format_detected_from_signature_armor() {
1827 assert_eq!(
1828 GpgFormat::from_signature(b"-----BEGIN SSH SIGNATURE-----\nABC\n"),
1829 Some(GpgFormat::Ssh)
1830 );
1831 assert_eq!(
1832 GpgFormat::from_signature(b"-----BEGIN PGP SIGNATURE-----\n"),
1833 Some(GpgFormat::OpenPgp)
1834 );
1835 assert_eq!(
1836 GpgFormat::from_signature(b"-----BEGIN PGP MESSAGE-----\n"),
1837 Some(GpgFormat::OpenPgp)
1838 );
1839 assert_eq!(
1840 GpgFormat::from_signature(b"-----BEGIN SIGNED MESSAGE-----\n"),
1841 Some(GpgFormat::X509)
1842 );
1843 assert_eq!(GpgFormat::from_signature(b"garbage"), None);
1844 }
1845
1846 #[test]
1847 fn should_sign_commit_honors_override_and_config() {
1848 let mut cfg = ConfigSet::default();
1849 assert!(!should_sign_commit(&cfg, None).unwrap());
1850 assert!(should_sign_commit(&cfg, Some(true)).unwrap());
1851 assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1852
1853 cfg.add_command_override("commit.gpgsign", "true").unwrap();
1854 assert!(should_sign_commit(&cfg, None).unwrap());
1855 assert!(!should_sign_commit(&cfg, Some(false)).unwrap());
1856 }
1857
1858 #[test]
1859 fn should_sign_tag_honors_override_and_config() {
1860 let mut cfg = ConfigSet::default();
1861 assert!(!should_sign_tag(&cfg, None).unwrap());
1862 assert!(should_sign_tag(&cfg, Some(true)).unwrap());
1863 assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1864
1865 cfg.add_command_override("tag.gpgSign", "true").unwrap();
1866 assert!(should_sign_tag(&cfg, None).unwrap());
1867 assert!(!should_sign_tag(&cfg, Some(false)).unwrap());
1868 }
1869
1870 #[test]
1871 fn should_sign_rejects_a_non_boolean_naming_the_key() {
1872 let mut cfg = ConfigSet::default();
1873 cfg.add_command_override("tag.gpgsign", "sometimes")
1874 .unwrap();
1875 let err = should_sign_tag(&cfg, None).unwrap_err();
1876 assert!(matches!(
1877 err,
1878 Error::Config(crate::error::ConfigError::InvalidValue { ref key, .. }) if key == "tag.gpgSign"
1879 ));
1880 assert!(should_sign_tag(&cfg, Some(true)).unwrap());
1881 }
1882
1883 #[test]
1884 fn append_tag_signature_concatenates_armor() {
1885 let body = b"object 0\ntype commit\ntag v1\ntagger t <t@e> 1 +0000\n\nmsg\n";
1886 let sig = b"-----BEGIN SSH SIGNATURE-----\nX\n-----END SSH SIGNATURE-----\n";
1887 let out = append_tag_signature(body, sig);
1888 assert_eq!(out.len(), body.len() + sig.len());
1889 assert_eq!(&out[..body.len()], body);
1890 assert_eq!(&out[body.len()..], sig);
1891 }
1892
1893 #[test]
1894 fn committer_signing_default_strips_timestamp() {
1895 assert_eq!(
1896 committer_signing_default("T <t@example.com> 1700000000 +0000"),
1897 "T <t@example.com>"
1898 );
1899 }
1900
1901 #[test]
1902 fn resolve_signing_key_uses_no_date_committer_default() {
1903 let cfg = GpgConfig::from_config(&ConfigSet::default()).expect("gpg config");
1904 let key = cfg.resolve_signing_key(
1905 None,
1906 &committer_signing_default("T <t@example.com> 1700000000 +0000"),
1907 );
1908 assert_eq!(key, "T <t@example.com>");
1909 }
1910
1911 #[test]
1912 fn literal_ssh_key_detection() {
1913 assert_eq!(
1914 is_literal_ssh_key("key::ssh-ed25519 AAAA"),
1915 Some("ssh-ed25519 AAAA")
1916 );
1917 assert_eq!(
1918 is_literal_ssh_key("ssh-ed25519 AAAA"),
1919 Some("ssh-ed25519 AAAA")
1920 );
1921 assert_eq!(is_literal_ssh_key("/home/u/.ssh/id_ed25519"), None);
1922 }
1923
1924 #[test]
1925 fn parse_ssh_output_trusted_principal() {
1926 let mut sigc = SignatureCheck::default_none();
1927 sigc.output =
1928 "Good \"git\" signature for principal with number 1 with ED25519 key SHA256:ABC\n"
1929 .to_owned();
1930 parse_ssh_output(&mut sigc);
1931 assert_eq!(sigc.result, 'G');
1932 assert_eq!(sigc.trust_level, TrustLevel::Fully);
1933 assert_eq!(sigc.signer.as_deref(), Some("principal with number 1"));
1934 assert_eq!(sigc.key.as_deref(), Some("SHA256:ABC"));
1935 assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:ABC"));
1936 assert!(sigc.primary_key_fingerprint.is_none());
1937 }
1938
1939 #[test]
1940 fn parse_ssh_output_untrusted_unknown_key() {
1941 let mut sigc = SignatureCheck::default_none();
1942 sigc.output = "Good \"git\" signature with ED25519 key SHA256:XYZ\nNo principal matched.\n"
1945 .to_owned();
1946 parse_ssh_output(&mut sigc);
1947 assert_eq!(sigc.result, 'G');
1948 assert_eq!(sigc.trust_level, TrustLevel::Undefined);
1949 assert!(sigc.signer.is_none());
1950 assert_eq!(sigc.key.as_deref(), Some("SHA256:XYZ"));
1951 assert_eq!(sigc.fingerprint.as_deref(), Some("SHA256:XYZ"));
1952 }
1953
1954 #[test]
1955 fn parse_ssh_output_bad_signature() {
1956 let mut sigc = SignatureCheck::default_none();
1957 sigc.output = "Signature verification failed: incorrect signature\n".to_owned();
1958 parse_ssh_output(&mut sigc);
1959 assert_eq!(sigc.result, 'B');
1960 assert_eq!(sigc.trust_level, TrustLevel::Never);
1961 assert!(sigc.key.is_none());
1962 }
1963
1964 #[test]
1965 fn stripspace_keeps_line_terminators() {
1966 assert_eq!(stripspace("Good ... SHA256:FPR\n"), "Good ... SHA256:FPR\n");
1968 assert_eq!(stripspace("a \n\n\nb\n\n"), "a\n\nb\n");
1969 assert_eq!(stripspace(""), "");
1970 let mut out = stripspace("Good ... SHA256:FPR\n");
1973 out.push_str("No principal matched.\n");
1974 assert_eq!(out.lines().next(), Some("Good ... SHA256:FPR"));
1975 }
1976
1977 #[test]
1978 fn payload_committer_timestamp_parsed() {
1979 let payload =
1980 b"tree 0123\nauthor A <a@x> 1112912173 -0700\ncommitter C <c@x> 1112912273 +0200\n\nmsg\n";
1981 assert_eq!(payload_committer_timestamp(payload), Some(1112912273));
1982 let tag = b"object 0123\ntype commit\ntag v1\ntagger T <t@x> 1112912000 -0500\n\nmsg\n";
1984 assert_eq!(payload_committer_timestamp(tag), Some(1112912000));
1985 assert_eq!(payload_committer_timestamp(b"tree 0123\n\nmsg\n"), None);
1987 }
1988}