Skip to content

Commit b37affe

Browse files
committed
Merge branch 'extract-signatures'
2 parents c7d9129 + cd6cfe4 commit b37affe

File tree

9 files changed

+247
-16
lines changed

9 files changed

+247
-16
lines changed

gitoxide-core/src/repository/commit.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
1-
use anyhow::{Context, Result};
1+
use anyhow::{anyhow, bail, Context, Result};
2+
use std::io::Write;
3+
use std::process::Stdio;
4+
5+
/// Note that this is a quick implementation of commit signature verification that ignores a lot of what
6+
/// git does and can do, while focussing on the gist of it.
7+
/// For this to go into `gix`, one will have to implement many more options and various validation programs.
8+
pub fn verify(repo: gix::Repository, rev_spec: Option<&str>) -> Result<()> {
9+
let rev_spec = rev_spec.unwrap_or("HEAD");
10+
let commit = repo
11+
.rev_parse_single(format!("{rev_spec}^{{commit}}").as_str())?
12+
.object()?
13+
.into_commit();
14+
let (signature, signed_data) = commit
15+
.signature()
16+
.context("Could not parse commit to obtain signature")?
17+
.ok_or_else(|| anyhow!("Commit at {rev_spec} is not signed"))?;
18+
19+
let mut signature_storage = tempfile::NamedTempFile::new()?;
20+
signature_storage.write_all(signature.as_ref())?;
21+
let signed_storage = signature_storage.into_temp_path();
22+
23+
let mut cmd = std::process::Command::new("gpg");
24+
cmd.args(["--keyid-format=long", "--status-fd=1", "--verify"])
25+
.arg(&signed_storage)
26+
.arg("-")
27+
.stdin(Stdio::piped());
28+
gix::trace::debug!("About to execute {cmd:?}");
29+
let mut child = cmd.spawn()?;
30+
child
31+
.stdin
32+
.take()
33+
.expect("configured")
34+
.write_all(signed_data.to_bstring().as_ref())?;
35+
36+
if !child.wait()?.success() {
37+
bail!("Command {cmd:?} failed");
38+
}
39+
Ok(())
40+
}
241

342
pub fn describe(
443
mut repo: gix::Repository,

gix-object/src/commit/mod.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use bstr::{BStr, ByteSlice};
1+
use bstr::{BStr, BString, ByteSlice};
2+
use std::ops::Range;
23

34
use crate::{Commit, CommitRef, TagRef};
45

@@ -21,16 +22,49 @@ pub struct MessageRef<'a> {
2122
pub body: Option<&'a BStr>,
2223
}
2324

25+
/// The raw commit data, parseable by [`CommitRef`] or [`Commit`], which was fed into a program to produce a signature.
26+
///
27+
/// See [`extract_signature()`](crate::CommitRefIter::signature()) for how to obtain it.
28+
// TODO: implement `std::io::Read` to avoid allocations
29+
#[derive(PartialEq, Eq, Debug, Hash, Clone)]
30+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31+
pub struct SignedData<'a> {
32+
/// The raw commit data that includes the signature.
33+
data: &'a [u8],
34+
/// The byte range at which we find the signature. All but the signature is the data that was signed.
35+
signature_range: Range<usize>,
36+
}
37+
38+
impl SignedData<'_> {
39+
/// Convenience method to obtain a copy of the signed data.
40+
pub fn to_bstring(&self) -> BString {
41+
let mut buf = BString::from(&self.data[..self.signature_range.start]);
42+
buf.extend_from_slice(&self.data[self.signature_range.end..]);
43+
buf
44+
}
45+
}
46+
47+
impl From<SignedData<'_>> for BString {
48+
fn from(value: SignedData<'_>) -> Self {
49+
value.to_bstring()
50+
}
51+
}
52+
2453
///
2554
pub mod ref_iter;
2655

2756
mod write;
2857

