1use anyhow::{Context, Result, bail};
17
18pub 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 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
41pub 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 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#[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 let open = line.rfind('<')?;
117 let close = line[open..].find('>')? + open;
118 Some(line[open + 1..close].to_string())
119}
120
121#[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#[must_use]
158pub fn signer_did(commit: &[u8]) -> Option<String> {
159 trailer_did(commit).or_else(|| committer_did(commit))
160}
161
162#[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
171const SIGNER_DID_KEY: &str = "Signed-by-DID";
173
174const COMMENT_PREFIX: char = '#';
181
182const GIT_GENERATED_PREFIXES: [&str; 2] = ["Signed-off-by: ", "(cherry picked from commit "];
190
191fn 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 Some(
219 value
220 .split(['#', '?', '/'])
221 .next()
222 .unwrap_or(value)
223 .to_string(),
224 )
225}
226
227fn last_trailer_value(message: &str, key: &str) -> Option<String> {
236 let mut lines: Vec<&str> = message.split('\n').collect();
237 if lines.last().is_some_and(|line| line.is_empty()) {
239 lines.pop();
240 }
241 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 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 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 value.map(|value| unfold(value.trim_ascii()))
284}
285
286fn 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
305fn trailer_block_start(lines: &[&str]) -> Option<usize> {
314 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 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 } 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
376fn 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
401fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}