This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-git / src / patch_parse.rs
36 kB 1082 lines
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}