58+
/// Lifecycle
2959
impl<'a> CommitRef<'a> {
3060
/// Deserialize a commit from the given `data` bytes while avoiding most allocations.
3161
pub fn from_bytes(data: &'a [u8]) -> Result<CommitRef<'a>, crate::decode::Error> {
3262
decode::commit(data).map(|(_, t)| t).map_err(crate::decode::Error::from)
3363
}
64+
}
65+
66+
/// Access
67+
impl<'a> CommitRef<'a> {
3468
/// Return the `tree` fields hash digest.
3569
pub fn tree(&self) -> gix_hash::ObjectId {
3670
gix_hash::ObjectId::from_hex(self.tree).expect("prior validation of tree hash during parsing")
@@ -45,7 +79,7 @@ impl<'a> CommitRef<'a> {
4579

4680
/// Returns a convenient iterator over all extra headers.
4781
pub fn extra_headers(&self) -> crate::commit::ExtraHeaders<impl Iterator<Item = (&BStr, &BStr)>> {
48-
crate::commit::ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref())))
82+
ExtraHeaders::new(self.extra_headers.iter().map(|(k, v)| (*k, v.as_ref())))
4983
}
5084

5185
/// Return the author, with whitespace trimmed.

gix-object/src/commit/ref_iter.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::borrow::Cow;
2+
use std::ops::Range;
23

34
use bstr::BStr;
45
use gix_hash::{oid, ObjectId};
@@ -9,6 +10,7 @@ use nom::{
910
error::context,
1011
};
1112

13+
use crate::commit::SignedData;
1214
use crate::{bstr::ByteSlice, commit::decode, parse, parse::NL, CommitRefIter};
1315

1416
#[derive(Copy, Clone)]
@@ -30,6 +32,7 @@ pub(crate) enum State {
3032
Message,
3133
}
3234

35+
/// Lifecycle
3336
impl<'a> CommitRefIter<'a> {
3437
/// Create a commit iterator from data.
3538
pub fn from_bytes(data: &'a [u8]) -> CommitRefIter<'a> {
@@ -38,6 +41,37 @@ impl<'a> CommitRefIter<'a> {
3841
state: State::default(),
3942
}
4043
}
44+
}
45+
46+
/// Access
47+
impl<'a> CommitRefIter<'a> {
48+
/// Parse `data` as commit and return its PGP signature, along with *all non-signature* data as [`SignedData`], or `None`
49+
/// if the commit isn't signed.
50+
///
51+
/// This allows the caller to validate the signature by passing the signed data along with the signature back to the program
52+
/// that created it.
53+
pub fn signature(data: &'a [u8]) -> Result<Option<(Cow<'a, BStr>, SignedData<'a>)>, crate::decode::Error> {
54+
let mut signature_and_range = None;
55+
56+
let raw_tokens = CommitRefIterRaw {
57+
data,
58+
state: State::default(),
59+
offset: 0,
60+
};
61+
for token in raw_tokens {
62+
let token = token?;
63+
if let Token::ExtraHeader((name, value)) = &token.token {
64+
if *name == "gpgsig" {
65+
// keep track of the signature range alongside the signature data,
66+
// because all but the signature is the signed data.
67+
signature_and_range = Some((value.clone(), token.token_range));
68+
break;
69+
}
70+
}
71+
}
72+
73+
Ok(signature_and_range.map(|(sig, signature_range)| (sig, SignedData { data, signature_range })))
74+
}
4175

4276
/// Returns the object id of this commits tree if it is the first function called and if there is no error in decoding
4377
/// the data.
@@ -233,6 +267,48 @@ impl<'a> Iterator for CommitRefIter<'a> {
233267
}
234268
}
235269

270+
/// A variation of [`CommitRefIter`] that return's [`RawToken`]s instead.
271+
struct CommitRefIterRaw<'a> {
272+
data: &'a [u8],
273+
state: State,
274+
offset: usize,
275+
}
276+
277+
impl<'a> Iterator for CommitRefIterRaw<'a> {
278+
type Item = Result<RawToken<'a>, crate::decode::Error>;
279+
280+
fn next(&mut self) -> Option<Self::Item> {
281+
if self.data.is_empty() {
282+
return None;
283+
}
284+
match CommitRefIter::next_inner(self.data, &mut self.state) {
285+
Ok((remaining, token)) => {
286+
let consumed = self.data.len() - remaining.len();
287+
let start = self.offset;
288+
let end = start + consumed;
289+
self.offset = end;
290+
291+
self.data = remaining;
292+
Some(Ok(RawToken {
293+
token,
294+
token_range: start..end,
295+
}))
296+
}
297+
Err(err) => {
298+
self.data = &[];
299+
Some(Err(err))
300+
}
301+
}
302+
}
303+
}
304+
305+
/// A combination of a parsed [`Token`] as well as the range of bytes that were consumed to parse it.
306+
struct RawToken<'a> {
307+
/// The parsed token.
308+
token: Token<'a>,
309+
token_range: Range<usize>,
310+
}
311+
236312
/// A token returned by the [commit iterator][CommitRefIter].
237313
#[allow(missing_docs)]
238314
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]

