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