1use anyhow::{Context, Result, bail};
10
11pub fn normalize_sshsig_armor(pem: &str) -> String {
19 let body: String = pem
20 .lines()
21 .filter(|line| !line.starts_with("-----"))
22 .map(str::trim)
23 .collect();
24 let mut normalized = String::from("-----BEGIN SSH SIGNATURE-----\n");
25 for chunk in body.as_bytes().chunks(70) {
26 normalized.push_str(&String::from_utf8_lossy(chunk));
28 normalized.push('\n');
29 }
30 normalized.push_str("-----END SSH SIGNATURE-----\n");
31 normalized
32}
33
34pub fn split_signed_commit(raw: &[u8]) -> Result<Option<(Vec<u8>, String)>> {
40 let text = std::str::from_utf8(raw).context("commit object is not UTF-8")?;
41 let Some((headers, body)) = text.split_once("\n\n") else {
42 bail!("malformed commit object: no header/body separator");
43 };
44
45 let mut kept_headers: Vec<&str> = Vec::new();
46 let mut signature_lines: Vec<&str> = Vec::new();
47 let mut in_gpgsig = false;
48 for line in headers.split('\n') {
49 if let Some(first) = line.strip_prefix("gpgsig ") {
50 in_gpgsig = true;
51 signature_lines.push(first);
52 } else if in_gpgsig && let Some(continuation) = line.strip_prefix(' ') {
53 signature_lines.push(continuation);
54 } else {
55 in_gpgsig = false;
56 kept_headers.push(line);
57 }
58 }
59
60 if signature_lines.is_empty() {
61 return Ok(None);
62 }
63
64 let mut payload = kept_headers.join("\n").into_bytes();
65 payload.extend_from_slice(b"\n\n");
66 payload.extend_from_slice(body.as_bytes());
67
68 let mut pem = signature_lines.join("\n");
69 pem.push('\n');
70 Ok(Some((payload, pem)))
71}
72
73#[must_use]
79pub fn committer_identity(commit: &[u8]) -> Option<String> {
80 let text = std::str::from_utf8(commit).ok()?;
81 let headers = text.split_once("\n\n").map_or(text, |(headers, _)| headers);
82 let line = headers
83 .split('\n')
84 .find_map(|line| line.strip_prefix("committer "))?;
85 let open = line.rfind('<')?;
87 let close = line[open..].find('>')? + open;
88 Some(line[open + 1..close].to_string())
89}
90
91#[must_use]
105pub fn committer_did(commit: &[u8]) -> Option<String> {
106 let identity = committer_identity(commit)?;
107 if !identity.starts_with("did:") {
108 return None;
109 }
110 let did = identity
111 .split(['#', '?', '/'])
112 .next()
113 .unwrap_or(identity.as_str());
114 if did.is_empty() {
115 return None;
116 }
117 Some(did.to_string())
118}
119
120#[cfg(test)]
121mod tests {
122 #![allow(clippy::unwrap_used)]
123
124 use super::*;
125
126 fn commit_with_committer(committer: &str) -> String {
127 format!(
128 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
129 author A U Thor <a@example.com> 1700000000 +0000\n\
130 committer {committer} 1700000000 +0000\n\
131 \n\
132 a message\n"
133 )
134 }
135
136 #[test]
137 fn a_did_committer_yields_the_bare_did() {
138 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
139 assert_eq!(
140 committer_did(commit.as_bytes()).unwrap(),
141 "did:webvh:QmAbc:example.com",
142 "the fragment names the key, not the identity the registry knows"
143 );
144 }
145
146 #[test]
147 fn a_did_without_a_fragment_survives_intact() {
148 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
149 assert_eq!(
150 committer_did(commit.as_bytes()).unwrap(),
151 "did:webvh:QmAbc:example.com"
152 );
153 }
154
155 #[test]
156 fn a_plain_email_committer_claims_no_did() {
157 let commit = commit_with_committer("Alice <alice@example.com>");
158 assert!(committer_did(commit.as_bytes()).is_none());
159 assert_eq!(
160 committer_identity(commit.as_bytes()).unwrap(),
161 "alice@example.com",
162 "the identity is still reported, so the failure can name it"
163 );
164 }
165
166 #[test]
167 fn a_body_line_cannot_impersonate_the_committer_header() {
168 let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
171 author A U Thor <a@example.com> 1700000000 +0000\n\
172 committer A U Thor <alice@example.com> 1700000000 +0000\n\
173 \n\
174 committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
175 assert!(
176 committer_did(commit.as_bytes()).is_none(),
177 "a DID in the message body must not be read as the committer"
178 );
179 }
180
181 #[test]
182 fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
183 let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
184 assert_eq!(
185 committer_did(commit.as_bytes()).unwrap(),
186 "did:webvh:QmAbc:example.com"
187 );
188 }
189
190 #[test]
191 fn a_signed_commits_payload_still_exposes_the_committer() {
192 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
195 let (headers, body) = commit.split_once("\n\n").unwrap();
196 let signed = format!(
197 "{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
198 AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
199 );
200 let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
201 assert_eq!(
202 committer_did(&payload).unwrap(),
203 "did:webvh:QmAbc:example.com"
204 );
205 }
206}