gix-object/tests/commit/from_bytes.rs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -294,23 +294,11 @@ fn merge() -> crate::Result {
294294
Ok(())
295295
}
296296

297-
const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE-----
298-
299-
wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi
300-
5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3
301-
+mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7
302-
QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv
303-
k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr
304-
iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0=
305-
=HLmy
306-
-----END PGP SIGNATURE-----
307-
";
308-
309297
#[test]
310298
fn newline_right_after_signature_multiline_header() -> crate::Result {
311299
let fixture = fixture_name("commit", "signed-whitespace.txt");
312300
let commit = CommitRef::from_bytes(&fixture)?;
313-
let pgp_sig = OTHER_SIGNATURE.as_bstr();
301+
let pgp_sig = crate::commit::OTHER_SIGNATURE.as_bstr();
314302
assert_eq!(commit.extra_headers[0].1.as_ref(), pgp_sig);
315303
assert_eq!(commit.extra_headers().pgp_signature(), Some(pgp_sig));
316304
assert_eq!(commit.extra_headers().find("gpgsig"), Some(pgp_sig));

gix-object/tests/commit/iter.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,60 @@ mod method {
205205
assert_eq!(iter.author().ok(), Some(signature(1592437401)), "it's not consuming");
206206
Ok(())
207207
}
208+
209+
mod signature {
210+
use bstr::{BStr, BString, ByteSlice};
211+
use gix_object::CommitRefIter;
212+
213+
use crate::{
214+
commit::{OTHER_SIGNATURE, SIGNATURE},
215+
fixture_name,
216+
};
217+
218+
fn validate<'a>(
219+
fixture: &str,
220+
expected_signature: impl Into<&'a BStr>,
221+
signature_lines: std::ops::RangeInclusive<usize>,
222+
) -> crate::Result {
223+
let expected_signature = expected_signature.into();
224+
let fixture_data = fixture_name("commit", fixture);
225+
226+
let (actual_signature, actual_signed_data) = CommitRefIter::signature(&fixture_data)?.expect("sig present");
227+
assert_eq!(actual_signature, expected_signature);
228+
229+
let expected_signed_data: BString = fixture_data
230+
.lines_with_terminator()
231+
.enumerate()
232+
.filter_map(|(i, line)| (!signature_lines.contains(&i)).then_some(line))
233+
.collect();
234+
235+
assert_eq!(actual_signed_data.to_bstring(), expected_signed_data);
236+
Ok(())
237+
}
238+
239+
#[test]
240+
fn single_line() -> crate::Result {
241+
validate("signed-singleline.txt", b"magic:signature", 4..=4)
242+
}
243+
244+
#[test]
245+
fn signed() -> crate::Result {
246+
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)
247+
}
248+
249+
#[test]
250+
fn with_encoding() -> crate::Result {
251+
validate("signed-with-encoding.txt", SIGNATURE, 5..=15)
252+
}
253+
254+
#[test]
255+
fn msg_footer() -> crate::Result {
256+
validate("message-with-footer.txt", b"-----BEGIN PGP SIGNATURE-----\n\niHUEABYIAB0WIQSuZwcGWSQItmusNgR5URpSUCnwXQUCYT7xpAAKCRB5URpSUCnw\nXWB3AP9q323HlxnI8MyqszNOeYDwa7Y3yEZaUM2y/IRjz+z4YQEAq0yr1Syt3mrK\nOSFCqL2vDm3uStP+vF31f6FnzayhNg0=\n=Mhpp\n-----END PGP SIGNATURE-----", 4..=10)
257+
}
258+
259+
#[test]
260+
fn whitespace() -> crate::Result {
261+
validate("signed-whitespace.txt", OTHER_SIGNATURE, 5..=15)
262+
}
263+
}
208264
}

