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#[must_use]
128pub fn signer_did(commit: &[u8]) -> Option<String> {
129 trailer_did(commit).or_else(|| committer_did(commit))
130}
131
132#[must_use]
135pub fn conflicting_signer_dids(commit: &[u8]) -> Option<(String, String)> {
136 let trailer = trailer_did(commit)?;
137 let committer = committer_did(commit)?;
138 (trailer != committer).then_some((trailer, committer))
139}
140
141fn trailer_did(commit: &[u8]) -> Option<String> {
144 let text = std::str::from_utf8(commit).ok()?;
145 let (_, body) = text.split_once("\n\n")?;
146
147 let mut lines: Vec<&str> = body.lines().collect();
148 while lines.last().is_some_and(|line| line.trim().is_empty()) {
149 lines.pop();
150 }
151
152 let mut trailer_start = lines.len();
153 while trailer_start > 0 && is_trailer_line(lines[trailer_start - 1]) {
154 trailer_start -= 1;
155 }
156 if trailer_start == lines.len() {
157 return None;
158 }
159
160 for line in lines[trailer_start..].iter().rev() {
161 if let Some(value) = line.strip_prefix("Signed-by-DID:") {
162 let value = value.trim();
163 if value.starts_with("did:") {
164 return Some(
165 value
166 .split(['#', '?', '/'])
167 .next()
168 .unwrap_or(value)
169 .to_string(),
170 );
171 }
172 }
173 }
174 None
175}
176
177fn is_trailer_line(line: &str) -> bool {
178 let Some((key, _)) = line.split_once(':') else {
179 return false;
180 };
181 !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
182}
183
184#[cfg(test)]
185mod tests {
186 #![allow(clippy::unwrap_used)]
187
188 use super::*;
189
190 fn commit_with_committer(committer: &str) -> String {
191 format!(
192 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
193 author A U Thor <a@example.com> 1700000000 +0000\n\
194 committer {committer} 1700000000 +0000\n\
195 \n\
196 a message\n"
197 )
198 }
199
200 #[test]
201 fn a_did_committer_yields_the_bare_did() {
202 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
203 assert_eq!(
204 committer_did(commit.as_bytes()).unwrap(),
205 "did:webvh:QmAbc:example.com",
206 "the fragment names the key, not the identity the registry knows"
207 );
208 }
209
210 #[test]
211 fn a_did_without_a_fragment_survives_intact() {
212 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
213 assert_eq!(
214 committer_did(commit.as_bytes()).unwrap(),
215 "did:webvh:QmAbc:example.com"
216 );
217 }
218
219 #[test]
220 fn a_plain_email_committer_claims_no_did() {
221 let commit = commit_with_committer("Alice <alice@example.com>");
222 assert!(committer_did(commit.as_bytes()).is_none());
223 assert_eq!(
224 committer_identity(commit.as_bytes()).unwrap(),
225 "alice@example.com",
226 "the identity is still reported, so the failure can name it"
227 );
228 }
229
230 #[test]
231 fn a_body_line_cannot_impersonate_the_committer_header() {
232 let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
235 author A U Thor <a@example.com> 1700000000 +0000\n\
236 committer A U Thor <alice@example.com> 1700000000 +0000\n\
237 \n\
238 committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
239 assert!(
240 committer_did(commit.as_bytes()).is_none(),
241 "a DID in the message body must not be read as the committer"
242 );
243 }
244
245 #[test]
246 fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
247 let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
248 assert_eq!(
249 committer_did(commit.as_bytes()).unwrap(),
250 "did:webvh:QmAbc:example.com"
251 );
252 }
253
254 #[test]
255 fn a_signed_commits_payload_still_exposes_the_committer() {
256 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
259 let (headers, body) = commit.split_once("\n\n").unwrap();
260 let signed = format!(
261 "{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
262 AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
263 );
264 let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
265 assert_eq!(
266 committer_did(&payload).unwrap(),
267 "did:webvh:QmAbc:example.com"
268 );
269 }
270
271 fn commit_with_trailer(committer: &str, trailer: &str) -> String {
272 format!(
273 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
274 author A U Thor <a@example.com> 1700000000 +0000\n\
275 committer {committer} 1700000000 +0000\n\
276 \n\
277 a message\n\
278 \n\
279 {trailer}\n"
280 )
281 }
282
283 #[test]
284 fn signer_did_prefers_trailer_over_committer() {
285 let commit = commit_with_trailer(
286 "Alice <did:webvh:QmOld:old.example#key-0>",
287 "Signed-by-DID: did:webvh:QmNew:new.example#key-0",
288 );
289 assert_eq!(
290 signer_did(commit.as_bytes()).unwrap(),
291 "did:webvh:QmNew:new.example",
292 "trailer must take precedence over committer email"
293 );
294 }
295
296 #[test]
297 fn signer_did_falls_back_to_committer_for_legacy_commits() {
298 let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
299 assert_eq!(
300 signer_did(commit.as_bytes()).unwrap(),
301 "did:webvh:QmAbc:example.com",
302 "legacy commits with DID in committer email must still work"
303 );
304 }
305
306 #[test]
307 fn signer_did_reads_trailer_with_normal_email_committer() {
308 let commit = commit_with_trailer(
309 "Alice <alice@example.com>",
310 "Signed-by-DID: did:webvh:QmAbc:example.com#key-0",
311 );
312 assert_eq!(
313 signer_did(commit.as_bytes()).unwrap(),
314 "did:webvh:QmAbc:example.com",
315 );
316 }
317
318 #[test]
319 fn signer_did_returns_none_without_did_anywhere() {
320 let commit = commit_with_committer("Alice <alice@example.com>");
321 assert!(signer_did(commit.as_bytes()).is_none());
322 }
323
324 #[test]
325 fn trailer_strips_fragment() {
326 let commit = commit_with_trailer(
327 "Alice <alice@example.com>",
328 "Signed-by-DID: did:webvh:QmAbc:example.com#key-1",
329 );
330 assert_eq!(
331 signer_did(commit.as_bytes()).unwrap(),
332 "did:webvh:QmAbc:example.com",
333 );
334 }
335
336 #[test]
337 fn trailer_ignores_non_did_values() {
338 let commit = commit_with_trailer("Alice <alice@example.com>", "Signed-by-DID: not-a-did");
339 assert!(signer_did(commit.as_bytes()).is_none());
340 }
341
342 #[test]
343 fn signer_did_ignores_body_line_outside_final_trailer_block() {
344 let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
345 author A U Thor <a@example.com> 1700000000 +0000\n\
346 committer Alice <alice@example.com> 1700000000 +0000\n\
347 \n\
348 This line only discusses a trailer.\n\
349 Signed-by-DID: did:webvh:QmBody:example.com#key-0\n\
350 \n\
351 final prose, not a trailer block\n";
352 assert!(signer_did(commit.as_bytes()).is_none());
353 }
354
355 #[test]
356 fn signer_did_reads_final_trailer_block_only() {
357 let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
358 author A U Thor <a@example.com> 1700000000 +0000\n\
359 committer Alice <alice@example.com> 1700000000 +0000\n\
360 \n\
361 Signed-by-DID: did:webvh:QmBody:ignored.example#key-0\n\
362 \n\
363 body text\n\
364 \n\
365 Signed-off-by: Alice <alice@example.com>\n\
366 Signed-by-DID: did:webvh:QmTrailer:example.com#key-0\n";
367 assert_eq!(
368 signer_did(commit.as_bytes()).unwrap(),
369 "did:webvh:QmTrailer:example.com"
370 );
371 }
372
373 #[test]
374 fn conflicting_signer_dids_reports_trailer_and_committer_disagreement() {
375 let commit = commit_with_trailer(
376 "Alice <did:webvh:QmCommitter:example.com#key-0>",
377 "Signed-by-DID: did:webvh:QmTrailer:example.com#key-0",
378 );
379 assert_eq!(
380 conflicting_signer_dids(commit.as_bytes()).unwrap(),
381 (
382 "did:webvh:QmTrailer:example.com".to_string(),
383 "did:webvh:QmCommitter:example.com".to_string(),
384 )
385 );
386 }
387}