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))
}
fn trailer_did(commit: &[u8]) -> Option<String> {
let text = std::str::from_utf8(commit).ok()?;
let (_, body) = text.split_once("\n\n")?;
let mut lines: Vec<&str> = body.lines().collect();
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
let mut trailer_start = lines.len();
while trailer_start > 0 && is_trailer_line(lines[trailer_start - 1]) {
trailer_start -= 1;
}
if trailer_start == lines.len() {
return None;
}
for line in lines[trailer_start..].iter().rev() {
if let Some(value) = line.strip_prefix("Signed-by-DID:") {
let value = value.trim();
if value.starts_with("did:") {
return Some(
value
.split(['#', '?', '/'])
.next()
.unwrap_or(value)
.to_string(),
);
}
}
}
None
}
fn is_trailer_line(line: &str) -> bool {
let Some((key, _)) = line.split_once(':') else {
return false;
};
!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
#[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(),
)
);
}
}