use anyhow::{Context, Result, bail};
pub fn normalize_sshsig_armor(pem: &str) -> String {
let body: String = pem
.lines()
.filter(|line| !line.starts_with("-----"))
.map(str::trim)
.collect();
let mut normalized = String::from("-----BEGIN SSH SIGNATURE-----\n");
for chunk in body.as_bytes().chunks(70) {
normalized.push_str(&String::from_utf8_lossy(chunk));
normalized.push('\n');
}
normalized.push_str("-----END SSH SIGNATURE-----\n");
normalized
}
pub fn split_signed_commit(raw: &[u8]) -> Result<Option<(Vec<u8>, String)>> {
let text = std::str::from_utf8(raw).context("commit object is not UTF-8")?;
let Some((headers, body)) = text.split_once("\n\n") else {
bail!("malformed commit object: no header/body separator");
};
let mut kept_headers: Vec<&str> = Vec::new();
let mut signature_lines: Vec<&str> = Vec::new();
let mut in_gpgsig = false;
for line in headers.split('\n') {
if let Some(first) = line.strip_prefix("gpgsig ") {
in_gpgsig = true;
signature_lines.push(first);
} else if in_gpgsig && let Some(continuation) = line.strip_prefix(' ') {
signature_lines.push(continuation);
} else {
in_gpgsig = false;
kept_headers.push(line);
}
}
if signature_lines.is_empty() {
return Ok(None);
}
let mut payload = kept_headers.join("\n").into_bytes();
payload.extend_from_slice(b"\n\n");
payload.extend_from_slice(body.as_bytes());
let mut pem = signature_lines.join("\n");
pem.push('\n');
Ok(Some((payload, pem)))
}
#[must_use]
pub fn committer_identity(commit: &[u8]) -> Option<String> {
let text = std::str::from_utf8(commit).ok()?;
let headers = text.split_once("\n\n").map_or(text, |(headers, _)| headers);
let line = headers
.split('\n')
.find_map(|line| line.strip_prefix("committer "))?;
let open = line.rfind('<')?;
let close = line[open..].find('>')? + open;
Some(line[open + 1..close].to_string())
}
#[must_use]
pub fn committer_did(commit: &[u8]) -> Option<String> {
let identity = committer_identity(commit)?;
if !identity.starts_with("did:") {
return None;
}
let did = identity
.split(['#', '?', '/'])
.next()
.unwrap_or(identity.as_str());
if did.is_empty() {
return None;
}
Some(did.to_string())
}
#[must_use]
pub fn signer_did(commit: &[u8]) -> Option<String> {
trailer_did(commit).or_else(|| committer_did(commit))
}
#[must_use]
pub fn conflicting_signer_dids(commit: &[u8]) -> Option<(String, String)> {
let trailer = trailer_did(commit)?;
let committer = committer_did(commit)?;
(trailer != committer).then_some((trailer, committer))
}
const SIGNER_DID_KEY: &str = "Signed-by-DID";
const COMMENT_PREFIX: char = '#';
const GIT_GENERATED_PREFIXES: [&str; 2] = ["Signed-off-by: ", "(cherry picked from commit "];
fn trailer_did(commit: &[u8]) -> Option<String> {
let text = std::str::from_utf8(commit).ok()?;
let (_, message) = text.split_once("\n\n")?;
let value = last_trailer_value(message, SIGNER_DID_KEY)?;
let value = value.trim_ascii();
if !value.starts_with("did:") {
return None;
}
Some(
value
.split(['#', '?', '/'])
.next()
.unwrap_or(value)
.to_string(),
)
}
fn last_trailer_value(message: &str, key: &str) -> Option<String> {
let mut lines: Vec<&str> = message.split('\n').collect();
if lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
let before_subject = lines.iter().take_while(|line| is_blank(line)).count();
let lines = &lines[before_subject..];
let start = trailer_block_start(lines)?;
let mut value: Option<String> = None;
let mut open: Option<bool> = None;
for line in &lines[start..] {
if open.is_some() && line.starts_with(|c: char| c.is_ascii_whitespace()) {
if open == Some(true)
&& let Some(value) = value.as_mut()
{
value.push('\n');
value.push_str(line);
}
continue;
}
match separator_pos(line) {
Some(position) => {
let matched = line[..position].trim_ascii().eq_ignore_ascii_case(key);
if matched {
value = Some(line[position + 1..].to_string());
}
open = Some(matched);
}
None => open = None,
}
}
value.map(|value| unfold(value.trim_ascii()))
}
fn unfold(value: &str) -> String {
let mut unfolded = String::with_capacity(value.len());
let mut rest = value;
while let Some(newline) = rest.find('\n') {
unfolded.push_str(&rest[..newline]);
unfolded.push(' ');
rest = rest[newline + 1..].trim_ascii_start();
}
unfolded.push_str(rest);
unfolded.trim_ascii().to_string()
}
fn trailer_block_start(lines: &[&str]) -> Option<usize> {
let end_of_title = lines
.iter()
.position(|line| !line.starts_with(COMMENT_PREFIX) && is_blank(line))?;
let mut recognized_prefix = false;
let mut trailer_lines = 0_usize;
let mut non_trailer_lines = 0_usize;
let mut possible_continuation_lines = 0_usize;
let mut only_spaces = true;
for index in (end_of_title..lines.len()).rev() {
let line = lines[index];
if line.starts_with(COMMENT_PREFIX) {
non_trailer_lines += possible_continuation_lines;
possible_continuation_lines = 0;
continue;
}
if is_blank(line) {
if only_spaces {
continue;
}
non_trailer_lines += possible_continuation_lines;
if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
return Some(index + 1);
}
if trailer_lines > 0 && non_trailer_lines == 0 {
return Some(index + 1);
}
return None;
}
only_spaces = false;
if GIT_GENERATED_PREFIXES
.iter()
.any(|prefix| line.starts_with(prefix))
{
trailer_lines += 1;
possible_continuation_lines = 0;
recognized_prefix = true;
} else if separator_pos(line).is_some() {
trailer_lines += 1;
possible_continuation_lines = 0;
} else if line.starts_with(|c: char| c.is_ascii_whitespace()) {
possible_continuation_lines += 1;
} else {
non_trailer_lines += 1 + possible_continuation_lines;
possible_continuation_lines = 0;
}
}
None
}
fn separator_pos(line: &str) -> Option<usize> {
let mut whitespace_found = false;
for (offset, c) in line.char_indices() {
if c == ':' {
return (offset >= 1).then_some(offset);
}
if !whitespace_found && (c.is_ascii_alphanumeric() || c == '-') {
continue;
}
if offset != 0 && (c == ' ' || c == '\t') {
whitespace_found = true;
continue;
}
return None;
}
None
}
fn is_blank(line: &str) -> bool {
line.trim_ascii().is_empty()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn commit_with_committer(committer: &str) -> String {
format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer {committer} 1700000000 +0000\n\
\n\
a message\n"
)
}
#[test]
fn a_did_committer_yields_the_bare_did() {
let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
assert_eq!(
committer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com",
"the fragment names the key, not the identity the registry knows"
);
}
#[test]
fn a_did_without_a_fragment_survives_intact() {
let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
assert_eq!(
committer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com"
);
}
#[test]
fn a_plain_email_committer_claims_no_did() {
let commit = commit_with_committer("Alice <alice@example.com>");
assert!(committer_did(commit.as_bytes()).is_none());
assert_eq!(
committer_identity(commit.as_bytes()).unwrap(),
"alice@example.com",
"the identity is still reported, so the failure can name it"
);
}
#[test]
fn a_body_line_cannot_impersonate_the_committer_header() {
let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer A U Thor <alice@example.com> 1700000000 +0000\n\
\n\
committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
assert!(
committer_did(commit.as_bytes()).is_none(),
"a DID in the message body must not be read as the committer"
);
}
#[test]
fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
assert_eq!(
committer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com"
);
}
#[test]
fn a_signed_commits_payload_still_exposes_the_committer() {
let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
let (headers, body) = commit.split_once("\n\n").unwrap();
let signed = format!(
"{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
);
let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
assert_eq!(
committer_did(&payload).unwrap(),
"did:webvh:QmAbc:example.com"
);
}
fn commit_with_trailer(committer: &str, trailer: &str) -> String {
format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer {committer} 1700000000 +0000\n\
\n\
a message\n\
\n\
{trailer}\n"
)
}
#[test]
fn signer_did_prefers_trailer_over_committer() {
let commit = commit_with_trailer(
"Alice <did:webvh:QmOld:old.example#key-0>",
"Signed-by-DID: did:webvh:QmNew:new.example#key-0",
);
assert_eq!(
signer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmNew:new.example",
"trailer must take precedence over committer email"
);
}
#[test]
fn signer_did_falls_back_to_committer_for_legacy_commits() {
let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
assert_eq!(
signer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com",
"legacy commits with DID in committer email must still work"
);
}
#[test]
fn signer_did_reads_trailer_with_normal_email_committer() {
let commit = commit_with_trailer(
"Alice <alice@example.com>",
"Signed-by-DID: did:webvh:QmAbc:example.com#key-0",
);
assert_eq!(
signer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com",
);
}
#[test]
fn signer_did_returns_none_without_did_anywhere() {
let commit = commit_with_committer("Alice <alice@example.com>");
assert!(signer_did(commit.as_bytes()).is_none());
}
#[test]
fn trailer_strips_fragment() {
let commit = commit_with_trailer(
"Alice <alice@example.com>",
"Signed-by-DID: did:webvh:QmAbc:example.com#key-1",
);
assert_eq!(
signer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmAbc:example.com",
);
}
#[test]
fn trailer_ignores_non_did_values() {
let commit = commit_with_trailer("Alice <alice@example.com>", "Signed-by-DID: not-a-did");
assert!(signer_did(commit.as_bytes()).is_none());
}
#[test]
fn signer_did_ignores_body_line_outside_final_trailer_block() {
let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer Alice <alice@example.com> 1700000000 +0000\n\
\n\
This line only discusses a trailer.\n\
Signed-by-DID: did:webvh:QmBody:example.com#key-0\n\
\n\
final prose, not a trailer block\n";
assert!(signer_did(commit.as_bytes()).is_none());
}
#[test]
fn signer_did_reads_final_trailer_block_only() {
let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer Alice <alice@example.com> 1700000000 +0000\n\
\n\
Signed-by-DID: did:webvh:QmBody:ignored.example#key-0\n\
\n\
body text\n\
\n\
Signed-off-by: Alice <alice@example.com>\n\
Signed-by-DID: did:webvh:QmTrailer:example.com#key-0\n";
assert_eq!(
signer_did(commit.as_bytes()).unwrap(),
"did:webvh:QmTrailer:example.com"
);
}
#[test]
fn conflicting_signer_dids_reports_trailer_and_committer_disagreement() {
let commit = commit_with_trailer(
"Alice <did:webvh:QmCommitter:example.com#key-0>",
"Signed-by-DID: did:webvh:QmTrailer:example.com#key-0",
);
assert_eq!(
conflicting_signer_dids(commit.as_bytes()).unwrap(),
(
"did:webvh:QmTrailer:example.com".to_string(),
"did:webvh:QmCommitter:example.com".to_string(),
)
);
}
fn commit_with_body(body: &str) -> String {
format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer Alice <alice@example.com> 1700000000 +0000\n\
\n\
{body}"
)
}
const DID: &str = "did:webvh:QmA:example.com";
#[test]
fn a_trailer_in_a_mixed_paragraph_is_not_a_claim() {
let commit = commit_with_body(&format!(
"subject\n\nprose about the change\nSigned-by-DID: {DID}\n"
));
assert!(trailer_did(commit.as_bytes()).is_none());
}
#[test]
fn a_trailer_in_the_title_paragraph_is_not_a_claim() {
let commit = commit_with_body(&format!("Signed-by-DID: {DID}\n"));
assert!(trailer_did(commit.as_bytes()).is_none());
let commit = commit_with_body(&format!("subject\nSigned-by-DID: {DID}\n"));
assert!(trailer_did(commit.as_bytes()).is_none());
}
#[test]
fn whitespace_before_the_colon_still_names_the_trailer() {
for gap in ["", " ", " ", "\t", " \t"] {
let commit = commit_with_body(&format!("subject\n\nSigned-by-DID{gap}: {DID}#key-0\n"));
assert_eq!(
trailer_did(commit.as_bytes()).as_deref(),
Some(DID),
"gap {gap:?} must not hide the claim"
);
}
}
#[test]
fn the_trailer_key_is_matched_case_insensitively() {
for key in [
"Signed-by-DID",
"signed-by-did",
"SIGNED-BY-DID",
"Signed-By-Did",
] {
let commit = commit_with_body(&format!("subject\n\n{key}: {DID}#key-0\n"));
assert_eq!(
trailer_did(commit.as_bytes()).as_deref(),
Some(DID),
"key {key:?} must be recognized"
);
}
}
#[test]
fn a_folded_trailer_value_is_unfolded_like_git() {
let commit = commit_with_body(&format!("subject\n\nSigned-by-DID: {DID}\n and more\n"));
assert_eq!(
trailer_did(commit.as_bytes()).as_deref(),
Some("did:webvh:QmA:example.com and more")
);
let commit = commit_with_body(&format!("subject\n\nSigned-by-DID: {DID} \n continued\n"));
assert_eq!(
trailer_did(commit.as_bytes()).as_deref(),
Some("did:webvh:QmA:example.com continued")
);
}
#[test]
fn a_git_generated_trailer_unlocks_the_25_percent_allowance() {
let commit = commit_with_body(&format!(
"subject\n\nn1\nn2\nn3\nSigned-off-by: A U Thor <a@example.com>\nSigned-by-DID: {DID}\n"
));
assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
let commit = commit_with_body(&format!(
"subject\n\nn1\nn2\nn3\nn4\nn5\nn6\nn7\n\
Signed-off-by: A U Thor <a@example.com>\nSigned-by-DID: {DID}\n"
));
assert!(trailer_did(commit.as_bytes()).is_none());
}
#[test]
fn a_cherry_pick_line_unlocks_the_allowance_without_a_separator() {
let commit = commit_with_body(&format!(
"subject\n\nprose\n\
(cherry picked from commit 0123456789abcdef0123456789abcdef01234567)\n\
Signed-by-DID: {DID}\n"
));
assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
}
#[test]
fn a_line_whose_colon_comes_first_defeats_the_block() {
let commit = commit_with_body(&format!(
"subject\n\n: did:webvh:QmEvil:attacker.example\nSigned-by-DID: {DID}\n"
));
assert!(trailer_did(commit.as_bytes()).is_none());
}
#[test]
fn the_last_trailer_wins_even_when_its_value_is_not_a_did() {
let commit = commit_with_body(
"subject\n\nSigned-by-DID: did:webvh:QmFirst:example.com\nSigned-by-DID: see below\n",
);
assert!(trailer_did(commit.as_bytes()).is_none());
}
#[test]
fn blank_lines_before_the_subject_are_skipped_like_git() {
for prefix in ["\n", " \n", "\t\n", "\n\n"] {
let commit = commit_with_body(&format!("{prefix}Signed-by-DID: {DID}\n"));
assert!(
trailer_did(commit.as_bytes()).is_none(),
"with prefix {prefix:?} the trailer line is the subject git shows"
);
}
let commit = commit_with_body(&format!("\nsubject\n\nSigned-by-DID: {DID}\n"));
assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
}
#[test]
fn comment_lines_do_not_count_against_the_block() {
let commit = commit_with_body(&format!("subject\n\n# a comment\nSigned-by-DID: {DID}\n"));
assert_eq!(trailer_did(commit.as_bytes()).as_deref(), Some(DID));
}
}