Skip to main content

vgi_core/
commit.rs

1//! Git commit-object handling for signature verification.
2//!
3//! Git signs the commit object with its `gpgsig` header removed;
4//! [`split_signed_commit`] reconstructs the exact signed bytes and recovers the
5//! armored signature. [`normalize_sshsig_armor`] re-wraps an sshsig body to the
6//! 70-column width strict PEM parsers require. [`committer_did`] reads the
7//! signer identity a commit claims on its `committer` header.
8//!
9//! [`signer_did`] prefers the claim in the commit message's `Signed-by-DID:`
10//! trailer. That trailer block is located with git's own rules, ported from
11//! `find_trailer_block_start` in git's `trailer.c`, so that the DID which gets
12//! verified is the one `git log --format='%(trailers:…)'`, `git
13//! interpret-trailers --parse` and git-based review UIs show. The port is held
14//! to real git by `tests/trailer_differential.rs`.
15
16use anyhow::{Context, Result, bail};
17
18/// Re-wrap an sshsig armor's base64 body at 70 columns.
19///
20/// OpenSSH's own base64 reader accepts any line width, but the strict PEM
21/// parser underneath `SshSig::from_pem` requires exactly the 70-column
22/// wrapping ssh-keygen emits. Signatures created by did-git-sign before it
23/// matched ssh-keygen's width (76 columns) live on in git history, so the
24/// armor is normalized rather than trusted to be canonical.
25pub fn normalize_sshsig_armor(pem: &str) -> String {
26    let body: String = pem
27        .lines()
28        .filter(|line| !line.starts_with("-----"))
29        .map(str::trim)
30        .collect();
31    let mut normalized = String::from("-----BEGIN SSH SIGNATURE-----\n");
32    for chunk in body.as_bytes().chunks(70) {
33        // Chunks of an ASCII base64 string are always valid UTF-8.
34        normalized.push_str(&String::from_utf8_lossy(chunk));
35        normalized.push('\n');
36    }
37    normalized.push_str("-----END SSH SIGNATURE-----\n");
38    normalized
39}
40
41/// Split a raw commit object into (payload-as-signed, armored signature).
42///
43/// Git signs the commit object with the `gpgsig` header removed; the header's
44/// value spans continuation lines (each prefixed with one space). Returns
45/// `Ok(None)` for an unsigned commit.
46///
47/// Every *other* header whose name starts with `gpgsig` (`gpgsig-sha256`, or a
48/// made-up `gpgsig-foo`) is removed too, continuation lines and all, because
49/// git removes it: that is the "other signature" arm of
50/// `parse_buffer_signed_by_header` in git's `commit.c`. Keeping such a header
51/// made this payload a superset of git's, so anyone could add one to a
52/// validly signed commit — fsck-clean, still GOOD to git — and have it
53/// reported here as a bad signature by its real signer.
54pub fn split_signed_commit(raw: &[u8]) -> Result<Option<(Vec<u8>, String)>> {
55    #[derive(PartialEq)]
56    enum Header {
57        Signature,
58        OtherSignature,
59        Kept,
60    }
61
62    let text = std::str::from_utf8(raw).context("commit object is not UTF-8")?;
63    let Some((headers, body)) = text.split_once("\n\n") else {
64        bail!("malformed commit object: no header/body separator");
65    };
66
67    let mut kept_headers: Vec<&str> = Vec::new();
68    let mut signature_lines: Vec<&str> = Vec::new();
69    let mut current = Header::Kept;
70    for line in headers.split('\n') {
71        // Same order as git: a continuation line belongs to the header before
72        // it, and is tested before any header name.
73        if current == Header::Signature
74            && let Some(continuation) = line.strip_prefix(' ')
75        {
76            signature_lines.push(continuation);
77        } else if let Some(first) = line.strip_prefix("gpgsig ") {
78            current = Header::Signature;
79            signature_lines.push(first);
80        } else if line.starts_with("gpgsig")
81            || (current == Header::OtherSignature && line.starts_with(' '))
82        {
83            current = Header::OtherSignature;
84        } else {
85            current = Header::Kept;
86            kept_headers.push(line);
87        }
88    }
89
90    if signature_lines.is_empty() {
91        return Ok(None);
92    }
93
94    let mut payload = kept_headers.join("\n").into_bytes();
95    payload.extend_from_slice(b"\n\n");
96    payload.extend_from_slice(body.as_bytes());
97
98    let mut pem = signature_lines.join("\n");
99    pem.push('\n');
100    Ok(Some((payload, pem)))
101}
102
103/// The committer identity: the `<…>` field of the `committer` header.
104///
105/// Read from the header block only, so a body line that happens to begin with
106/// `committer ` cannot be mistaken for the header. Returns `None` for a commit
107/// with no committer header or no angle-bracketed identity.
108#[must_use]
109pub fn committer_identity(commit: &[u8]) -> Option<String> {
110    let text = std::str::from_utf8(commit).ok()?;
111    let headers = text.split_once("\n\n").map_or(text, |(headers, _)| headers);
112    let line = headers
113        .split('\n')
114        .find_map(|line| line.strip_prefix("committer "))?;
115    // `rfind` so a display name containing '<' cannot truncate the identity.
116    let open = line.rfind('<')?;
117    let close = line[open..].find('>')? + open;
118    Some(line[open + 1..close].to_string())
119}
120
121/// The signer DID a commit claims: its committer identity when that is a DID,
122/// reduced to the bare DID.
123///
124/// `did-git-sign` sets `user.email` to the verification-method id it signs
125/// with (`did:webvh:…#key-0`); the fragment names *which* key, while the DID
126/// is the identity to resolve and to ask the registry about, so any
127/// fragment, path or query is stripped.
128///
129/// This is a **claim**, not an authenticated fact — the committer header is
130/// author-controlled text. It is safe to use only as a lookup hint whose
131/// answer is then checked: the DID must publish the key that actually signed,
132/// and the signature must verify over a payload that includes this very
133/// header. A commit claiming a DID it cannot sign for fails both checks.
134#[must_use]
135pub fn committer_did(commit: &[u8]) -> Option<String> {
136    let identity = committer_identity(commit)?;
137    if !identity.starts_with("did:") {
138        return None;
139    }
140    let did = identity
141        .split(['#', '?', '/'])
142        .next()
143        .unwrap_or(identity.as_str());
144    if did.is_empty() {
145        return None;
146    }
147    Some(did.to_string())
148}
149
150/// The signer DID a commit claims, checking the `Signed-by-DID:` trailer
151/// first, then falling back to the committer email for legacy commits.
152///
153/// The trailer is the canonical location for new commits (it lets
154/// `user.email` be a normal email for git-host attribution). Old commits
155/// that carried the DID in the committer email still verify via the
156/// fallback.
157#[must_use]
158pub fn signer_did(commit: &[u8]) -> Option<String> {
159    trailer_did(commit).or_else(|| committer_did(commit))
160}
161
162/// Return both explicit identity claims when the final `Signed-by-DID:`
163/// trailer and legacy DID committer identity disagree.
164#[must_use]
165pub fn conflicting_signer_dids(commit: &[u8]) -> Option<(String, String)> {
166    let trailer = trailer_did(commit)?;
167    let committer = committer_did(commit)?;
168    (trailer != committer).then_some((trailer, committer))
169}
170
171/// The trailer key that carries the signer DID.
172const SIGNER_DID_KEY: &str = "Signed-by-DID";
173
174/// Git's default comment prefix (`core.commentChar`).
175///
176/// This code reads no git configuration, so a repository that sets a
177/// different comment character can have git see a trailer block where this
178/// does not. That direction only loses a DID claim, and a lost claim fails
179/// closed.
180const COMMENT_PREFIX: char = '#';
181
182/// The prefixes git treats as its own generated trailers
183/// (`git_generated_prefixes` in git's `trailer.c`).
184///
185/// A line starting with one of these counts as a trailer line *and* unlocks
186/// the 25%-non-trailer allowance in [`trailer_block_start`], whether or not
187/// the line has a separator — which is why `(cherry picked from commit …)`,
188/// with no `:` in it at all, can turn a mixed paragraph into a trailer block.
189const GIT_GENERATED_PREFIXES: [&str; 2] = ["Signed-off-by: ", "(cherry picked from commit "];
190
191/// Extract a bare DID from the `Signed-by-DID:` trailer of the commit
192/// message's trailer block.
193///
194/// The trailer block is located with git's own rules rather than an
195/// approximation of them, because the risk here is a *display* differential:
196/// a reviewer reads the DID that `git log --format='%(trailers:…)'`, `git
197/// interpret-trailers --parse` and every git-based UI report, so the DID that
198/// verify-trust checks has to be that same one. Where the two disagree the
199/// commit either claims a DID no reviewer is shown, or shows a DID nobody
200/// checked. `crates/vgi-core/tests/trailer_differential.rs` holds git to this
201/// by running both git commands over generated messages.
202///
203/// The claim is the *last* `Signed-by-DID` trailer git reports, and it is a
204/// claim only when that trailer's value is a DID. An earlier trailer is never
205/// promoted when a later one is not a DID: the last trailer is what a reader
206/// scanning to the bottom of the block sees, so preferring an earlier one
207/// would hide the checked DID behind it.
208fn trailer_did(commit: &[u8]) -> Option<String> {
209    let text = std::str::from_utf8(commit).ok()?;
210    let (_, message) = text.split_once("\n\n")?;
211    let value = last_trailer_value(message, SIGNER_DID_KEY)?;
212    let value = value.trim_ascii();
213    if !value.starts_with("did:") {
214        return None;
215    }
216    // The fragment names which key signed; the DID is the identity to resolve
217    // and to ask the registry about.
218    Some(
219        value
220            .split(['#', '?', '/'])
221            .next()
222            .unwrap_or(value)
223            .to_string(),
224    )
225}
226
227/// The value of the last trailer named `key` in `message`'s trailer block,
228/// unfolded as git unfolds a continuation line.
229///
230/// Mirrors git's `trailer_block_get`: the block is split into entries, a line
231/// whose first character is whitespace continues the entry before it (but
232/// only when that entry had a separator), and every other line starts a new
233/// entry. Key matching is case-insensitive, as it is for git's
234/// `%(trailers:key=…)`.
235fn last_trailer_value(message: &str, key: &str) -> Option<String> {
236    let mut lines: Vec<&str> = message.split('\n').collect();
237    // A message ending in a newline has no empty final line in git's view.
238    if lines.last().is_some_and(|line| line.is_empty()) {
239        lines.pop();
240    }
241    // git presents a commit's message from its subject onward: pretty.c's
242    // `parse_commit_message` runs `skip_blank_lines` before recording where the
243    // subject starts, and `%(trailers:…)` reads from there. So blank lines at
244    // the very start of a message are not part of it. Keeping them would make
245    // the subject look like a second paragraph, and turn a trailer-shaped
246    // subject line — which `git log` and GitHub both display as the subject —
247    // into a trailer nobody is shown.
248    let before_subject = lines.iter().take_while(|line| is_blank(line)).count();
249    let lines = &lines[before_subject..];
250
251    let start = trailer_block_start(lines)?;
252
253    let mut value: Option<String> = None;
254    // Whether an entry is open for continuation lines, and whether that entry
255    // is the one being looked for. A line without a separator (a comment or a
256    // non-trailer line inside the block) opens nothing, so a continuation
257    // after it is not folded into the trailer before it.
258    let mut open: Option<bool> = None;
259    for line in &lines[start..] {
260        if open.is_some() && line.starts_with(|c: char| c.is_ascii_whitespace()) {
261            if open == Some(true)
262                && let Some(value) = value.as_mut()
263            {
264                // Keep the raw text, newline and all: git concatenates the
265                // continuation onto the value and unfolds once, at the end.
266                value.push('\n');
267                value.push_str(line);
268            }
269            continue;
270        }
271        match separator_pos(line) {
272            Some(position) => {
273                let matched = line[..position].trim_ascii().eq_ignore_ascii_case(key);
274                if matched {
275                    value = Some(line[position + 1..].to_string());
276                }
277                open = Some(matched);
278            }
279            None => open = None,
280        }
281    }
282    // git trims the assembled value, then unfolds it.
283    value.map(|value| unfold(value.trim_ascii()))
284}
285
286/// Collapse every newline and the whitespace that follows it down to a single
287/// space, as git's `unfold_value` does, then trim.
288///
289/// Whitespace *before* a newline is left alone, so a trailer value with
290/// trailing spaces and a continuation line under it keeps those spaces and
291/// gains one more for the fold — which is the text git reports, and is why the
292/// value cannot be trimmed line by line as it is assembled.
293fn unfold(value: &str) -> String {
294    let mut unfolded = String::with_capacity(value.len());
295    let mut rest = value;
296    while let Some(newline) = rest.find('\n') {
297        unfolded.push_str(&rest[..newline]);
298        unfolded.push(' ');
299        rest = rest[newline + 1..].trim_ascii_start();
300    }
301    unfolded.push_str(rest);
302    unfolded.trim_ascii().to_string()
303}
304
305/// The index of the first line of the message's trailer block, following
306/// git's `find_trailer_block_start` (`trailer.c`). `None` when git would see
307/// no trailer block at all.
308///
309/// Only the final paragraph can be a trailer block, it cannot be the title
310/// paragraph, and it qualifies when either every line in it is a trailer, or
311/// it holds one of git's own generated trailers and is at least 25% trailer
312/// lines.
313fn trailer_block_start(lines: &[&str]) -> Option<usize> {
314    // The first paragraph is the title and cannot hold trailers, so the scan
315    // below stops at the blank line ending it. With no blank line anywhere
316    // the message is all title, and so has no trailer block.
317    let end_of_title = lines
318        .iter()
319        .position(|line| !line.starts_with(COMMENT_PREFIX) && is_blank(line))?;
320
321    let mut recognized_prefix = false;
322    let mut trailer_lines = 0_usize;
323    let mut non_trailer_lines = 0_usize;
324    // Lines that are continuations if a trailer turns up above them, and
325    // non-trailers if a non-trailer does.
326    let mut possible_continuation_lines = 0_usize;
327    let mut only_spaces = true;
328
329    for index in (end_of_title..lines.len()).rev() {
330        let line = lines[index];
331        if line.starts_with(COMMENT_PREFIX) {
332            non_trailer_lines += possible_continuation_lines;
333            possible_continuation_lines = 0;
334            continue;
335        }
336        if is_blank(line) {
337            if only_spaces {
338                continue;
339            }
340            non_trailer_lines += possible_continuation_lines;
341            if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
342                return Some(index + 1);
343            }
344            if trailer_lines > 0 && non_trailer_lines == 0 {
345                return Some(index + 1);
346            }
347            return None;
348        }
349        only_spaces = false;
350
351        if GIT_GENERATED_PREFIXES
352            .iter()
353            .any(|prefix| line.starts_with(prefix))
354        {
355            trailer_lines += 1;
356            possible_continuation_lines = 0;
357            recognized_prefix = true;
358        } else if separator_pos(line).is_some() {
359            trailer_lines += 1;
360            possible_continuation_lines = 0;
361            // git also sets `recognized_prefix` here for a key named in
362            // `trailer.<token>.key` configuration. This reads no git config,
363            // so only git's own prefixes above unlock the 25% allowance; a
364            // repository that configures more of them can have git see a
365            // block this does not, which loses a claim and fails closed.
366        } else if line.starts_with(|c: char| c.is_ascii_whitespace()) {
367            possible_continuation_lines += 1;
368        } else {
369            non_trailer_lines += 1 + possible_continuation_lines;
370            possible_continuation_lines = 0;
371        }
372    }
373    None
374}
375
376/// The offset of the `:` that ends a trailer key, following git's
377/// `find_separator` for its default separator set.
378///
379/// The key is alphanumerics and `-`, optionally followed by spaces or tabs
380/// before the colon, and the colon may not be the first character. A line
381/// starting with whitespace never has one, which is what makes it a
382/// continuation line rather than a trailer.
383fn separator_pos(line: &str) -> Option<usize> {
384    let mut whitespace_found = false;
385    for (offset, c) in line.char_indices() {
386        if c == ':' {
387            return (offset >= 1).then_some(offset);
388        }
389        if !whitespace_found && (c.is_ascii_alphanumeric() || c == '-') {
390            continue;
391        }
392        if offset != 0 && (c == ' ' || c == '\t') {
393            whitespace_found = true;
394            continue;
395        }
396        return None;
397    }
398    None
399}
400
401/// Whether a line is blank in git's sense: empty, or only whitespace.
402fn is_blank(line: &str) -> bool {
403    line.trim_ascii().is_empty()
404}
405
406#[cfg(test)]
407mod tests {
408    #![allow(clippy::unwrap_used)]
409
410    use super::*;
411
412    fn commit_with_committer(committer: &str) -> String {
413        format!(
414            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
415             author A U Thor <a@example.com> 1700000000 +0000\n\
416             committer {committer} 1700000000 +0000\n\
417             \n\
418             a message\n"
419        )
420    }
421
422    #[test]
423    fn a_did_committer_yields_the_bare_did() {
424        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
425        assert_eq!(
426            committer_did(commit.as_bytes()).unwrap(),
427            "did:webvh:QmAbc:example.com",
428            "the fragment names the key, not the identity the registry knows"
429        );
430    }
431
432    #[test]
433    fn a_did_without_a_fragment_survives_intact() {
434        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
435        assert_eq!(
436            committer_did(commit.as_bytes()).unwrap(),
437            "did:webvh:QmAbc:example.com"
438        );
439    }
440
441    #[test]
442    fn a_plain_email_committer_claims_no_did() {
443        let commit = commit_with_committer("Alice <alice@example.com>");
444        assert!(committer_did(commit.as_bytes()).is_none());
445        assert_eq!(
446            committer_identity(commit.as_bytes()).unwrap(),
447            "alice@example.com",
448            "the identity is still reported, so the failure can name it"
449        );
450    }
451
452    #[test]
453    fn a_body_line_cannot_impersonate_the_committer_header() {
454        // The header block ends at the first blank line; everything after it
455        // is the message, where an author controls every byte.
456        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
457             author A U Thor <a@example.com> 1700000000 +0000\n\
458             committer A U Thor <alice@example.com> 1700000000 +0000\n\
459             \n\
460             committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
461        assert!(
462            committer_did(commit.as_bytes()).is_none(),
463            "a DID in the message body must not be read as the committer"
464        );
465    }
466
467    #[test]
468    fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
469        let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
470        assert_eq!(
471            committer_did(commit.as_bytes()).unwrap(),
472            "did:webvh:QmAbc:example.com"
473        );
474    }
475
476    #[test]
477    fn a_signed_commits_payload_still_exposes_the_committer() {
478        // The committer header is a kept header, so it survives the gpgsig
479        // strip and is covered by the signature.
480        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
481        let (headers, body) = commit.split_once("\n\n").unwrap();
482        let signed = format!(
483            "{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
484             AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
485        );
486        let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
487        assert_eq!(
488            committer_did(&payload).unwrap(),
489            "did:webvh:QmAbc:example.com"
490        );
491    }
492
493    /// A signed commit with `extra` header lines inserted before or after the
494    /// `gpgsig` block, and the payload git signed (no `extra`, no `gpgsig`).
495    fn signed_with_extra_headers(extra: &str, after_signature: bool) -> (String, String) {
496        let head = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
497             author A U Thor <a@example.com> 1700000000 +0000\n\
498             committer A U Thor <a@example.com> 1700000000 +0000";
499        let signature = "gpgsig -----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----";
500        let headers = if after_signature {
501            format!("{head}\n{signature}\n{extra}")
502        } else {
503            format!("{head}\n{extra}\n{signature}")
504        };
505        (
506            format!("{headers}\n\na message\n"),
507            format!("{head}\n\na message\n"),
508        )
509    }
510
511    #[test]
512    fn other_gpgsig_headers_are_removed_from_the_payload_like_git() {
513        // Each of these is dropped by git's `parse_buffer_signed_by_header`
514        // (checked against git 2.50 with `git verify-commit`), so each must be
515        // dropped here or a signature git calls good is reported bad.
516        for extra in [
517            "gpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n AAAA\n -----END PGP SIGNATURE-----",
518            "gpgsig-sha256 ",
519            "gpgsigx junk",
520            "gpgsig-foo bar",
521            "gpgsig",
522        ] {
523            for after_signature in [false, true] {
524                let (commit, expected) = signed_with_extra_headers(extra, after_signature);
525                let (payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
526                assert_eq!(
527                    String::from_utf8(payload).unwrap(),
528                    expected,
529                    "{extra:?} (after the signature: {after_signature})"
530                );
531                assert_eq!(
532                    pem, "-----BEGIN SSH SIGNATURE-----\nAAAA\n-----END SSH SIGNATURE-----\n",
533                    "the other header must not leak into the signature"
534                );
535            }
536        }
537    }
538
539    #[test]
540    fn a_header_that_merely_contains_gpgsig_is_kept() {
541        // git keeps every header not *starting* with `gpgsig`, so these stay
542        // in the signed payload.
543        let (commit, _) = signed_with_extra_headers("x-gpgsig junk", false);
544        let (payload, _) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
545        assert!(
546            String::from_utf8(payload)
547                .unwrap()
548                .contains("\nx-gpgsig junk\n")
549        );
550    }
551
552    #[test]
553    fn a_header_after_another_signature_is_kept() {
554        // The other signature's continuation lines go with it; the next real
555        // header does not.
556        let (commit, expected) =
557            signed_with_extra_headers("gpgsig-sha256 first\n second\nencoding UTF-8", true);
558        let (payload, _) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
559        let expected = expected.replace("\n\na message", "\nencoding UTF-8\n\na message");
560        assert_eq!(String::from_utf8(payload).unwrap(), expected);
561    }
562
563    #[test]
564    fn a_commit_signed_only_by_another_header_is_unsigned() {
565        // A SHA-1 repository's signature is `gpgsig`; with none, there is
566        // nothing to verify, whatever other signature headers exist.
567        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
568             committer A U Thor <a@example.com> 1700000000 +0000\n\
569             gpgsig-sha256 -----BEGIN SSH SIGNATURE-----\n AAAA\n\
570             \n\
571             a message\n";
572        assert!(split_signed_commit(commit.as_bytes()).unwrap().is_none());
573    }
574
575    fn commit_with_trailer(committer: &str, trailer: &str) -> String {
576        format!(
577            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
578             author A U Thor <a@example.com> 1700000000 +0000\n\
579             committer {committer} 1700000000 +0000\n\
580             \n\
581             a message\n\
582             \n\
583             {trailer}\n"
584        )
585    }
586
587    #[test]
588    fn signer_did_prefers_trailer_over_committer() {
589        let commit = commit_with_trailer(
590            "Alice <did:webvh:QmOld:old.example#key-0>",
591            "Signed-by-DID: did:webvh:QmNew:new.example#key-0",
592        );
593        assert_eq!(
594            signer_did(commit.as_bytes()).unwrap(),
595            "did:webvh:QmNew:new.example",
596            "trailer must take precedence over committer email"
597        );
598    }
599
600    #[test]
601    fn signer_did_falls_back_to_committer_for_legacy_commits() {
602        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
603        assert_eq!(
604            signer_did(commit.as_bytes()).unwrap(),
605            "did:webvh:QmAbc:example.com",
606            "legacy commits with DID in committer email must still work"
607        );
608    }
609
610    #[test]
611    fn signer_did_reads_trailer_with_normal_email_committer() {
612        let commit = commit_with_trailer(
613            "Alice <alice@example.com>",
614            "Signed-by-DID: did:webvh:QmAbc:example.com#key-0",
615        );
616        assert_eq!(
617            signer_did(commit.as_bytes()).unwrap(),
618            "did:webvh:QmAbc:example.com",
619        );
620    }
621
622    #[test]
623    fn signer_did_returns_none_without_did_anywhere() {
624        let commit = commit_with_committer("Alice <alice@example.com>");
625        assert!(signer_did(commit.as_bytes()).is_none());
626    }
627
628    #[test]
629    fn trailer_strips_fragment() {
630        let commit = commit_with_trailer(
631            "Alice <alice@example.com>",
632            "Signed-by-DID: did:webvh:QmAbc:example.com#key-1",
633        );
634        assert_eq!(
635            signer_did(commit.as_bytes()).unwrap(),
636            "did:webvh:QmAbc:example.com",
637        );
638    }
639
640    #[test]
641    fn trailer_ignores_non_did_values() {
642        let commit = commit_with_trailer("Alice <alice@example.com>", "Signed-by-DID: not-a-did");
643        assert!(signer_did(commit.as_bytes()).is_none());
644    }
645
646    #[test]
647    fn signer_did_ignores_body_line_outside_final_trailer_block() {
648        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
649             author A U Thor <a@example.com> 1700000000 +0000\n\
650             committer Alice <alice@example.com> 1700000000 +0000\n\
651             \n\
652             This line only discusses a trailer.\n\
653             Signed-by-DID: did:webvh:QmBody:example.com#key-0\n\
654             \n\
655             final prose, not a trailer block\n";
656        assert!(signer_did(commit.as_bytes()).is_none());
657    }
658
659    #[test]
660    fn signer_did_reads_final_trailer_block_only() {
661        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
662             author A U Thor <a@example.com> 1700000000 +0000\n\
663             committer Alice <alice@example.com> 1700000000 +0000\n\
664             \n\
665             Signed-by-DID: did:webvh:QmBody:ignored.example#key-0\n\
666             \n\
667             body text\n\
668             \n\
669             Signed-off-by: Alice <alice@example.com>\n\
670             Signed-by-DID: did:webvh:QmTrailer:example.com#key-0\n";
671        assert_eq!(
672            signer_did(commit.as_bytes()).unwrap(),
673            "did:webvh:QmTrailer:example.com"
674        );
675    }
676
677    #[test]
678    fn conflicting_signer_dids_reports_trailer_and_committer_disagreement() {
679        let commit = commit_with_trailer(
680            "Alice <did:webvh:QmCommitter:example.com#key-0>",
681            "Signed-by-DID: did:webvh:QmTrailer:example.com#key-0",
682        );
683        assert_eq!(
684            conflicting_signer_dids(commit.as_bytes()).unwrap(),
685            (
686                "did:webvh:QmTrailer:example.com".to_string(),
687                "did:webvh:QmCommitter:example.com".to_string(),
688            )
689        );
690    }
691
692    // Git's trailer-block rules, as fast assertions that do not need `git` on
693    // PATH. Every one of them is also checked against real git, over
694    // generated messages, by `tests/trailer_differential.rs`.
695
696    /// A commit whose committer is a plain email, so a DID claim can only come
697    /// from the trailer.
698    fn commit_with_body(body: &str) -> String {
699        format!(
700            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
701             author A U Thor <a@example.com> 1700000000 +0000\n\
702             committer Alice <alice@example.com> 1700000000 +0000\n\
703             \n\
704             {body}"
705        )
706    }
707
708    const DID: &str = "did:webvh:QmA:example.com";
709
710    #[test]
711    fn a_trailer_in_a_mixed_paragraph_is_not_a_claim() {
712        // git accepts the final paragraph as trailers only when every line is
713        // a trailer, or when one of git's own trailers is in it. A
714        // `Signed-by-DID` line appended to a prose paragraph is not a trailer
715        // to git, so it must not be a claim here: it would be a DID that no
716        // reviewer's tooling shows as one.
717        let commit = commit_with_body(&format!(
718            "subject\n\nprose about the change\nSigned-by-DID: {DID}\n"
719        ));
720        assert!(trailer_did(commit.as_bytes()).is_none());
721    }
722
723    #[test]
724    fn a_trailer_in_the_title_paragraph_is_not_a_claim() {
725        // The first paragraph is the title, and git never reads trailers from
726        // it — not when it is the whole message, and not when the trailer is
727        // the line under the subject with no blank line between.
728        let commit = commit_with_body(&format!("Signed-by-DID: {DID}\n"));
729        assert!(trailer_did(commit.as_bytes()).is_none());
730        let commit = commit_with_body(&format!("subject\nSigned-by-DID: {DID}\n"));
731        assert!(trailer_did(commit.as_bytes()).is_none());
732    }
733
734    #[test]
735    fn whitespace_before_the_colon_still_names_the_trailer() {
736        // git's key scan allows spaces and tabs between the key and the
737        // colon, and trims them, so these are all the same trailer to it.
738        for gap in ["", " ", "  ", "\t", " \t"] {
739            let commit = commit_with_body(&format!("subject\n\nSigned-by-DID{gap}: {DID}#key-0\n"));
740            assert_eq!(
741                trailer_did(commit.as_bytes()).as_deref(),
742                Some(DID),
743                "gap {gap:?} must not hide the claim"
744            );
745        }
746    }
747
748    #[test]
749    fn the_trailer_key_is_matched_case_insensitively() {
750        // As `%(trailers:key=…)` matches it.
751        for key in [
752            "Signed-by-DID",
753            "signed-by-did",
754            "SIGNED-BY-DID",
755            "Signed-By-Did",
756        ] {
757            let commit = commit_with_body(&format!("subject\n\n{key}: {DID}#key-0\n"));
758            assert_eq!(
759                trailer_did(commit.as_bytes()).as_deref(),
760                Some(DID),
761                "key {key:?} must be recognized"
762            );
763        }
764    }
765
766    #[test]
767    fn a_folded_trailer_value_is_unfolded_like_git() {
768        // git folds a continuation line into the value with a single space and
769        // displays it that way. The result is not a resolvable DID, so the
770        // commit fails closed — but it fails on the same text git shows,
771        // rather than on a truncated prefix of it.
772        let commit = commit_with_body(&format!("subject\n\nSigned-by-DID: {DID}\n  and more\n"));
773        assert_eq!(
774            trailer_did(commit.as_bytes()).as_deref(),
775            Some("did:webvh:QmA:example.com and more")
776        );
777        // Only the whitespace *after* the newline collapses. The three spaces
778        // before it are part of the value, so git reports them and the fold's
779        // single space — four in all. Trimming the first line as it is read
780        // would report one.
781        let commit = commit_with_body(&format!("subject\n\nSigned-by-DID: {DID}   \n continued\n"));
782        assert_eq!(
783            trailer_did(commit.as_bytes()).as_deref(),
784            Some("did:webvh:QmA:example.com    continued")
785        );
786    }
787
788    #[test]
789    fn a_git_generated_trailer_unlocks_the_25_percent_allowance() {
790        // With a `Signed-off-by:` in the block, git tolerates non-trailer
791        // lines while trailers are at least a quarter of it…
792        let commit = commit_with_body(&format!(
793            "subject\n\nn1\nn2\nn3\nSigned-off-by: A U Thor <a@example.com>\nSigned-by-DID: {DID}\n"
794        ));
795        assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
796        // …and past that boundary sees no trailer block at all.
797        let commit = commit_with_body(&format!(
798            "subject\n\nn1\nn2\nn3\nn4\nn5\nn6\nn7\n\
799             Signed-off-by: A U Thor <a@example.com>\nSigned-by-DID: {DID}\n"
800        ));
801        assert!(trailer_did(commit.as_bytes()).is_none());
802    }
803
804    #[test]
805    fn a_cherry_pick_line_unlocks_the_allowance_without_a_separator() {
806        // `(cherry picked from commit …)` holds no colon, so it is not a
807        // trailer line by the separator rule, yet git counts it as one of its
808        // own and lets the block through.
809        let commit = commit_with_body(&format!(
810            "subject\n\nprose\n\
811             (cherry picked from commit 0123456789abcdef0123456789abcdef01234567)\n\
812             Signed-by-DID: {DID}\n"
813        ));
814        assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
815    }
816
817    #[test]
818    fn a_line_whose_colon_comes_first_defeats_the_block() {
819        // A separator at offset 0 is not a trailer to git, so the paragraph is
820        // neither all trailers nor git-generated, and holds no claim.
821        let commit = commit_with_body(&format!(
822            "subject\n\n: did:webvh:QmEvil:attacker.example\nSigned-by-DID: {DID}\n"
823        ));
824        assert!(trailer_did(commit.as_bytes()).is_none());
825    }
826
827    #[test]
828    fn the_last_trailer_wins_even_when_its_value_is_not_a_did() {
829        // git reports both trailers, and the last one is what a reader
830        // scanning to the bottom of the block sees, so an earlier DID is not
831        // promoted over it.
832        let commit = commit_with_body(
833            "subject\n\nSigned-by-DID: did:webvh:QmFirst:example.com\nSigned-by-DID: see below\n",
834        );
835        assert!(trailer_did(commit.as_bytes()).is_none());
836    }
837
838    #[test]
839    fn blank_lines_before_the_subject_are_skipped_like_git() {
840        // git shows a commit's message from its subject onward, skipping blank
841        // lines before it. So in each of these the `Signed-by-DID` line *is*
842        // the subject, with no trailer block under it, and reading a claim
843        // here would verify a DID that `git log` and GitHub display as the
844        // commit's subject line.
845        for prefix in ["\n", "   \n", "\t\n", "\n\n"] {
846            let commit = commit_with_body(&format!("{prefix}Signed-by-DID: {DID}\n"));
847            assert!(
848                trailer_did(commit.as_bytes()).is_none(),
849                "with prefix {prefix:?} the trailer line is the subject git shows"
850            );
851        }
852        // The skip must not cost a real trailer block further down.
853        let commit = commit_with_body(&format!("\nsubject\n\nSigned-by-DID: {DID}\n"));
854        assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
855    }
856
857    #[test]
858    fn comment_lines_do_not_count_against_the_block() {
859        // git skips them when deciding what the block is.
860        let commit = commit_with_body(&format!("subject\n\n# a comment\nSigned-by-DID: {DID}\n"));
861        assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
862    }
863}