This repository has no description
1use std::io::Read;
2use std::sync::LazyLock;
3
4use base64::Engine;
5use knot_types::{AuthorName, Email, Oid};
6
7use crate::objects::{CommitChangeId, EntryKind};
8use crate::patch::{Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES};
9
10#[derive(Debug, thiserror::Error, PartialEq, Eq)]
11pub enum PatchParseError {
12 #[error("patch is empty")]
13 Empty,
14 #[error("patch contains no file changes")]
15 NoFiles,
16 #[error("malformed patch: {0}")]
17 Malformed(String),
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FileIntent {
22 Create,
23 Delete,
24 Modify,
25 Rename { from: String },
26 Copy { from: String },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum PatchPayload {
31 Text(Vec<Hunk>),
32 BinaryLiteral(Vec<u8>),
33 BinaryDelta(Vec<u8>),
34 BinaryOpaque,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ParsedFile {
39 pub path: String,
40 pub intent: FileIntent,
41 pub old_kind: Option<EntryKind>,
42 pub new_kind: Option<EntryKind>,
43 pub old_index: Option<Oid>,
44 pub payload: PatchPayload,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct MailPatch {
49 pub author_name: AuthorName,
50 pub author_email: Email,
51 pub date: String,
52 pub subject: String,
53 pub body: String,
54 pub change_id: Option<CommitChangeId>,
55 pub files: Vec<ParsedFile>,
56}
57
58impl MailPatch {
59 pub fn commit_message(&self) -> String {
60 match self.body.is_empty() {
61 true => self.subject.clone(),
62 false => format!("{}\n\n{}", self.subject, self.body),
63 }
64 }
65}
66
67pub fn is_format_patch(patch: &str) -> bool {
68 let lines: Vec<&str> = patch.split('\n').collect();
69 if lines.len() < 2 {
70 return false;
71 }
72 let first = lines[0].trim();
73 if first.starts_with("From ") && first.contains(" Mon Sep 17 00:00:00 2001") {
74 return true;
75 }
76 lines
77 .iter()
78 .take(10)
79 .map(|line| line.trim())
80 .filter(|line| {
81 line.starts_with("From: ")
82 || line.starts_with("Date: ")
83 || line.starts_with("Subject: ")
84 || line.starts_with("commit ")
85 })
86 .count()
87 >= 2
88}
89
90struct Cursor<'a> {
91 lines: &'a [&'a str],
92 pos: usize,
93}
94
95impl<'a> Cursor<'a> {
96 fn new(lines: &'a [&'a str]) -> Self {
97 Self { lines, pos: 0 }
98 }
99
100 fn peek(&self) -> Option<&'a str> {
101 self.lines.get(self.pos).copied()
102 }
103
104 fn next(&mut self) -> Option<&'a str> {
105 let line = self.peek()?;
106 self.pos += 1;
107 Some(line)
108 }
109
110 fn take_prefix(&mut self, prefix: &str) -> Option<&'a str> {
111 let rest = self.peek()?.strip_prefix(prefix)?;
112 self.pos += 1;
113 Some(rest)
114 }
115}
116
117fn malformed(message: impl Into<String>) -> PatchParseError {
118 PatchParseError::Malformed(message.into())
119}
120
121const MAX_TOTAL_PATCH_BYTES: u64 = 128 * 1024 * 1024;
122
123struct Budget {
124 remaining: u64,
125}
126
127impl Budget {
128 fn new(limit: u64) -> Self {
129 Self { remaining: limit }
130 }
131
132 fn charge(&mut self, bytes: u64) -> Result<(), PatchParseError> {
133 self.remaining = self
134 .remaining
135 .checked_sub(bytes)
136 .ok_or_else(|| malformed("patch exceeds total decompressed size budget"))?;
137 Ok(())
138 }
139}
140
141fn unescape_c(bytes: &[u8]) -> Option<Vec<u8>> {
142 let mut pos = 0usize;
143 std::iter::from_fn(move || match bytes.get(pos..) {
144 None | Some([]) => None,
145 Some(slice) => {
146 let decoded: Option<u8> = match slice {
147 [b'\\', b'n', ..] => apply(&mut pos, 2, b'\n'),
148 [b'\\', b't', ..] => apply(&mut pos, 2, b'\t'),
149 [b'\\', b'"', ..] => apply(&mut pos, 2, b'"'),
150 [b'\\', b'\\', ..] => apply(&mut pos, 2, b'\\'),
151 [b'\\', a @ b'0'..=b'3', b @ b'0'..=b'7', c @ b'0'..=b'7', ..] => {
152 apply(&mut pos, 4, (a - b'0') * 64 + (b - b'0') * 8 + (c - b'0'))
153 }
154 [b'\\', ..] => {
155 pos += 1;
156 None
157 }
158 [byte, ..] => apply(&mut pos, 1, *byte),
159 [] => None,
160 };
161 Some(decoded)
162 }
163 })
164 .collect()
165}
166
167fn apply(pos: &mut usize, width: usize, byte: u8) -> Option<u8> {
168 *pos += width;
169 Some(byte)
170}
171
172fn quoted_end(bytes: &[u8]) -> Option<usize> {
173 let mut idx = 0usize;
174 std::iter::from_fn(move || match bytes.get(idx) {
175 Some(b'"') => Some(Some(idx)),
176 Some(b'\\') => {
177 idx += 2;
178 Some(None)
179 }
180 Some(_) => {
181 idx += 1;
182 Some(None)
183 }
184 None => None,
185 })
186 .flatten()
187 .next()
188}
189
190fn unquote(raw: &str) -> Result<String, PatchParseError> {
191 match raw.strip_prefix('"') {
192 None => Ok(raw.to_string()),
193 Some(inner) => {
194 let end =
195 quoted_end(inner.as_bytes()).ok_or_else(|| malformed("unclosed quoted path"))?;
196 let unescaped = unescape_c(&inner.as_bytes()[..end])
197 .ok_or_else(|| malformed("bad escape in quoted path"))?;
198 String::from_utf8(unescaped).map_err(|_| malformed("quoted path isn't utf-8"))
199 }
200 }
201}
202
203fn strip_level(path: &str) -> String {
204 path.split_once('/')
205 .map(|(_, rest)| rest.to_string())
206 .unwrap_or_else(|| path.to_string())
207}
208
209fn parse_label(raw: &str) -> Result<Option<String>, PatchParseError> {
210 let bare = match raw.starts_with('"') {
211 true => unquote(raw)?,
212 false => raw.split('\t').next().unwrap_or(raw).trim_end().to_string(),
213 };
214 Ok(match bare.as_str() {
215 "/dev/null" => None,
216 _ => Some(strip_level(&bare)),
217 })
218}
219
220fn diff_paths(rest: &str) -> Option<(String, String)> {
221 match rest.contains('"') {
222 true => {
223 let (old, after) = take_path_token(rest)?;
224 let (new, _) = take_path_token(after.strip_prefix(' ')?)?;
225 Some((strip_level(&old), strip_level(&new)))
226 }
227 false => {
228 let split = rest.rfind(" b/")?;
229 let old = rest.get(..split)?.strip_prefix("a/")?;
230 let new = rest.get(split + 3..)?;
231 Some((old.to_string(), new.to_string()))
232 }
233 }
234}
235
236fn take_path_token(rest: &str) -> Option<(String, &str)> {
237 match rest.strip_prefix('"') {
238 Some(inner) => {
239 let end = quoted_end(inner.as_bytes())?;
240 let token = String::from_utf8(unescape_c(&inner.as_bytes()[..end])?).ok()?;
241 Some((token, inner.get(end + 1..)?))
242 }
243 None => {
244 let end = rest.find(' ').unwrap_or(rest.len());
245 Some((rest[..end].to_string(), &rest[end..]))
246 }
247 }
248}
249
250fn full_oid(hex: &str) -> Option<Oid> {
251 (hex.len() == 40).then(|| Oid::from_hex(hex).ok()).flatten()
252}
253
254fn parse_hunk_header(line: &str) -> Option<(LineNumber, LineCount, LineNumber, LineCount)> {
255 let rest = line.strip_prefix("@@ -")?;
256 let (old, rest) = rest.split_once(" +")?;
257 let (new, _) = rest.split_once(" @@")?;
258 let span = |raw: &str| -> Option<(LineNumber, LineCount)> {
259 match raw.split_once(',') {
260 Some((start, lines)) => Some((
261 LineNumber::new(start.parse().ok()?),
262 LineCount::new(lines.parse().ok()?),
263 )),
264 None => Some((LineNumber::new(raw.parse().ok()?), LineCount::new(1))),
265 }
266 };
267 let (old_start, old_lines) = span(old)?;
268 let (new_start, new_lines) = span(new)?;
269 Some((old_start, old_lines, new_start, new_lines))
270}
271
272fn parse_hunk(cursor: &mut Cursor<'_>, budget: &mut Budget) -> Result<Hunk, PatchParseError> {
273 let header = cursor.next().ok_or_else(|| malformed("truncated hunk"))?;
274 let (old_start, old_lines, new_start, new_lines) =
275 parse_hunk_header(header).ok_or_else(|| malformed(format!("bad hunk header {header}")))?;
276 let mut lines: Vec<HunkLine> = Vec::new();
277 let mut old_left = old_lines.get() as i64;
278 let mut new_left = new_lines.get() as i64;
279 std::iter::from_fn(|| {
280 (old_left > 0 || new_left > 0).then(|| -> Result<(), PatchParseError> {
281 let line = cursor
282 .next()
283 .ok_or_else(|| malformed("hunk ends before its declared length"))?;
284 budget.charge(line.len() as u64 + 1)?;
285 let push = |lines: &mut Vec<HunkLine>, op: LineOp| {
286 let mut text = line.get(1..).unwrap_or("").as_bytes().to_vec();
287 text.push(b'\n');
288 lines.push(HunkLine { op, text });
289 };
290 match line.as_bytes().first() {
291 Some(b' ') | None => {
292 old_left -= 1;
293 new_left -= 1;
294 push(&mut lines, LineOp::Context);
295 Ok(())
296 }
297 Some(b'-') => {
298 old_left -= 1;
299 push(&mut lines, LineOp::Delete);
300 Ok(())
301 }
302 Some(b'+') => {
303 new_left -= 1;
304 push(&mut lines, LineOp::Add);
305 Ok(())
306 }
307 Some(b'\\') => {
308 strip_last_newline(&mut lines);
309 Ok(())
310 }
311 _ => Err(malformed(format!("unexpected hunk line {line}"))),
312 }
313 })
314 })
315 .try_for_each(|outcome| outcome)?;
316 if cursor.peek().is_some_and(|line| line.starts_with('\\')) {
317 cursor.next();
318 strip_last_newline(&mut lines);
319 }
320 Ok(Hunk {
321 old_start,
322 old_lines,
323 new_start,
324 new_lines,
325 lines,
326 })
327}
328
329fn strip_last_newline(lines: &mut [HunkLine]) {
330 if let Some(last) = lines.last_mut()
331 && last.text.last() == Some(&b'\n')
332 {
333 last.text.pop();
334 }
335}
336
337fn parse_hunks(cursor: &mut Cursor<'_>, budget: &mut Budget) -> Result<Vec<Hunk>, PatchParseError> {
338 std::iter::from_fn(|| {
339 cursor
340 .peek()
341 .is_some_and(|line| line.starts_with("@@ -"))
342 .then(|| parse_hunk(cursor, budget))
343 })
344 .collect()
345}
346
347static BASE85: LazyLock<[i16; 256]> = LazyLock::new(|| {
348 const ALPHABET: &[u8] =
349 b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~";
350 std::array::from_fn(|byte| {
351 ALPHABET
352 .iter()
353 .position(|&c| c as usize == byte)
354 .map(|digit| digit as i16)
355 .unwrap_or(-1)
356 })
357});
358
359fn decode_base85_line(line: &str, out: &mut Vec<u8>) -> Result<(), PatchParseError> {
360 let bad = || malformed("bad base85 line in binary patch");
361 let (len_char, data) = line.as_bytes().split_first().ok_or_else(bad)?;
362 let line_len = match len_char {
363 b'A'..=b'Z' => (len_char - b'A' + 1) as usize,
364 b'a'..=b'z' => (len_char - b'a' + 27) as usize,
365 _ => return Err(bad()),
366 };
367 if data.len() != line_len.div_ceil(4) * 5 {
368 return Err(bad());
369 }
370 data.chunks(5)
371 .enumerate()
372 .try_for_each(|(group, chunk)| -> Result<(), PatchParseError> {
373 let acc = chunk
374 .iter()
375 .try_fold(0u64, |acc, &c| {
376 let digit = BASE85[c as usize];
377 (digit >= 0).then(|| acc * 85 + digit as u64)
378 })
379 .filter(|&acc| acc <= u32::MAX as u64)
380 .ok_or_else(bad)?;
381 let take = (line_len - group * 4).min(4);
382 out.extend_from_slice(&(acc as u32).to_be_bytes()[..take]);
383 Ok(())
384 })
385}
386
387fn parse_binary_block(
388 cursor: &mut Cursor<'_>,
389 budget: &mut Budget,
390) -> Result<(bool, Vec<u8>), PatchParseError> {
391 let header = cursor
392 .next()
393 .ok_or_else(|| malformed("truncated binary patch"))?;
394 let (kind, size) = header
395 .split_once(' ')
396 .ok_or_else(|| malformed(format!("bad binary patch header {header}")))?;
397 let is_delta = match kind {
398 "literal" => false,
399 "delta" => true,
400 _ => return Err(malformed(format!("unknown binary patch kind {kind}"))),
401 };
402 let size: u64 = size
403 .trim()
404 .parse()
405 .map_err(|_| malformed("bad binary patch size"))?;
406 if size > MAX_DIFF_BLOB_BYTES {
407 return Err(malformed("binary patch exceeds size limit"));
408 }
409 budget.charge(size)?;
410 let mut packed: Vec<u8> = Vec::new();
411 std::iter::from_fn(|| {
412 cursor
413 .peek()
414 .is_some_and(|line| !line.is_empty())
415 .then(|| cursor.next().expect("peeked line is present"))
416 })
417 .try_for_each(|line| decode_base85_line(line, &mut packed))?;
418 cursor.next();
419 let mut inflated: Vec<u8> = Vec::new();
420 flate2::read::ZlibDecoder::new(packed.as_slice())
421 .take(size + 1)
422 .read_to_end(&mut inflated)
423 .map_err(|error| malformed(format!("bad zlib stream in binary patch: {error}")))?;
424 if inflated.len() as u64 != size {
425 return Err(malformed("binary patch size doesn't match its header"));
426 }
427 Ok((is_delta, inflated))
428}
429
430fn parse_binary_payload(
431 cursor: &mut Cursor<'_>,
432 budget: &mut Budget,
433) -> Result<PatchPayload, PatchParseError> {
434 cursor.next();
435 let (is_delta, data) = parse_binary_block(cursor, budget)?;
436 if cursor
437 .peek()
438 .is_some_and(|line| line.starts_with("literal ") || line.starts_with("delta "))
439 {
440 parse_binary_block(cursor, budget)?;
441 }
442 Ok(match is_delta {
443 true => PatchPayload::BinaryDelta(data),
444 false => PatchPayload::BinaryLiteral(data),
445 })
446}
447
448#[derive(Default)]
449struct FileHeaders {
450 diff_old: Option<String>,
451 diff_new: Option<String>,
452 old_mode: Option<EntryKind>,
453 new_mode: Option<EntryKind>,
454 created: bool,
455 deleted: bool,
456 rename_from: Option<String>,
457 rename_to: Option<String>,
458 copy_from: Option<String>,
459 copy_to: Option<String>,
460 old_index: Option<Oid>,
461 label_old: Option<Option<String>>,
462 label_new: Option<Option<String>>,
463}
464
465fn parse_extended_headers(
466 cursor: &mut Cursor<'_>,
467 headers: &mut FileHeaders,
468) -> Result<(), PatchParseError> {
469 std::iter::from_fn(|| {
470 let line = cursor.peek()?;
471 let step: Option<Result<(), PatchParseError>> = if let Some(rest) =
472 line.strip_prefix("old mode ")
473 {
474 headers.old_mode = EntryKind::from_git_mode(rest);
475 Some(Ok(()))
476 } else if let Some(rest) = line.strip_prefix("new mode ") {
477 headers.new_mode = EntryKind::from_git_mode(rest);
478 Some(Ok(()))
479 } else if let Some(rest) = line.strip_prefix("new file mode ") {
480 headers.created = true;
481 headers.new_mode = EntryKind::from_git_mode(rest);
482 Some(Ok(()))
483 } else if let Some(rest) = line.strip_prefix("deleted file mode ") {
484 headers.deleted = true;
485 headers.old_mode = EntryKind::from_git_mode(rest);
486 Some(Ok(()))
487 } else if let Some(rest) = line.strip_prefix("rename from ") {
488 Some(unquote(rest).map(|path| {
489 headers.rename_from = Some(path);
490 }))
491 } else if let Some(rest) = line.strip_prefix("rename to ") {
492 Some(unquote(rest).map(|path| {
493 headers.rename_to = Some(path);
494 }))
495 } else if let Some(rest) = line.strip_prefix("copy from ") {
496 Some(unquote(rest).map(|path| {
497 headers.copy_from = Some(path);
498 }))
499 } else if let Some(rest) = line.strip_prefix("copy to ") {
500 Some(unquote(rest).map(|path| {
501 headers.copy_to = Some(path);
502 }))
503 } else if line.starts_with("similarity index ") || line.starts_with("dissimilarity index ")
504 {
505 Some(Ok(()))
506 } else if let Some(rest) = line.strip_prefix("index ") {
507 let (oids, mode) = rest
508 .split_once(' ')
509 .map(|(oids, mode)| (oids, Some(mode)))
510 .unwrap_or((rest, None));
511 if let Some((old, _)) = oids.split_once("..") {
512 headers.old_index = full_oid(old);
513 }
514 if let Some(kind) = mode.and_then(EntryKind::from_git_mode) {
515 headers.old_mode = headers.old_mode.or(Some(kind));
516 headers.new_mode = headers.new_mode.or(Some(kind));
517 }
518 Some(Ok(()))
519 } else {
520 None
521 };
522 step.inspect(|_| {
523 cursor.next();
524 })
525 })
526 .try_for_each(|outcome| outcome)
527}
528
529fn parse_labels_and_hunks(
530 cursor: &mut Cursor<'_>,
531 headers: &mut FileHeaders,
532 budget: &mut Budget,
533) -> Result<PatchPayload, PatchParseError> {
534 let old_raw = cursor
535 .take_prefix("--- ")
536 .ok_or_else(|| malformed("expected --- label"))?;
537 headers.label_old = Some(parse_label(old_raw)?);
538 let new_raw = cursor
539 .take_prefix("+++ ")
540 .ok_or_else(|| malformed("expected +++ label"))?;
541 headers.label_new = Some(parse_label(new_raw)?);
542 Ok(PatchPayload::Text(parse_hunks(cursor, budget)?))
543}
544
545fn assemble(headers: FileHeaders, payload: PatchPayload) -> Result<ParsedFile, PatchParseError> {
546 let FileHeaders {
547 diff_old,
548 diff_new,
549 old_mode,
550 new_mode,
551 created,
552 deleted,
553 rename_from,
554 rename_to,
555 copy_from,
556 copy_to,
557 old_index,
558 label_old,
559 label_new,
560 } = headers;
561 let created = created || matches!(label_old, Some(None));
562 let deleted = deleted || matches!(label_new, Some(None));
563 let need = |path: Option<String>, what: &str| {
564 path.ok_or_else(|| malformed(format!("file section is missing its {what} path")))
565 };
566 let (intent, path) = match (rename_from, rename_to, copy_from, copy_to) {
567 (Some(from), to, _, _) => (FileIntent::Rename { from }, need(to, "rename target")?),
568 (_, _, Some(from), to) => (FileIntent::Copy { from }, need(to, "copy target")?),
569 _ if created => (
570 FileIntent::Create,
571 need(label_new.flatten().or(diff_new), "new")?,
572 ),
573 _ if deleted => (
574 FileIntent::Delete,
575 need(label_old.flatten().or(diff_old), "old")?,
576 ),
577 _ => (
578 FileIntent::Modify,
579 need(label_new.flatten().or(diff_new), "target")?,
580 ),
581 };
582 Ok(ParsedFile {
583 path,
584 intent,
585 old_kind: old_mode,
586 new_kind: new_mode,
587 old_index,
588 payload,
589 })
590}
591
592fn parse_git_file(
593 cursor: &mut Cursor<'_>,
594 budget: &mut Budget,
595) -> Result<ParsedFile, PatchParseError> {
596 let rest = cursor
597 .take_prefix("diff --git ")
598 .ok_or_else(|| malformed("expected diff --git header"))?;
599 let mut headers = FileHeaders::default();
600 if let Some((old, new)) = diff_paths(rest) {
601 headers.diff_old = Some(old);
602 headers.diff_new = Some(new);
603 }
604 parse_extended_headers(cursor, &mut headers)?;
605 let payload = match cursor.peek() {
606 Some(line) if line.starts_with("--- ") => {
607 parse_labels_and_hunks(cursor, &mut headers, budget)?
608 }
609 Some("GIT binary patch") => parse_binary_payload(cursor, budget)?,
610 Some(line) if line.starts_with("Binary files ") => {
611 cursor.next();
612 PatchPayload::BinaryOpaque
613 }
614 _ => PatchPayload::Text(Vec::new()),
615 };
616 assemble(headers, payload)
617}
618
619fn parse_traditional_file(
620 cursor: &mut Cursor<'_>,
621 budget: &mut Budget,
622) -> Result<ParsedFile, PatchParseError> {
623 let mut headers = FileHeaders::default();
624 let payload = parse_labels_and_hunks(cursor, &mut headers, budget)?;
625 assemble(headers, payload)
626}
627
628fn at_file_start(cursor: &Cursor<'_>) -> bool {
629 match cursor.peek() {
630 Some(line) if line.starts_with("diff --git ") => true,
631 Some(line) if line.starts_with("--- ") => cursor
632 .lines
633 .get(cursor.pos + 1)
634 .is_some_and(|next| next.starts_with("+++ ")),
635 _ => false,
636 }
637}
638
639fn skip_to_file_start(cursor: &mut Cursor<'_>) -> bool {
640 std::iter::from_fn(|| {
641 (!at_file_start(cursor) && cursor.peek().is_some()).then(|| cursor.next())
642 })
643 .for_each(|_| ());
644 cursor.peek().is_some()
645}
646
647pub fn parse_patch(text: &str) -> Result<Vec<ParsedFile>, PatchParseError> {
648 parse_patch_bounded(text, MAX_TOTAL_PATCH_BYTES)
649}
650
651pub fn parse_patch_bounded(text: &str, max_bytes: u64) -> Result<Vec<ParsedFile>, PatchParseError> {
652 parse_patch_budgeted(text, &mut Budget::new(max_bytes))
653}
654
655fn parse_patch_budgeted(
656 text: &str,
657 budget: &mut Budget,
658) -> Result<Vec<ParsedFile>, PatchParseError> {
659 if text.trim().is_empty() {
660 return Err(PatchParseError::Empty);
661 }
662 let lines: Vec<&str> = text.split('\n').collect();
663 let mut cursor = Cursor::new(&lines);
664 let files: Vec<ParsedFile> = std::iter::from_fn(|| {
665 skip_to_file_start(&mut cursor).then(|| match cursor.peek() {
666 Some(line) if line.starts_with("diff --git ") => parse_git_file(&mut cursor, budget),
667 _ => parse_traditional_file(&mut cursor, budget),
668 })
669 })
670 .collect::<Result<_, _>>()?;
671 match files.is_empty() {
672 true => Err(PatchParseError::NoFiles),
673 false => Ok(files),
674 }
675}
676
677fn is_mail_divider(line: &str) -> bool {
678 line.strip_prefix("From ").is_some_and(|rest| {
679 rest.len() > 40
680 && rest.as_bytes()[..40]
681 .iter()
682 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
683 && rest.as_bytes()[40] == b' '
684 })
685}
686
687fn split_mail(text: &str) -> Vec<&str> {
688 let starts: Vec<usize> = text
689 .split_inclusive('\n')
690 .scan(0usize, |offset, line| {
691 let start = *offset;
692 *offset += line.len();
693 Some((start, line))
694 })
695 .filter(|(_, line)| is_mail_divider(line.trim_end_matches('\n')))
696 .map(|(start, _)| start)
697 .collect();
698 match starts.is_empty() {
699 true => vec![text],
700 false => {
701 let ends = starts
702 .iter()
703 .skip(1)
704 .copied()
705 .chain(std::iter::once(text.len()));
706 starts
707 .iter()
708 .copied()
709 .zip(ends)
710 .map(|(start, end)| &text[start..end])
711 .collect()
712 }
713 }
714}
715
716fn decode_q(bytes: &[u8]) -> Option<Vec<u8>> {
717 let hex = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
718 let mut pos = 0usize;
719 std::iter::from_fn(move || match bytes.get(pos..) {
720 None | Some([]) => None,
721 Some(slice) => {
722 let decoded: Option<u8> = match slice {
723 [b'_', ..] => apply(&mut pos, 1, b' '),
724 [b'=', high, low, ..] => {
725 let byte = hex(*high).zip(hex(*low)).map(|(high, low)| high * 16 + low);
726 pos += 3;
727 byte
728 }
729 [b'=', ..] => {
730 pos += 1;
731 None
732 }
733 [byte, ..] => apply(&mut pos, 1, *byte),
734 [] => None,
735 };
736 Some(decoded)
737 }
738 })
739 .collect()
740}
741
742fn decode_rfc2047_word(word: &str) -> Option<String> {
743 let inner = word.strip_prefix("=?")?.strip_suffix("?=")?;
744 let (charset, rest) = inner.split_once('?')?;
745 let (encoding, payload) = rest.split_once('?')?;
746 if !charset.eq_ignore_ascii_case("utf-8") {
747 return None;
748 }
749 let bytes = match encoding {
750 "Q" | "q" => decode_q(payload.as_bytes())?,
751 "B" | "b" => base64::engine::general_purpose::STANDARD
752 .decode(payload)
753 .ok()?,
754 _ => return None,
755 };
756 Some(String::from_utf8_lossy(&bytes).into_owned())
757}
758
759fn decode_rfc2047(value: &str) -> String {
760 value
761 .split(' ')
762 .filter(|token| !token.is_empty())
763 .map(|token| match decode_rfc2047_word(token) {
764 Some(decoded) => (true, decoded),
765 None => (false, token.to_string()),
766 })
767 .fold(
768 (String::new(), false),
769 |(mut acc, prev_encoded), (encoded, text)| {
770 if !(acc.is_empty() || prev_encoded && encoded) {
771 acc.push(' ');
772 }
773 acc.push_str(&text);
774 (acc, encoded)
775 },
776 )
777 .0
778}
779
780fn strip_subject_prefix(subject: &str) -> String {
781 let stripped = std::iter::successors(Some(subject.trim_start()), |current| {
782 current
783 .strip_prefix('[')
784 .and_then(|rest| rest.split_once(']'))
785 .map(|(_, tail)| tail.trim_start())
786 })
787 .last()
788 .unwrap_or("");
789 match stripped.starts_with('[') {
790 true => stripped.to_string(),
791 false => stripped.trim_end().to_string(),
792 }
793}
794
795fn parse_address(raw: &str) -> (AuthorName, Email) {
796 let decoded = decode_rfc2047(raw.trim());
797 match decoded.rsplit_once('<') {
798 Some((name, rest)) => {
799 let email = rest.split('>').next().unwrap_or(rest).trim();
800 let name = name.trim().trim_matches('"').trim();
801 (AuthorName::new(name), Email::new(email))
802 }
803 None => {
804 let bare = decoded.trim();
805 (AuthorName::new(bare), Email::new(bare))
806 }
807 }
808}
809
810fn fold_headers(lines: &[&str]) -> Vec<(String, String)> {
811 lines.iter().fold(Vec::new(), |mut acc, line| {
812 match line.strip_prefix(' ').or_else(|| line.strip_prefix('\t')) {
813 Some(continuation) => {
814 if let Some(last) = acc.last_mut() {
815 last.1.push(' ');
816 last.1.push_str(continuation.trim());
817 }
818 }
819 None => {
820 if let Some((name, value)) = line.split_once(':') {
821 acc.push((name.trim().to_string(), value.trim().to_string()));
822 }
823 }
824 }
825 acc
826 })
827}
828
829fn parse_mail(chunk: &str, budget: &mut Budget) -> Result<MailPatch, PatchParseError> {
830 let lines: Vec<&str> = chunk.split('\n').collect();
831 let after_divider: &[&str] = match lines.split_first() {
832 Some((first, rest)) if is_mail_divider(first) => rest,
833 _ => &lines,
834 };
835 let header_end = after_divider
836 .iter()
837 .position(|line| line.trim().is_empty())
838 .ok_or_else(|| malformed("mail patch has no header separator"))?;
839 let headers = fold_headers(&after_divider[..header_end]);
840 let header = |name: &str| {
841 headers
842 .iter()
843 .find(|(key, _)| key.eq_ignore_ascii_case(name))
844 .map(|(_, value)| value.clone())
845 };
846 let (author_name, author_email) = header("From")
847 .map(|raw| parse_address(&raw))
848 .ok_or_else(|| malformed("mail patch has no From header"))?;
849 let rest = &after_divider[header_end + 1..];
850 let body_end = rest
851 .iter()
852 .position(|line| line.trim_end() == "---" || line.starts_with("diff --git "))
853 .unwrap_or(rest.len());
854 let body = rest[..body_end].join("\n").trim().to_string();
855 let files = parse_patch_budgeted(&rest[body_end..].join("\n"), budget)?;
856 Ok(MailPatch {
857 author_name,
858 author_email,
859 date: header("Date").unwrap_or_default(),
860 subject: strip_subject_prefix(&decode_rfc2047(&header("Subject").unwrap_or_default())),
861 body,
862 change_id: header("Change-Id").and_then(|raw| CommitChangeId::new(raw).ok()),
863 files,
864 })
865}
866
867pub fn parse_mailbox(text: &str) -> Result<Vec<MailPatch>, PatchParseError> {
868 parse_mailbox_bounded(text, MAX_TOTAL_PATCH_BYTES)
869}
870
871pub fn parse_mailbox_bounded(
872 text: &str,
873 max_bytes: u64,
874) -> Result<Vec<MailPatch>, PatchParseError> {
875 if text.trim().is_empty() {
876 return Err(PatchParseError::Empty);
877 }
878 let mut budget = Budget::new(max_bytes);
879 split_mail(text)
880 .into_iter()
881 .map(|chunk| parse_mail(chunk, &mut budget))
882 .collect()
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
890 fn format_patch_detection_matches_the_mailbox_heuristic() {
891 assert!(is_format_patch(
892 "From 0123456789012345678901234567890123456789 Mon Sep 17 00:00:00 2001\nFrom: nel <nel@oyster.cafe>\n"
893 ));
894 assert!(is_format_patch(
895 "From: nel <nel@oyster.cafe>\nSubject: [PATCH] tide pool\n\n"
896 ));
897 assert!(!is_format_patch(
898 "diff --git a/reef.txt b/reef.txt\n--- a/reef.txt\n+++ b/reef.txt\n"
899 ));
900 assert!(!is_format_patch(""));
901 }
902
903 #[test]
904 fn a_simple_modification_parses() {
905 let patch = "diff --git a/reef.txt b/reef.txt\nindex 1111111..2222222 100644\n--- a/reef.txt\n+++ b/reef.txt\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n";
906 let files = parse_patch(patch).unwrap();
907 assert_eq!(files.len(), 1);
908 assert_eq!(files[0].path, "reef.txt");
909 assert_eq!(files[0].intent, FileIntent::Modify);
910 let PatchPayload::Text(hunks) = &files[0].payload else {
911 panic!("expected text payload");
912 };
913 assert_eq!(hunks.len(), 1);
914 assert_eq!(hunks[0].lines.len(), 3);
915 }
916
917 #[test]
918 fn creations_deletions_and_renames_parse() {
919 let patch = concat!(
920 "diff --git a/new.txt b/new.txt\n",
921 "new file mode 100644\n",
922 "index 0000000..2222222\n",
923 "--- /dev/null\n",
924 "+++ b/new.txt\n",
925 "@@ -0,0 +1 @@\n",
926 "+hello\n",
927 "diff --git a/gone.txt b/gone.txt\n",
928 "deleted file mode 100755\n",
929 "index 2222222..0000000\n",
930 "--- a/gone.txt\n",
931 "+++ /dev/null\n",
932 "@@ -1 +0,0 @@\n",
933 "-bye\n",
934 "diff --git a/old.txt b/moved.txt\n",
935 "similarity index 100%\n",
936 "rename from old.txt\n",
937 "rename to moved.txt\n",
938 );
939 let files = parse_patch(patch).unwrap();
940 assert_eq!(files.len(), 3);
941 assert_eq!(files[0].intent, FileIntent::Create);
942 assert_eq!(files[0].new_kind, Some(EntryKind::Blob));
943 assert_eq!(files[1].intent, FileIntent::Delete);
944 assert_eq!(files[1].old_kind, Some(EntryKind::BlobExecutable));
945 assert_eq!(
946 files[2].intent,
947 FileIntent::Rename {
948 from: "old.txt".to_string()
949 }
950 );
951 assert_eq!(files[2].path, "moved.txt");
952 }
953
954 #[test]
955 fn the_no_newline_marker_strips_the_trailing_newline() {
956 let patch = "diff --git a/reef.txt b/reef.txt\nindex 1111111..2222222 100644\n--- a/reef.txt\n+++ b/reef.txt\n@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file\n";
957 let files = parse_patch(patch).unwrap();
958 let PatchPayload::Text(hunks) = &files[0].payload else {
959 panic!("expected text payload");
960 };
961 assert_eq!(hunks[0].lines[0].text, b"old\n".to_vec());
962 assert_eq!(hunks[0].lines[1].text, b"new".to_vec());
963 }
964
965 #[test]
966 fn quoted_paths_unescape() {
967 assert_eq!(unquote("\"a/sp ace.txt\"").unwrap(), "a/sp ace.txt");
968 assert_eq!(unquote("\"a/tab\\there\"").unwrap(), "a/tab\there");
969 assert_eq!(unquote("\"a/\\303\\251\"").unwrap(), "a/é");
970 assert!(unquote("\"a/broken").is_err());
971 }
972
973 #[test]
974 fn a_mailbox_splits_into_individual_patches() {
975 let mbox = concat!(
976 "From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001\n",
977 "From: nel <nel@oyster.cafe>\n",
978 "Date: Tue, 5 Sep 2023 12:00:00 +0530\n",
979 "Subject: [PATCH 1/2] first\n",
980 "\n",
981 "body text\n",
982 "---\n",
983 " reef.txt | 1 +\n",
984 " 1 file changed, 1 insertion(+)\n",
985 "\n",
986 "diff --git a/reef.txt b/reef.txt\n",
987 "new file mode 100644\n",
988 "index 0000000..2222222\n",
989 "--- /dev/null\n",
990 "+++ b/reef.txt\n",
991 "@@ -0,0 +1 @@\n",
992 "+one\n",
993 "-- \n2.43.0\n\n",
994 "From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001\n",
995 "From: =?UTF-8?q?t=C3=A9q?= <teq@nel.pet>\n",
996 "Date: Tue, 5 Sep 2023 13:00:00 +0530\n",
997 "Subject: [PATCH 2/2] second\n",
998 "Change-Id: I0123456789abcdef\n",
999 "\n",
1000 "---\n",
1001 "diff --git a/reef.txt b/reef.txt\n",
1002 "index 2222222..3333333 100644\n",
1003 "--- a/reef.txt\n",
1004 "+++ b/reef.txt\n",
1005 "@@ -1 +1 @@\n",
1006 "-one\n",
1007 "+two\n",
1008 );
1009 let mails = parse_mailbox(mbox).unwrap();
1010 assert_eq!(mails.len(), 2);
1011 assert_eq!(mails[0].author_name.as_str(), "nel");
1012 assert_eq!(mails[0].author_email.as_str(), "nel@oyster.cafe");
1013 assert_eq!(mails[0].subject, "first");
1014 assert_eq!(mails[0].body, "body text");
1015 assert_eq!(mails[0].commit_message(), "first\n\nbody text");
1016 assert_eq!(mails[0].files.len(), 1);
1017 assert_eq!(mails[1].author_name.as_str(), "téq");
1018 assert_eq!(
1019 mails[1].change_id,
1020 Some(CommitChangeId::new("I0123456789abcdef").unwrap())
1021 );
1022 assert_eq!(mails[1].files[0].intent, FileIntent::Modify);
1023 }
1024
1025 #[test]
1026 fn base85_decodes_lengths_and_rejects_garbage() {
1027 let mut out = Vec::new();
1028 decode_base85_line("D00000", &mut out).unwrap();
1029 assert_eq!(out, vec![0, 0, 0, 0]);
1030 let mut out = Vec::new();
1031 decode_base85_line("B00000", &mut out).unwrap();
1032 assert_eq!(out, vec![0, 0]);
1033 assert!(decode_base85_line("D0000", &mut Vec::new()).is_err());
1034 assert!(decode_base85_line("D0\"000", &mut Vec::new()).is_err());
1035 assert!(decode_base85_line("?00000", &mut Vec::new()).is_err());
1036 }
1037
1038 #[test]
1039 fn malformed_input_yields_typed_errors() {
1040 assert_eq!(parse_patch(" \n "), Err(PatchParseError::Empty));
1041 assert_eq!(parse_patch("hello world\n"), Err(PatchParseError::NoFiles));
1042
1043 let patch = "diff --git a/r.txt b/r.txt\n--- a/r.txt\n+++ b/r.txt\n@@ -0,0 +1 @@\n+a line that is wider than four bytes\n";
1044 let mut tight = Budget { remaining: 4 };
1045 assert_eq!(
1046 parse_patch_budgeted(patch, &mut tight),
1047 Err(malformed("patch exceeds total decompressed size budget")),
1048 );
1049 let mut roomy = Budget { remaining: 1_000 };
1050 assert!(parse_patch_budgeted(patch, &mut roomy).is_ok());
1051 }
1052
1053 #[test]
1054 fn pathological_depth_inputs_do_not_overflow_the_stack() {
1055 let huge = "a".repeat(1_000_000);
1056 assert_eq!(unquote(&format!("\"{huge}\"")).unwrap(), huge);
1057 let brackets = "[x]".repeat(500_000);
1058 assert_eq!(strip_subject_prefix(&brackets), "");
1059 let encoded = format!("=?utf-8?q?{}?=", "=41".repeat(400_000));
1060 assert_eq!(decode_rfc2047(&encoded), "A".repeat(400_000));
1061 }
1062
1063 #[test]
1064 fn a_bare_email_from_header_becomes_both_name_and_email() {
1065 let mbox = concat!(
1066 "From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001\n",
1067 "From: nel@oyster.cafe\n",
1068 "Date: Tue, 5 Sep 2023 12:00:00 +0000\n",
1069 "Subject: [PATCH] bare\n",
1070 "\n",
1071 "diff --git a/reef.txt b/reef.txt\n",
1072 "new file mode 100644\n",
1073 "--- /dev/null\n",
1074 "+++ b/reef.txt\n",
1075 "@@ -0,0 +1 @@\n",
1076 "+hi\n",
1077 );
1078 let mails = parse_mailbox(mbox).unwrap();
1079 assert_eq!(mails[0].author_name.as_str(), "nel@oyster.cafe");
1080 assert_eq!(mails[0].author_email.as_str(), "nel@oyster.cafe");
1081 }
1082}