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)>> {
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#[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 let open = line.rfind('<')?;
94 let close = line[open..].find('>')? + open;
95 Some(line[open + 1..close].to_string())
96}
97
98#[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#[must_use]
135pub fn signer_did(commit: &[u8]) -> Option<String> {
136 trailer_did(commit).or_else(|| committer_did(commit))
137}
138
139#[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
148const SIGNER_DID_KEY: &str = "Signed-by-DID";
150
151const COMMENT_PREFIX: char = '#';
158
159const GIT_GENERATED_PREFIXES: [&str; 2] = ["Signed-off-by: ", "(cherry picked from commit "];
167
168fn 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 Some(
196 value
197 .split(['#', '?', '/'])
198 .next()
199 .unwrap_or(value)
200 .to_string(),
201 )
202}
203
204fn last_trailer_value(message: &str, key: &str) -> Option<String> {
213 let mut lines: Vec<&str> = message.split('\n').collect();
214 if lines.last().is_some_and(|line| line.is_empty()) {
216 lines.pop();
217 }
218 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 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 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 value.map(|value| unfold(value.trim_ascii()))
261}
262
263fn 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
282fn trailer_block_start(lines: &[&str]) -> Option<usize> {
291 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 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 } 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
353fn 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
378fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}