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())
}
#[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"
);
}
}