diff --git a/gitoxide-core/src/repository/commit.rs b/gitoxide-core/src/repository/commit.rs index fea3e71bbbe..febd67a70e0 100644 --- a/gitoxide-core/src/repository/commit.rs +++ b/gitoxide-core/src/repository/commit.rs @@ -1,4 +1,43 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use std::io::Write; +use std::process::Stdio; + +/// Note that this is a quick implementation of commit signature verification that ignores a lot of what +/// git does and can do, while focussing on the gist of it. +/// For this to go into `gix`, one will have to implement many more options and various validation programs. +pub fn verify(repo: gix::Repository, rev_spec: Option<&str>) -> Result<()> { + let rev_spec = rev_spec.unwrap_or("HEAD"); + let commit = repo + .rev_parse_single(format!("{rev_spec}^{{commit}}").as_str())? + .object()? + .into_commit(); + let (signature, signed_data) = commit + .signature() + .context("Could not parse commit to obtain signature")? + .ok_or_else(|| anyhow!("Commit at {rev_spec} is not signed"))?; + + let mut signature_storage = tempfile::NamedTempFile::new()?; + signature_storage.write_all(signature.as_ref())?; + let signed_storage = signature_storage.into_temp_path(); + + let mut cmd = std::process::Command::new("gpg"); + cmd.args(["--keyid-format=long", "--status-fd=1", "--verify"]) + .arg(&signed_storage) + .arg("-") + .stdin(Stdio::piped()); + gix::trace::debug!("About to execute {cmd:?}"); + let mut child = cmd.spawn()?; + child + .stdin + .take() + .expect("configured") + .write_all(signed_data.to_bstring().as_ref())?; + + if !child.wait()?.success() { + bail!("Command {cmd:?} failed"); + } + Ok(()) +} pub fn describe( mut repo: gix::Repository, diff --git a/gix-object/src/commit/mod.rs b/gix-object/src/commit/mod.rs index 1e55644edab..a7ac7f6b22a 100644 --- a/gix-object/src/commit/mod.rs +++ b/gix-object/src/commit/mod.rs @@ -1,4 +1,5 @@ -use bstr::{BStr, ByteSlice}; +use bstr::{BStr, BString, ByteSlice}; +use std::ops::Range; use crate::{Commit, CommitRef, TagRef}; @@ -21,16 +22,49 @@ pub struct MessageRef<'a> { pub body: Option<&'a BStr>, } +/// The raw commit data, parseable by [`CommitRef`] or [`Commit`], which was fed into a program to produce a signature. +/// +/// See [`extract_signature()`](crate::CommitRefIter::signature()) for how to obtain it. +// TODO: implement `std::io::Read` to avoid allocations +#[derive(PartialEq, Eq, Debug, Hash, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SignedData<'a> { + /// The raw commit data that includes the signature. + data: &'a [u8], + /// The byte range at which we find the signature. All but the signature is the data that was signed. + signature_range: Range, +} + +impl SignedData<'_> { + /// Convenience method to obtain a copy of the signed data. + pub fn to_bstring(&self) -> BString { + let mut buf = BString::from(&self.data[..self.signature_range.start]); + buf.extend_from_slice(&self.data[self.signature_range.end..]); + buf + } +} + +impl From> for BString { + fn from(value: SignedData<'_>) -> Self { + value.to_bstring() + } +} + /// pub mod ref_iter; mod write; +/// Lifecycle impl<'a> CommitRef<'a> { /// Deserialize a commit from the given `data` bytes while avoiding most allocations. pub fn from_bytes(data: &'a [u8]) -> Result, crate::decode::Error> { decode::commit(data).map(|(_, t)| t).map_err(crate::decode::Error::from) } +} + +/// Access +impl<'a> CommitRef<'a> { /// Return the `tree` fields hash digest. pub fn tree(&self) -> gix_hash::ObjectId { gix_hash::ObjectId::from_hex(self.tree).expect("prior validation of tree hash during parsing") @@ -45,7 +79,7 @@ impl<'a> CommitRef<'a> { /// Returns a convenient iterator over all extra headers. pub fn extra_headers(&self) -> crate::commit::ExtraHeaders> { - crate::commit::ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref()))) + ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref()))) } /// Return the author, with whitespace trimmed. diff --git a/gix-object/src/commit/ref_iter.rs b/gix-object/src/commit/ref_iter.rs index 98a7c5bcf33..e4f643accb8 100644 --- a/gix-object/src/commit/ref_iter.rs +++ b/gix-object/src/commit/ref_iter.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::ops::Range; use bstr::BStr; use gix_hash::{oid, ObjectId}; @@ -9,6 +10,7 @@ use nom::{ error::context, }; +use crate::commit::SignedData; use crate::{bstr::ByteSlice, commit::decode, parse, parse::NL, CommitRefIter}; #[derive(Copy, Clone)] @@ -30,6 +32,7 @@ pub(crate) enum State { Message, } +/// Lifecycle impl<'a> CommitRefIter<'a> { /// Create a commit iterator from data. pub fn from_bytes(data: &'a [u8]) -> CommitRefIter<'a> { @@ -38,6 +41,37 @@ impl<'a> CommitRefIter<'a> { state: State::default(), } } +} + +/// Access +impl<'a> CommitRefIter<'a> { + /// Parse `data` as commit and return its PGP signature, along with *all non-signature* data as [`SignedData`], or `None` + /// if the commit isn't signed. + /// + /// This allows the caller to validate the signature by passing the signed data along with the signature back to the program + /// that created it. + pub fn signature(data: &'a [u8]) -> Result, SignedData<'a>)>, crate::decode::Error> { + let mut signature_and_range = None; + + let raw_tokens = CommitRefIterRaw { + data, + state: State::default(), + offset: 0, + }; + for token in raw_tokens { + let token = token?; + if let Token::ExtraHeader((name, value)) = &token.token { + if *name == "gpgsig" { + // keep track of the signature range alongside the signature data, + // because all but the signature is the signed data. + signature_and_range = Some((value.clone(), token.token_range)); + break; + } + } + } + + Ok(signature_and_range.map(|(sig, signature_range)| (sig, SignedData { data, signature_range }))) + } /// Returns the object id of this commits tree if it is the first function called and if there is no error in decoding /// the data. @@ -233,6 +267,48 @@ impl<'a> Iterator for CommitRefIter<'a> { } } +/// A variation of [`CommitRefIter`] that return's [`RawToken`]s instead. +struct CommitRefIterRaw<'a> { + data: &'a [u8], + state: State, + offset: usize, +} + +impl<'a> Iterator for CommitRefIterRaw<'a> { + type Item = Result, crate::decode::Error>; + + fn next(&mut self) -> Option { + if self.data.is_empty() { + return None; + } + match CommitRefIter::next_inner(self.data, &mut self.state) { + Ok((remaining, token)) => { + let consumed = self.data.len() - remaining.len(); + let start = self.offset; + let end = start + consumed; + self.offset = end; + + self.data = remaining; + Some(Ok(RawToken { + token, + token_range: start..end, + })) + } + Err(err) => { + self.data = &[]; + Some(Err(err)) + } + } + } +} + +/// A combination of a parsed [`Token`] as well as the range of bytes that were consumed to parse it. +struct RawToken<'a> { + /// The parsed token. + token: Token<'a>, + token_range: Range, +} + /// A token returned by the [commit iterator][CommitRefIter]. #[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] diff --git a/gix-object/tests/commit/from_bytes.rs b/gix-object/tests/commit/from_bytes.rs index 26f5be64142..84c98785561 100644 --- a/gix-object/tests/commit/from_bytes.rs +++ b/gix-object/tests/commit/from_bytes.rs @@ -294,23 +294,11 @@ fn merge() -> crate::Result { Ok(()) } -const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE----- - -wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi -5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3 -+mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7 -QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv -k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr -iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0= -=HLmy ------END PGP SIGNATURE----- -"; - #[test] fn newline_right_after_signature_multiline_header() -> crate::Result { let fixture = fixture_name("commit", "signed-whitespace.txt"); let commit = CommitRef::from_bytes(&fixture)?; - let pgp_sig = OTHER_SIGNATURE.as_bstr(); + let pgp_sig = crate::commit::OTHER_SIGNATURE.as_bstr(); assert_eq!(commit.extra_headers[0].1.as_ref(), pgp_sig); assert_eq!(commit.extra_headers().pgp_signature(), Some(pgp_sig)); assert_eq!(commit.extra_headers().find("gpgsig"), Some(pgp_sig)); diff --git a/gix-object/tests/commit/iter.rs b/gix-object/tests/commit/iter.rs index c701eeb8aa8..b4110758f17 100644 --- a/gix-object/tests/commit/iter.rs +++ b/gix-object/tests/commit/iter.rs @@ -205,4 +205,60 @@ mod method { assert_eq!(iter.author().ok(), Some(signature(1592437401)), "it's not consuming"); Ok(()) } + + mod signature { + use bstr::{BStr, BString, ByteSlice}; + use gix_object::CommitRefIter; + + use crate::{ + commit::{OTHER_SIGNATURE, SIGNATURE}, + fixture_name, + }; + + fn validate<'a>( + fixture: &str, + expected_signature: impl Into<&'a BStr>, + signature_lines: std::ops::RangeInclusive, + ) -> crate::Result { + let expected_signature = expected_signature.into(); + let fixture_data = fixture_name("commit", fixture); + + let (actual_signature, actual_signed_data) = CommitRefIter::signature(&fixture_data)?.expect("sig present"); + assert_eq!(actual_signature, expected_signature); + + let expected_signed_data: BString = fixture_data + .lines_with_terminator() + .enumerate() + .filter_map(|(i, line)| (!signature_lines.contains(&i)).then_some(line)) + .collect(); + + assert_eq!(actual_signed_data.to_bstring(), expected_signed_data); + Ok(()) + } + + #[test] + fn single_line() -> crate::Result { + validate("signed-singleline.txt", b"magic:signature", 4..=4) + } + + #[test] + fn signed() -> crate::Result { + validate("signed.txt", b"-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCAAdFiEEdjYp/sh4j8NRKLX27gKdHl60AwAFAl7p9tgACgkQ7gKdHl60\nAwBpegf+KQciv9AOIN7+yPmowecGxBnSfpKWTDzFxnyGR8dq63SpWT8WEKG5mf3a\nG6iUqpsDWaMHlzihaMKRvgRpZxFRbjnNPFBj6F4RRqfE+5R7k6DRSLUV5PqnsdSH\nuccfIDWi1imhsm7AaP5trwl1t+83U2JhHqPcPVFLMODYwWeO6NLR/JCzGSTQRa8t\nRgaVMKI19O/fge5OT5Ua8D47VKEhsJX0LfmkP5RfZQ8JJvNd40TupqKRdlv0sAzP\nya7NXkSHXCavHNR6kA+KpWxn900UoGK8/IDlwU6MeOkpPVawb3NFMqnc7KJDaC2p\nSMzpuEG8LTrCx2YSpHNLqHyzvQ1CZA==\n=5ITV\n-----END PGP SIGNATURE-----", 4..=14) + } + + #[test] + fn with_encoding() -> crate::Result { + validate("signed-with-encoding.txt", SIGNATURE, 5..=15) + } + + #[test] + fn msg_footer() -> crate::Result { + validate("message-with-footer.txt", b"-----BEGIN PGP SIGNATURE-----\n\niHUEABYIAB0WIQSuZwcGWSQItmusNgR5URpSUCnwXQUCYT7xpAAKCRB5URpSUCnw\nXWB3AP9q323HlxnI8MyqszNOeYDwa7Y3yEZaUM2y/IRjz+z4YQEAq0yr1Syt3mrK\nOSFCqL2vDm3uStP+vF31f6FnzayhNg0=\n=Mhpp\n-----END PGP SIGNATURE-----", 4..=10) + } + + #[test] + fn whitespace() -> crate::Result { + validate("signed-whitespace.txt", OTHER_SIGNATURE, 5..=15) + } + } } diff --git a/gix-object/tests/commit/mod.rs b/gix-object/tests/commit/mod.rs index e939618a565..7a503b48231 100644 --- a/gix-object/tests/commit/mod.rs +++ b/gix-object/tests/commit/mod.rs @@ -130,6 +130,19 @@ zRo/4HJ04mSQYp0kluP/EBhz9g2wM/htIPyWRveB/ByKEYt3UNKjB++PJmPbu5UG dS3aXZhRfaPqpdsWrMB9fY7ll+oyfw== =T+RI -----END PGP SIGNATURE-----"; + +const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE----- + +wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi +5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3 ++mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7 +QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv +k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr +iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0= +=HLmy +-----END PGP SIGNATURE----- +"; + mod method { use gix_object::CommitRef; use pretty_assertions::assert_eq; diff --git a/gix/src/object/commit.rs b/gix/src/object/commit.rs index 171c6adee39..967228736dd 100644 --- a/gix/src/object/commit.rs +++ b/gix/src/object/commit.rs @@ -147,6 +147,15 @@ impl<'repo> Commit<'repo> { max_candidates: 10, } } + + /// Extracts the PGP signature and the data that was used to create the signature, or `None` if it wasn't signed. + // TODO: make it possible to verify the signature, probably by wrapping `SignedData`. It's quite some work to do it properly. + pub fn signature( + &self, + ) -> Result, gix_object::commit::SignedData<'_>)>, gix_object::decode::Error> + { + gix_object::CommitRefIter::signature(&self.data) + } } impl<'r> std::fmt::Debug for Commit<'r> { diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index d521d472645..1c2e2f4a19e 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -879,6 +879,17 @@ pub fn main() -> Result<()> { ), }, Subcommands::Commit(cmd) => match cmd { + commit::Subcommands::Verify { rev_spec } => prepare_and_run( + "commit-verify", + trace, + auto_verbose, + progress, + progress_keep_open, + None, + move |_progress, _out, _err| { + core::repository::commit::verify(repository(Mode::Lenient)?, rev_spec.as_deref()) + }, + ), commit::Subcommands::Describe { annotated_tags, all_refs, diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index 5be6f9b2222..32fcab9ab2c 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -472,6 +472,11 @@ pub mod tree { pub mod commit { #[derive(Debug, clap::Subcommand)] pub enum Subcommands { + /// Verify the signature of a commit. + Verify { + /// A specification of the revision to verify, or the current `HEAD` if unset. + rev_spec: Option, + }, /// Describe the current commit or the given one using the name of the closest annotated tag in its ancestry. Describe { /// Use annotated tag references only, not all tags.