gix-object/tests/commit/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ zRo/4HJ04mSQYp0kluP/EBhz9g2wM/htIPyWRveB/ByKEYt3UNKjB++PJmPbu5UG
130130
dS3aXZhRfaPqpdsWrMB9fY7ll+oyfw==
131131
=T+RI
132132
-----END PGP SIGNATURE-----";
133+
134+
const OTHER_SIGNATURE: &[u8; 455] = b"-----BEGIN PGP SIGNATURE-----
135+
136+
wsBcBAABCAAQBQJeqxW4CRBK7hj4Ov3rIwAAdHIIAFD98qgN/k8ybukCLf6kpzvi
137+
5V8gf6BflONXc/oIDySurW7kfS9/r6jOgu08UN8KlQx4Q4g8yY7PROABhwGI70B3
138+
+mHPFcParQf5FBDDZ3GNNpJdlaI9eqzEnFk8AmHmyKHfuGLoclXUObXQ3oe3fmT7
139+
QdTC7JTyk/bPnZ9HQKw7depa3+7Kw4wv4DG8QcW3BG6B9bcE15qaWmOiq0ryRXsv
140+
k7D0LqGSXjU5wrQrKnemC7nWhmQsqaXDe89XXmliClCAx4/bepPiXK0eT/DNIKUr
141+
iyBBl69jASy41Ug/BlFJbw4+ItkShpXwkJKuBBV/JExChmvbxYWaS7QnyYC9UO0=
142+
=HLmy
143+
-----END PGP SIGNATURE-----
144+
";
145+
133146
mod method {
134147
use gix_object::CommitRef;
135148
use pretty_assertions::assert_eq;

gix/src/object/commit.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ impl<'repo> Commit<'repo> {
147147
max_candidates: 10,
148148
}
149149
}
150+
151+
/// Extracts the PGP signature and the data that was used to create the signature, or `None` if it wasn't signed.
152+
// TODO: make it possible to verify the signature, probably by wrapping `SignedData`. It's quite some work to do it properly.
153+
pub fn signature(
154+
&self,
155+
) -> Result<Option<(std::borrow::Cow<'_, BStr>, gix_object::commit::SignedData<'_>)>, gix_object::decode::Error>
156+
{
157+
gix_object::CommitRefIter::signature(&self.data)
158+
}
150159
}
151160

152161
impl<'r> std::fmt::Debug for Commit<'r> {

src/plumbing/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,17 @@ pub fn main() -> Result<()> {
879879
),
880880
},
881881
Subcommands::Commit(cmd) => match cmd {
882+
commit::Subcommands::Verify { rev_spec } => prepare_and_run(
883+
"commit-verify",
884+
trace,
885+
auto_verbose,
886+
progress,
887+
progress_keep_open,
888+
None,
889+
move |_progress, _out, _err| {
890+
core::repository::commit::verify(repository(Mode::Lenient)?, rev_spec.as_deref())
891+
},
892+
),
882893
commit::Subcommands::Describe {
883894
annotated_tags,
884895
all_refs,

src/plumbing/options/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,11 @@ pub mod tree {
472472
pub mod commit {
473473
#[derive(Debug, clap::Subcommand)]
474474
pub enum Subcommands {
475+
/// Verify the signature of a commit.
476+
Verify {
477+
/// A specification of the revision to verify, or the current `HEAD` if unset.
478+
rev_spec: Option<String>,
479+
},
475480
/// Describe the current commit or the given one using the name of the closest annotated tag in its ancestry.
476481
Describe {
477482
/// Use annotated tag references only, not all tags.

0 commit comments

Comments
 (0)