1mod apply_blob;
19
20pub use apply_blob::{apply_hunks_to_blob, ApplyOptions};
21
22use crate::error::{ApplyError, Error, Result};
23use crate::quote_path_root::unquote_c_style_prefix;
24use regex::Regex;
25use std::sync::OnceLock;
26
27#[derive(Debug, Clone)]
29pub struct Hunk {
30 pub old_start: usize,
32 pub old_count: usize,
34 pub new_start: usize,
36 pub new_count: usize,
38 pub first_body_line: usize,
40 pub lines: Vec<HunkLine>,
42}
43
44#[derive(Debug, Clone)]
46pub enum HunkLine {
47 Context(String),
49 Add(String),
51 Remove(String),
53 NoNewline,
55}
56
57#[derive(Debug, Clone)]
59pub struct FilePatch {
60 pub diff_old_path: Option<String>,
62 pub diff_new_path: Option<String>,
64 pub old_path: Option<String>,
66 pub new_path: Option<String>,
68 pub saw_old_header: bool,
70 pub saw_new_header: bool,
72 pub old_mode: Option<String>,
74 pub new_mode: Option<String>,
76 pub old_mode_line: Option<usize>,
78 pub new_mode_line: Option<usize>,
80 pub is_new: bool,
82 pub is_deleted: bool,
84 pub is_rename: bool,
86 pub is_copy: bool,
88 pub similarity_index: Option<u32>,
90 pub dissimilarity_index: Option<u32>,
92 pub old_oid: Option<String>,
94 pub new_oid: Option<String>,
96 pub binary_patch: Option<BinaryPatchPayload>,
98 pub is_binary: bool,
101 pub hunks: Vec<Hunk>,
103 pub ws_rule: u32,
105 pub is_toplevel_relative: bool,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum BinaryPatchSectionKind {
113 Literal,
115 Delta,
117}
118
119#[derive(Debug, Clone)]
121pub struct BinaryPatchSection {
122 pub kind: BinaryPatchSectionKind,
124 pub compressed: Vec<u8>,
126 pub declared_size: usize,
128}
129
130#[derive(Debug, Clone)]
132pub struct BinaryPatchPayload {
133 pub forward: BinaryPatchSection,
135 pub reverse: Option<BinaryPatchSection>,
137}
138
139impl FilePatch {
140 fn empty() -> Self {
141 Self {
142 diff_old_path: None,
143 diff_new_path: None,
144 old_path: None,
145 new_path: None,
146 saw_old_header: false,
147 saw_new_header: false,
148 old_mode: None,
149 new_mode: None,
150 old_mode_line: None,
151 new_mode_line: None,
152 is_new: false,
153 is_deleted: false,
154 is_rename: false,
155 is_copy: false,
156 similarity_index: None,
157 dissimilarity_index: None,
158 old_oid: None,
159 new_oid: None,
160 binary_patch: None,
161 is_binary: false,
162 hunks: Vec::new(),
163 ws_rule: 0,
164 is_toplevel_relative: false,
165 }
166 }
167
168 pub fn effective_path(&self) -> Option<&str> {
173 if self.is_deleted {
174 return self
175 .old_path
176 .as_deref()
177 .filter(|p| *p != "/dev/null")
178 .or(self.new_path.as_deref().filter(|p| *p != "/dev/null"));
179 }
180 if self.is_new {
181 return self
182 .new_path
183 .as_deref()
184 .filter(|p| *p != "/dev/null")
185 .or(self.old_path.as_deref().filter(|p| *p != "/dev/null"));
186 }
187 self.new_path
188 .as_deref()
189 .filter(|p| *p != "/dev/null")
190 .or(self.old_path.as_deref().filter(|p| *p != "/dev/null"))
191 }
192
193 pub fn source_path(&self) -> Option<&str> {
198 if self.is_rename || self.is_copy {
199 self.old_path
200 .as_deref()
201 .filter(|p| *p != "/dev/null")
202 .or(self.effective_path())
203 } else if let (Some(old), Some(new)) = (self.old_path.as_deref(), self.new_path.as_deref())
204 {
205 if old != "/dev/null" && new != "/dev/null" && old != new {
206 Some(old)
207 } else {
208 self.effective_path()
209 }
210 } else {
211 self.effective_path()
212 }
213 }
214
215 pub fn target_path(&self) -> Option<&str> {
220 if self.is_new || self.is_rename || self.is_copy {
221 self.new_path
222 .as_deref()
223 .filter(|p| *p != "/dev/null")
224 .or(self.effective_path())
225 } else {
226 self.effective_path()
227 }
228 }
229
230 pub fn involves_gitlink(&self) -> bool {
232 self.old_mode.as_deref() == Some("160000") || self.new_mode.as_deref() == Some("160000")
233 }
234}
235
236fn sanitize_patch_header_value(s: &mut String) {
241 *s = s.trim().trim_end_matches('\r').to_string();
242}
243
244fn strip_git_diff_path_prefix(path: &str) -> String {
249 if path == "/dev/null" {
250 return path.to_string();
251 }
252 let p = path.trim_start_matches("./");
253 if let Some(rest) = p.strip_prefix("a/") {
254 return rest.to_string();
255 }
256 if let Some(rest) = p.strip_prefix("b/") {
257 return rest.to_string();
258 }
259 path.to_string()
260}
261
262fn sanitize_file_patch_headers(fp: &mut FilePatch) {
263 if let Some(ref mut s) = fp.old_mode {
264 sanitize_patch_header_value(s);
265 if s.is_empty() {
266 fp.old_mode = None;
267 }
268 }
269 if let Some(ref mut s) = fp.new_mode {
270 sanitize_patch_header_value(s);
271 if s.is_empty() {
272 fp.new_mode = None;
273 }
274 }
275 if let Some(ref mut s) = fp.old_oid {
276 sanitize_patch_header_value(s);
277 }
278 if let Some(ref mut s) = fp.new_oid {
279 sanitize_patch_header_value(s);
280 }
281 for ref mut s in [
282 &mut fp.diff_old_path,
283 &mut fp.diff_new_path,
284 &mut fp.old_path,
285 &mut fp.new_path,
286 ]
287 .into_iter()
288 .flatten()
289 {
290 sanitize_patch_header_value(s);
291 **s = strip_git_diff_path_prefix(s);
292 }
293}
294
295fn squash_slash_path(s: &str) -> String {
297 let mut out = String::with_capacity(s.len());
298 let mut prev_slash = false;
299 for ch in s.chars() {
300 if ch == '/' {
301 if !prev_slash {
302 out.push('/');
303 }
304 prev_slash = true;
305 } else {
306 prev_slash = false;
307 out.push(ch);
308 }
309 }
310 out
311}
312
313fn bytes_to_path_string(bytes: &[u8]) -> Result<String> {
314 let s = String::from_utf8(bytes.to_vec())
315 .map_err(|e| Error::Message(format!("diff path is not valid UTF-8: {e}")))?;
316 Ok(squash_slash_path(&s))
317}
318
319fn skip_tree_prefix_bytes(line: &[u8], p_value: usize) -> Option<&[u8]> {
321 if p_value == 0 {
322 return Some(line);
323 }
324 let mut nslash = p_value;
325 let mut i = 0usize;
326 while i < line.len() {
327 if line[i] == b'/' {
328 nslash = nslash.saturating_sub(1);
329 if nslash == 0 {
330 return if i == 0 { None } else { Some(&line[i + 1..]) };
331 }
332 }
333 i += 1;
334 }
335 None
336}
337
338fn skip_tree_prefix_str(path: &str, p_value: usize) -> Option<String> {
340 let stripped = skip_tree_prefix_bytes(path.as_bytes(), p_value)?;
341 Some(String::from_utf8_lossy(stripped).into_owned())
342}
343
344fn sane_tz_len(line: &[u8]) -> usize {
345 const SUFFIX: &[u8] = b" +0500";
346 if line.len() < SUFFIX.len() || line[line.len() - SUFFIX.len()] != b' ' {
347 return 0;
348 }
349 let tz = &line[line.len() - SUFFIX.len()..];
350 if tz[1] != b'+' && tz[1] != b'-' {
351 return 0;
352 }
353 for p in &tz[2..] {
354 if !p.is_ascii_digit() {
355 return 0;
356 }
357 }
358 SUFFIX.len()
359}
360
361fn tz_with_colon_len(line: &[u8]) -> usize {
362 const SUFFIX_LEN: usize = 7;
364 if line.len() < SUFFIX_LEN || line[line.len() - 3] != b':' {
365 return 0;
366 }
367 let tz = &line[line.len() - SUFFIX_LEN..];
368 if tz[0] != b' ' || (tz[1] != b'+' && tz[1] != b'-') {
369 return 0;
370 }
371 let p = &tz[2..];
372 if p.len() != 5
373 || !p[0].is_ascii_digit()
374 || !p[1].is_ascii_digit()
375 || p[2] != b':'
376 || !p[3].is_ascii_digit()
377 || !p[4].is_ascii_digit()
378 {
379 return 0;
380 }
381 SUFFIX_LEN
382}
383
384fn date_len(line: &[u8]) -> usize {
385 const SHORT: &[u8] = b"72-02-05";
386 if line.len() < SHORT.len() || line[line.len() - 3] != b'-' {
387 return 0;
388 }
389 let mut p = line.len() - SHORT.len();
390 let date = &line[p..];
391 if !date[0].is_ascii_digit()
392 || !date[1].is_ascii_digit()
393 || date[2] != b'-'
394 || !date[3].is_ascii_digit()
395 || !date[4].is_ascii_digit()
396 || date[5] != b'-'
397 || !date[6].is_ascii_digit()
398 || !date[7].is_ascii_digit()
399 {
400 return 0;
401 }
402 if p >= 2 {
403 let y1 = line[p - 1];
404 let y2 = line[p - 2];
405 if y1.is_ascii_digit() && y2.is_ascii_digit() {
406 p -= 2;
407 }
408 }
409 line.len() - p
410}
411
412fn short_time_len(line: &[u8]) -> usize {
413 const PAT: &[u8] = b" 07:01:32";
414 if line.len() < PAT.len() || line[line.len() - 3] != b':' {
415 return 0;
416 }
417 let p = line.len() - PAT.len();
418 let time = &line[p..];
419 if time[0] != b' '
420 || !time[1].is_ascii_digit()
421 || !time[2].is_ascii_digit()
422 || time[3] != b':'
423 || !time[4].is_ascii_digit()
424 || !time[5].is_ascii_digit()
425 || time[6] != b':'
426 || !time[7].is_ascii_digit()
427 || !time[8].is_ascii_digit()
428 {
429 return 0;
430 }
431 PAT.len()
432}
433
434fn fractional_time_len(line: &[u8]) -> usize {
435 if line.is_empty() || !line[line.len() - 1].is_ascii_digit() {
436 return 0;
437 }
438 let mut p = line.len() - 1;
439 while p > 0 && line[p].is_ascii_digit() {
440 p -= 1;
441 }
442 if p == 0 || line[p] != b'.' {
443 return 0;
444 }
445 let n = short_time_len(&line[..p]);
446 if n == 0 {
447 return 0;
448 }
449 line.len() - p + n
450}
451
452fn trailing_spaces_len(line: &[u8]) -> usize {
453 if line.is_empty() || line[line.len() - 1] != b' ' {
454 return 0;
455 }
456 let mut p = line.len();
457 while p > 0 {
458 p -= 1;
459 if line[p] != b' ' {
460 return line.len() - (p + 1);
461 }
462 }
463 line.len()
464}
465
466fn diff_timestamp_len(line: &[u8]) -> usize {
467 if line.is_empty() || !line[line.len() - 1].is_ascii_digit() {
468 return 0;
469 }
470 let mut end = line.len();
471 let mut n = sane_tz_len(&line[..end]);
472 if n == 0 {
473 n = tz_with_colon_len(&line[..end]);
474 }
475 if n == 0 {
476 return 0;
477 }
478 end -= n;
479
480 n = short_time_len(&line[..end]);
481 if n == 0 {
482 n = fractional_time_len(&line[..end]);
483 }
484 if n == 0 {
485 return 0;
486 }
487 end -= n;
488
489 n = date_len(&line[..end]);
490 if n == 0 {
491 return 0;
492 }
493 end -= n;
494
495 if end == 0 {
496 return 0;
497 }
498 match line[end - 1] {
499 b'\t' => {
500 end -= 1;
501 line.len() - end
502 }
503 b' ' => {
504 end -= trailing_spaces_len(&line[..end]);
505 line.len() - end
506 }
507 _ => 0,
508 }
509}
510
511fn find_name_common_bounded(
513 line: &[u8],
514 def: Option<&[u8]>,
515 p_value: usize,
516 end: usize,
517) -> Option<Vec<u8>> {
518 let end = end.min(line.len());
519 let mut start: Option<usize> = if p_value == 0 { Some(0) } else { None };
520 let mut p = p_value;
521 let mut i = 0usize;
522 while i < end {
523 let c = line[i];
524 i += 1;
525 if c == b'/' && p > 0 {
526 p -= 1;
527 if p == 0 {
528 start = Some(i);
529 }
530 }
531 }
532 let start = start?;
533 let len = i - start;
534 if len == 0 {
535 return def.map(|d| d.to_vec());
536 }
537 let slice = &line[start..i];
538 if let Some(d) = def {
539 if d.len() < len && slice.starts_with(d) {
540 return Some(d.to_vec());
541 }
542 }
543 Some(slice.to_vec())
544}
545
546fn find_name_traditional(line: &[u8], def: Option<&[u8]>, p_value: usize) -> Option<Vec<u8>> {
548 if line.first() == Some(&b'"') {
549 let (decoded, _) = unquote_c_style_prefix(std::str::from_utf8(line).ok()?)?;
550 let skip = skip_tree_prefix_bytes(&decoded, p_value)?;
551 return Some(skip.to_vec());
552 }
553 let ts = diff_timestamp_len(line);
554 let name_end = line.len().saturating_sub(ts);
555 find_name_common_bounded(line, def, p_value, name_end)
556}
557
558fn find_name_tab_terminated(line: &[u8], p_value: usize) -> Option<Vec<u8>> {
559 if line.first() == Some(&b'"') {
560 let (decoded, _) = unquote_c_style_prefix(std::str::from_utf8(line).ok()?)?;
561 let skip = skip_tree_prefix_bytes(&decoded, p_value)?;
562 return Some(skip.to_vec());
563 }
564 let end = line
565 .iter()
566 .position(|&b| b == b'\t' || b == b'\n' || b == b'\r')
567 .unwrap_or(line.len());
568 find_name_common_bounded(line, None, p_value, end)
569}
570
571fn is_dev_null_nameline(line: &[u8]) -> bool {
572 line.strip_prefix(b"/dev/null")
573 .map(|rest| rest.is_empty() || rest.first().is_some_and(|b| b.is_ascii_whitespace()))
574 .unwrap_or(false)
575}
576
577fn count_slashes_in_prefix(prefix: &str) -> usize {
578 prefix.bytes().filter(|&b| b == b'/').count()
579}
580
581fn guess_p_value_from_nameline(line: &[u8], setup_prefix: Option<&str>) -> Option<usize> {
583 if is_dev_null_nameline(line) {
584 return None;
585 }
586 let name = find_name_traditional(line, None, 0)?;
587 let name_str = String::from_utf8_lossy(&name);
588 if !name_str.contains('/') {
589 return Some(0);
590 }
591 let pfx = setup_prefix.filter(|p| !p.is_empty())?;
592 if name_str.starts_with(pfx) {
593 return Some(count_slashes_in_prefix(pfx));
594 }
595 let slash = name_str.find('/')?;
596 let rest = name_str.get(slash + 1..)?;
597 if rest.starts_with(pfx) {
598 return Some(count_slashes_in_prefix(pfx) + 1);
599 }
600 None
601}
602
603fn epoch_stamp_regex() -> &'static Regex {
604 static RE: OnceLock<Regex> = OnceLock::new();
606 RE.get_or_init(|| {
607 #[allow(clippy::expect_used)]
609 Regex::new(r"^([0-2][0-9]):([0-5][0-9]):00(?:\.0+)? ([-+][0-2][0-9]:?[0-5][0-9])")
610 .expect("epoch stamp regex is a valid constant pattern")
611 })
612}
613
614fn has_epoch_timestamp(nameline: &[u8]) -> bool {
616 let Some(tab) = nameline.iter().position(|&b| b == b'\t') else {
617 return false;
618 };
619 let mut ts = &nameline[tab + 1..];
620 let epoch_hour = if let Some(r) = ts.strip_prefix(b"1969-12-31 ") {
621 ts = r;
622 24i32
623 } else if let Some(r) = ts.strip_prefix(b"1970-01-01 ") {
624 ts = r;
625 0i32
626 } else {
627 return false;
628 };
629 let end = ts.iter().position(|&b| b == b'\n').unwrap_or(ts.len());
630 let stamp = &ts[..end];
631 let stamp_str = match std::str::from_utf8(stamp) {
632 Ok(s) => s,
633 Err(_) => return false,
634 };
635 let caps = match epoch_stamp_regex().captures(stamp_str) {
636 Some(c) => c,
637 None => return false,
638 };
639 let hour: i32 = caps
640 .get(1)
641 .and_then(|m| m.as_str().parse().ok())
642 .unwrap_or(-1);
643 let minute: i32 = caps
644 .get(2)
645 .and_then(|m| m.as_str().parse().ok())
646 .unwrap_or(-1);
647 let tz_s = match caps.get(3).map(|m| m.as_str()) {
648 Some(s) if !s.is_empty() => s,
649 _ => return false,
650 };
651 if hour < 0 || minute < 0 {
652 return false;
653 }
654 let tz_byte = tz_s.as_bytes()[0];
655 let tz_rest = &tz_s[1..];
656 let zoneoffset: i32 = if let Some(colon_pos) = tz_rest.find(':') {
657 let h: i32 = tz_rest[..colon_pos].parse().unwrap_or(0);
658 let mm: i32 = tz_rest[colon_pos + 1..].parse().unwrap_or(0);
659 h * 60 + mm
660 } else if tz_rest.len() >= 4 {
661 let n: i32 = tz_rest[..4].parse().unwrap_or(0);
662 (n / 100) * 60 + (n % 100)
663 } else {
664 return false;
665 };
666 let zoneoffset = if tz_byte == b'-' {
667 -zoneoffset
668 } else {
669 zoneoffset
670 };
671 hour * 60 + minute - zoneoffset == epoch_hour * 60
672}
673
674fn parse_traditional_patch_pair(
676 old_line: &[u8],
677 new_line: &[u8],
678 strip: usize,
679 p_guess: &mut Option<usize>,
680 setup_prefix: Option<&str>,
681) -> Result<FilePatch> {
682 let old_p = old_line.strip_prefix(b"--- ").unwrap_or(old_line);
683 let new_p = new_line.strip_prefix(b"+++ ").unwrap_or(new_line);
684
685 if p_guess.is_none() {
686 let p = guess_p_value_from_nameline(old_p, setup_prefix);
687 let q = guess_p_value_from_nameline(new_p, setup_prefix);
688 let chosen = match (p, q) {
689 (None, None) => None,
690 (Some(a), None) => Some(a),
691 (None, Some(b)) => Some(b),
692 (Some(a), Some(b)) if a == b => Some(a),
693 _ => None,
694 };
695 *p_guess = chosen;
696 }
697 let p_val = p_guess.unwrap_or(strip);
698
699 let mut fp = FilePatch {
700 saw_old_header: true,
701 saw_new_header: true,
702 ..FilePatch::empty()
703 };
704
705 if is_dev_null_nameline(old_p) {
706 fp.is_new = true;
707 let name = find_name_traditional(new_p, None, p_val).ok_or_else(|| {
708 Error::Message("unable to find filename in traditional patch".to_string())
709 })?;
710 fp.new_path = Some(bytes_to_path_string(&name)?);
711 } else if is_dev_null_nameline(new_p) {
712 fp.is_deleted = true;
713 let name = find_name_traditional(old_p, None, p_val).ok_or_else(|| {
714 Error::Message("unable to find filename in traditional patch".to_string())
715 })?;
716 fp.old_path = Some(bytes_to_path_string(&name)?);
717 } else {
718 let first_name = find_name_traditional(old_p, None, p_val).ok_or_else(|| {
719 Error::Message("unable to find filename in traditional patch".to_string())
720 })?;
721 let name = find_name_traditional(new_p, Some(&first_name), p_val).ok_or_else(|| {
722 Error::Message("unable to find filename in traditional patch".to_string())
723 })?;
724 let name_str = bytes_to_path_string(&name)?;
725 if has_epoch_timestamp(old_p) {
726 fp.is_new = true;
727 fp.new_path = Some(name_str);
728 } else if has_epoch_timestamp(new_p) {
729 fp.is_deleted = true;
730 fp.old_path = Some(name_str);
731 } else {
732 fp.old_path = Some(name_str.clone());
735 fp.new_path = Some(name_str);
736 }
737 }
738
739 Ok(fp)
740}
741
742fn git_header_def_name(line: &str, p_value: usize) -> Option<String> {
744 let rest = line.strip_prefix("diff --git ").unwrap_or(line);
745 let rest_b = rest.as_bytes();
746
747 if rest_b.first() == Some(&b'"') {
748 let (first_decoded, second_raw) = unquote_c_style_prefix(rest)?;
749 let rel_first = skip_tree_prefix_bytes(&first_decoded, p_value)?;
750 let second = second_raw.trim_start_matches(|c: char| c.is_ascii_whitespace());
751 if second.is_empty() {
752 return None;
753 }
754 if second.as_bytes().first() == Some(&b'"') {
755 let (second_decoded, _) = unquote_c_style_prefix(second)?;
756 let rel2 = skip_tree_prefix_bytes(&second_decoded, p_value)?;
757 if rel2 != rel_first {
758 return None;
759 }
760 } else {
761 let rel2 = skip_tree_prefix_bytes(second.as_bytes(), p_value)?;
762 if rel2.len() != rel_first.len() || rel2 != rel_first {
763 return None;
764 }
765 }
766 return bytes_to_path_string(rel_first).ok();
767 }
768
769 let name = skip_tree_prefix_bytes(rest_b, p_value)?;
770 let name_start = name.as_ptr() as usize - rest_b.as_ptr() as usize;
771
772 for offset in 0..name.len() {
773 if name[offset] != b'"' {
774 continue;
775 }
776 let second_slice = &rest_b[name_start + offset..];
777 let (decoded, _) = unquote_c_style_prefix(std::str::from_utf8(second_slice).ok()?)?;
778 let np = skip_tree_prefix_bytes(&decoded, p_value)?;
779 let plen = np.len();
780 if plen < offset
781 && name.len() > plen
782 && &name[..plen] == np
783 && name[plen].is_ascii_whitespace()
784 {
785 return bytes_to_path_string(np).ok();
786 }
787 return None;
788 }
789
790 let line_len = rest.len().saturating_sub(name_start);
791 let mut len = 0usize;
792 while len < line_len {
793 match rest_b[name_start + len] {
794 b'\n' => return None,
795 b'\t' | b' ' => {
796 let after = name_start + len + 1;
797 if after > name_start + line_len {
798 return None;
799 }
800 let second =
801 skip_tree_prefix_bytes(&rest_b[after..name_start + line_len], p_value)?;
802 let names_match =
803 name.len() >= len && second.len() >= len && name[..len] == second[..len];
804 let boundary_ok = second.get(len) == Some(&b'\n') || second.len() == len;
805 if names_match && boundary_ok {
806 return bytes_to_path_string(&name[..len]).ok();
807 }
808 }
809 _ => {}
810 }
811 len += 1;
812 }
813 None
814}
815
816fn find_name_extended_header(rest: &str, p_extended: usize) -> Option<String> {
818 let rest = rest.trim_end_matches(['\r', '\n']);
819 let b = rest.as_bytes();
820 if b.first() == Some(&b'"') {
821 let (decoded, tail) = unquote_c_style_prefix(rest)?;
822 if !tail.trim().is_empty() {
823 return None;
824 }
825 let skip = skip_tree_prefix_bytes(&decoded, p_extended)?;
826 return bytes_to_path_string(skip).ok();
827 }
828 let end = b
829 .iter()
830 .position(|&c| c == b'\t' || c == b'\n' || c == b'\r' || c == b' ')
831 .unwrap_or(b.len());
832 let name = find_name_common_bounded(b, None, p_extended, end)?;
833 bytes_to_path_string(&name).ok()
834}
835
836pub fn parse_patch(
843 input: &str,
844 strip: usize,
845 input_name: &str,
846 recount: bool,
847 setup_prefix: Option<&str>,
848) -> Result<Vec<FilePatch>> {
849 let lines: Vec<&str> = input.lines().collect();
850 let mut patches = Vec::new();
851 let mut i = 0;
852 let mut p_guess_for_traditional = None;
853 let setup_prefix_for_guess = setup_prefix.filter(|p| !p.is_empty());
854
855 while i < lines.len() {
856 let mut fp = if lines[i].starts_with("diff --git ") {
857 let (fp, next_i) = parse_git_patch_header(&lines, i, strip)?;
858 i = next_i;
859 fp
860 } else if lines[i].starts_with("--- ")
861 && i + 1 < lines.len()
862 && lines[i + 1].starts_with("+++ ")
863 {
864 let fp = parse_traditional_patch_pair(
865 lines[i].as_bytes(),
866 lines[i + 1].as_bytes(),
867 strip,
868 &mut p_guess_for_traditional,
869 setup_prefix_for_guess,
870 )?;
871 i += 2;
872 while i < lines.len() && lines[i].starts_with("index ") {
873 i += 1;
874 }
875 fp
876 } else {
877 i += 1;
878 continue;
879 };
880
881 if i < lines.len() && lines[i] == "GIT binary patch" {
882 let (binary_patch, next_i) = parse_binary_patch(&lines, i + 1)?;
883 fp.binary_patch = Some(binary_patch);
884 fp.is_binary = true;
885 i = next_i;
886 } else {
887 while i < lines.len() && lines[i].starts_with("@@ ") {
888 let (hunk, next_i) = parse_hunk(&lines, i, input_name, recount)?;
889 fp.hunks.push(hunk);
890 i = next_i;
891 }
892 }
893
894 sanitize_file_patch_headers(&mut fp);
895 patches.push(fp);
896 }
897
898 Ok(patches)
899}
900
901fn parse_git_patch_header(
904 lines: &[&str],
905 mut i: usize,
906 strip: usize,
907) -> Result<(FilePatch, usize)> {
908 let p_strip = strip;
909 let p_extended = strip.saturating_sub(1);
910 let mut fp = FilePatch {
911 is_toplevel_relative: true,
912 ..FilePatch::empty()
913 };
914
915 let header_line = lines[i];
916 let def_name = git_header_def_name(header_line, p_strip);
917
918 let rest = &header_line["diff --git ".len()..];
920 if let Some((a, b)) = split_diff_git_paths(rest) {
921 fp.diff_old_path = Some(a.clone());
922 fp.diff_new_path = Some(b.clone());
923 fp.old_path = Some(skip_tree_prefix_str(&a, p_strip).ok_or_else(|| {
924 Error::Message(format!("malformed old path in diff --git header: {a}"))
925 })?);
926 fp.new_path = Some(skip_tree_prefix_str(&b, p_strip).ok_or_else(|| {
927 Error::Message(format!("malformed new path in diff --git header: {b}"))
928 })?);
929 }
930 i += 1;
931
932 while i < lines.len()
934 && !lines[i].starts_with("--- ")
935 && !lines[i].starts_with("diff --git ")
936 && !lines[i].starts_with("@@ ")
937 {
938 let line = lines[i];
939 let line_no = i + 1;
940 if let Some(val) = line.strip_prefix("old mode ") {
941 let v = val.trim_end_matches('\r').trim_end();
942 if v.is_empty() {
943 return Err(Error::Message(format!(
944 "invalid mode on line {line_no}: {line}"
945 )));
946 }
947 fp.old_mode = Some(v.to_string());
948 fp.old_mode_line = Some(line_no);
949 } else if let Some(val) = line.strip_prefix("new mode ") {
950 let v = val.trim_end_matches('\r').trim_end();
951 if v.is_empty() {
952 return Err(Error::Message(format!(
953 "invalid mode on line {line_no}: {line}"
954 )));
955 }
956 fp.new_mode = Some(v.to_string());
957 fp.new_mode_line = Some(line_no);
958 } else if let Some(val) = line.strip_prefix("new file mode ") {
959 let v = val.trim_end_matches('\r').trim_end();
960 if v.is_empty() {
961 return Err(Error::Message(format!(
962 "invalid mode on line {line_no}: {line}"
963 )));
964 }
965 fp.is_new = true;
966 fp.new_mode = Some(v.to_string());
967 fp.new_mode_line = Some(line_no);
968 } else if let Some(val) = line.strip_prefix("deleted file mode ") {
969 let v = val.trim_end_matches('\r').trim_end();
970 if v.is_empty() {
971 return Err(Error::Message(format!(
972 "invalid mode on line {line_no}: {line}"
973 )));
974 }
975 fp.is_deleted = true;
976 fp.old_mode = Some(v.to_string());
977 fp.old_mode_line = Some(line_no);
978 } else if let Some(val) = line.strip_prefix("rename from ") {
979 fp.is_rename = true;
980 if let Some(p) = find_name_extended_header(val, p_extended) {
981 fp.old_path = Some(p);
982 }
983 } else if let Some(val) = line.strip_prefix("rename to ") {
984 fp.is_rename = true;
985 if let Some(p) = find_name_extended_header(val, p_extended) {
986 fp.new_path = Some(p);
987 }
988 } else if let Some(val) = line.strip_prefix("copy from ") {
989 fp.is_copy = true;
990 if let Some(p) = find_name_extended_header(val, p_extended) {
991 fp.old_path = Some(p);
992 }
993 } else if let Some(val) = line.strip_prefix("copy to ") {
994 fp.is_copy = true;
995 if let Some(p) = find_name_extended_header(val, p_extended) {
996 fp.new_path = Some(p);
997 }
998 } else if let Some(val) = line.strip_prefix("similarity index ") {
999 fp.similarity_index = val.trim_end_matches('%').parse().ok();
1000 } else if let Some(val) = line.strip_prefix("dissimilarity index ") {
1001 fp.dissimilarity_index = val.trim_end_matches('%').parse().ok();
1002 } else if let Some(val) = line.strip_prefix("index ") {
1003 let mut parts = val.split_whitespace();
1005 let hash_part = parts.next().unwrap_or("");
1006 if let Some((old, new)) = hash_part.split_once("..") {
1007 fp.old_oid = Some(old.to_string());
1008 fp.new_oid = Some(new.to_string());
1009 }
1010 if let Some(mode_tok) = parts.next() {
1011 let v = mode_tok.trim_end_matches('\r').trim_end();
1012 if !v.is_empty() {
1013 fp.old_mode = Some(v.to_string());
1014 fp.old_mode_line = Some(line_no);
1015 }
1016 }
1017 } else if line == "GIT binary patch" {
1018 let (binary_patch, next_i) = parse_binary_patch(lines, i + 1)?;
1019 fp.binary_patch = Some(binary_patch);
1020 fp.is_binary = true;
1021 i = next_i;
1022 break;
1023 } else if line.starts_with("Binary files ") && line.ends_with(" differ") {
1024 fp.is_binary = true;
1027 }
1028 i += 1;
1030 }
1031
1032 if let Some(dn) = def_name {
1033 if fp.old_path.is_none() {
1034 fp.old_path = Some(dn.clone());
1035 }
1036 if fp.new_path.is_none() {
1037 fp.new_path = Some(dn);
1038 }
1039 }
1040
1041 if i < lines.len() && lines[i].starts_with("--- ") {
1043 let old_p = lines[i]["--- ".len()..].trim_end_matches(['\r', '\n']);
1044 let old_b = old_p.as_bytes();
1045 if is_dev_null_nameline(old_b) {
1046 fp.old_path = Some("/dev/null".to_string());
1047 } else if let Some(p) = find_name_tab_terminated(old_b, p_strip) {
1048 fp.old_path = Some(bytes_to_path_string(&p)?);
1049 }
1050 fp.saw_old_header = true;
1051 i += 1;
1052 if i < lines.len() && lines[i].starts_with("+++ ") {
1053 let new_p = lines[i]["+++ ".len()..].trim_end_matches(['\r', '\n']);
1054 let new_b = new_p.as_bytes();
1055 if is_dev_null_nameline(new_b) {
1056 fp.new_path = Some("/dev/null".to_string());
1057 } else if let Some(p) = find_name_tab_terminated(new_b, p_strip) {
1058 fp.new_path = Some(bytes_to_path_string(&p)?);
1059 }
1060 fp.saw_new_header = true;
1061 i += 1;
1062 }
1063 }
1064
1065 while i < lines.len() && lines[i].starts_with("index ") {
1066 let line = lines[i];
1067 let val = line["index ".len()..].trim_end();
1068 if let Some((old, new)) = val
1069 .split_whitespace()
1070 .next()
1071 .and_then(|h| h.split_once(".."))
1072 {
1073 fp.old_oid = Some(old.to_string());
1074 fp.new_oid = Some(new.to_string());
1075 }
1076 i += 1;
1077 }
1078
1079 Ok((fp, i))
1080}
1081
1082fn parse_binary_patch(lines: &[&str], mut i: usize) -> Result<(BinaryPatchPayload, usize)> {
1084 let forward = parse_binary_section(lines, &mut i)?;
1085 let reverse = if i < lines.len()
1086 && (lines[i].starts_with("literal ") || lines[i].starts_with("delta "))
1087 {
1088 Some(parse_binary_section(lines, &mut i)?)
1089 } else {
1090 None
1091 };
1092
1093 Ok((BinaryPatchPayload { forward, reverse }, i))
1094}
1095
1096fn parse_binary_section(lines: &[&str], i: &mut usize) -> Result<BinaryPatchSection> {
1098 let header = lines.get(*i).copied().unwrap_or_default();
1099 let (kind, size_str) = if let Some(s) = header.strip_prefix("literal ") {
1100 (BinaryPatchSectionKind::Literal, s)
1101 } else if let Some(s) = header.strip_prefix("delta ") {
1102 (BinaryPatchSectionKind::Delta, s)
1103 } else {
1104 return Err(Error::Message(format!(
1105 "unsupported binary patch section: '{header}'"
1106 )));
1107 };
1108 let declared_size: usize = size_str
1109 .trim()
1110 .parse()
1111 .map_err(|e: std::num::ParseIntError| {
1112 Error::Message(format!("invalid binary patch section size: {e}"))
1113 })?;
1114 *i += 1;
1115
1116 let mut compressed = Vec::new();
1117 while *i < lines.len() {
1118 let line = lines[*i];
1119 if line.is_empty() {
1120 *i += 1;
1121 break;
1122 }
1123 decode_binary_patch_line(line, &mut compressed)?;
1124 *i += 1;
1125 }
1126
1127 Ok(BinaryPatchSection {
1128 kind,
1129 compressed,
1130 declared_size,
1131 })
1132}
1133
1134fn decode_binary_patch_line(line: &str, out: &mut Vec<u8>) -> Result<()> {
1136 let mut chars = line.chars();
1137 let Some(len_ch) = chars.next() else {
1138 return Err(Error::Message(
1139 "empty binary patch payload line".to_string(),
1140 ));
1141 };
1142 let expected_len = decode_binary_line_len(len_ch)?;
1143 let body = chars.as_str().as_bytes();
1144 let decoded = crate::git_binary_base85::decode_body(body, expected_len)
1145 .map_err(|e| Error::Message(format!("invalid binary patch base85: {e}")))?;
1146 out.extend_from_slice(&decoded);
1147 Ok(())
1148}
1149
1150fn decode_binary_line_len(ch: char) -> Result<usize> {
1151 if ch.is_ascii_uppercase() {
1152 return Ok((ch as u8 - b'A' + 1) as usize);
1153 }
1154 if ch.is_ascii_lowercase() {
1155 return Ok((ch as u8 - b'a' + 27) as usize);
1156 }
1157 Err(Error::Message(format!(
1158 "invalid binary patch line length marker: '{ch}'"
1159 )))
1160}
1161
1162fn encode_binary_line_len(raw_len: usize) -> Result<char> {
1163 if raw_len == 0 || raw_len > 52 {
1164 return Err(Error::Message(format!(
1165 "binary patch line length out of range: {raw_len}"
1166 )));
1167 }
1168 let ch = if raw_len <= 26 {
1169 char::from(b'A' + (raw_len as u8) - 1)
1170 } else {
1171 char::from(b'a' + (raw_len as u8) - 27)
1172 };
1173 Ok(ch)
1174}
1175
1176fn zlib_compress_binary_payload(data: &[u8]) -> Result<Vec<u8>> {
1177 use flate2::write::ZlibEncoder;
1178 use flate2::Compression;
1179 use std::io::Write;
1180
1181 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1182 encoder
1183 .write_all(data)
1184 .map_err(|e| Error::Message(format!("zlib compress failed: {e}")))?;
1185 encoder
1186 .finish()
1187 .map_err(|e| Error::Message(format!("zlib compress finish failed: {e}")))
1188}
1189
1190fn append_binary_literal_section(out: &mut String, uncompressed: &[u8]) -> Result<()> {
1191 let compressed = zlib_compress_binary_payload(uncompressed)?;
1192 out.push_str(&format!("literal {}\n", uncompressed.len()));
1193 for chunk in compressed.chunks(52) {
1194 let len_ch = encode_binary_line_len(chunk.len())?;
1195 let body = crate::git_binary_base85::encode(chunk);
1196 out.push_str(&format!("{len_ch}{body}\n"));
1197 }
1198 out.push('\n');
1199 Ok(())
1200}
1201
1202pub fn write_git_binary_patch_sections<W: std::io::Write>(
1207 out: &mut W,
1208 old: &[u8],
1209 new: &[u8],
1210) -> Result<()> {
1211 out.write_all(b"GIT binary patch\n")
1212 .map_err(|e| Error::Message(format!("write binary patch header: {e}")))?;
1213 let mut buf = String::new();
1214 append_binary_literal_section(&mut buf, new)?;
1215 out.write_all(buf.as_bytes())
1216 .map_err(|e| Error::Message(format!("write binary patch forward: {e}")))?;
1217 buf.clear();
1218 append_binary_literal_section(&mut buf, old)?;
1219 out.write_all(buf.as_bytes())
1220 .map_err(|e| Error::Message(format!("write binary patch reverse: {e}")))?;
1221 Ok(())
1222}
1223
1224pub fn write_git_binary_patch(
1228 old: &[u8],
1229 new: &[u8],
1230 _old_path: &str,
1231 _new_path: &str,
1232) -> Result<String> {
1233 use crate::objects::{HashAlgo, ObjectKind};
1234
1235 let old_oid = HashAlgo::Sha1.hash_object(ObjectKind::Blob, old)?;
1236 let new_oid = HashAlgo::Sha1.hash_object(ObjectKind::Blob, new)?;
1237 let mut out = String::with_capacity(old.len() + new.len() + 128);
1238 out.push_str(&format!(
1239 "index {}..{} 100644\n",
1240 old_oid.to_hex(),
1241 new_oid.to_hex()
1242 ));
1243 out.push_str("GIT binary patch\n");
1244 append_binary_literal_section(&mut out, new)?;
1245 append_binary_literal_section(&mut out, old)?;
1246 Ok(out)
1247}
1248
1249pub fn inflate_binary_payload(compressed: &[u8]) -> Result<Vec<u8>> {
1251 use flate2::read::ZlibDecoder;
1252 use std::io::Read;
1253
1254 let mut decoder = ZlibDecoder::new(compressed);
1255 let mut out = Vec::new();
1256 decoder
1257 .read_to_end(&mut out)
1258 .map_err(|e| Error::Message(format!("failed to inflate binary patch payload: {e}")))?;
1259 Ok(out)
1260}
1261
1262pub fn materialize_binary_patch_section(
1266 section: &BinaryPatchSection,
1267 base: Option<&[u8]>,
1268) -> Result<Vec<u8>> {
1269 let inflated = inflate_binary_payload(§ion.compressed)?;
1270 match section.kind {
1271 BinaryPatchSectionKind::Literal => {
1272 if inflated.len() != section.declared_size {
1273 return Err(Error::Message(format!(
1274 "binary patch literal size {} != declared {}",
1275 inflated.len(),
1276 section.declared_size
1277 )));
1278 }
1279 Ok(inflated)
1280 }
1281 BinaryPatchSectionKind::Delta => {
1282 let base = base.ok_or_else(|| {
1283 Error::Message("binary patch delta requires a base blob".to_string())
1284 })?;
1285 crate::unpack_objects::apply_delta(base, &inflated)
1286 }
1287 }
1288}
1289
1290pub fn materialize_binary_patch_forward(
1292 payload: &BinaryPatchPayload,
1293 old: &[u8],
1294) -> Result<Vec<u8>> {
1295 materialize_binary_patch_section(&payload.forward, Some(old))
1296}
1297
1298pub fn materialize_binary_patch_reverse(
1300 payload: &BinaryPatchPayload,
1301 new: &[u8],
1302) -> Result<Vec<u8>> {
1303 let reverse = payload
1304 .reverse
1305 .as_ref()
1306 .ok_or_else(|| Error::Message("binary patch has no reverse section".to_string()))?;
1307 materialize_binary_patch_section(reverse, Some(new))
1308}
1309
1310fn split_diff_git_paths(s: &str) -> Option<(String, String)> {
1312 let s = s.trim_end_matches(['\r', '\n']);
1313
1314 if s.as_bytes().first() == Some(&b'"') {
1315 let (first, rest_raw) = unquote_c_style_prefix(s)?;
1316 let rest = rest_raw.trim_start_matches(|c: char| c.is_ascii_whitespace());
1317 if rest.is_empty() {
1318 return None;
1319 }
1320 if rest.as_bytes().first() == Some(&b'"') {
1321 let (second, _) = unquote_c_style_prefix(rest)?;
1322 return Some((
1323 String::from_utf8_lossy(&first).into_owned(),
1324 String::from_utf8_lossy(&second).into_owned(),
1325 ));
1326 }
1327 let second = rest;
1328 if second.len() != first.len() || second.as_bytes() != first.as_slice() {
1329 return None;
1330 }
1331 return Some((
1332 String::from_utf8_lossy(&first).into_owned(),
1333 second.to_string(),
1334 ));
1335 }
1336
1337 if let Some(pos) = s.find(" b/") {
1338 let a = &s[..pos];
1339 let b = &s[pos + 1..];
1340 return Some((a.to_string(), b.to_string()));
1341 }
1342 if s.starts_with("a/") {
1343 if let Some(pos) = s.find(" /dev/null") {
1344 let a = &s[..pos];
1345 return Some((a.to_string(), "/dev/null".to_string()));
1346 }
1347 }
1348 if let Some(b) = s.strip_prefix("/dev/null ") {
1349 return Some(("/dev/null".to_string(), b.to_string()));
1350 }
1351
1352 let name = s.as_bytes();
1353 let line_len = name.len();
1354 let mut len = 0usize;
1355 while len < line_len {
1356 match name[len] {
1357 b'\n' => return None,
1358 b'\t' | b' ' => {
1359 if len + 1 > line_len {
1360 return None;
1361 }
1362 let second = &name[len + 1..line_len];
1363 let names_match =
1364 name.len() >= len && second.len() >= len && name[..len] == second[..len];
1365 let boundary_ok = second.get(len) == Some(&b'\n') || second.len() == len;
1366 if names_match && boundary_ok {
1367 return Some((
1368 String::from_utf8_lossy(&name[..len]).into_owned(),
1369 String::from_utf8_lossy(second).into_owned(),
1370 ));
1371 }
1372 }
1373 _ => {}
1374 }
1375 len += 1;
1376 }
1377 None
1378}
1379
1380fn parse_hunk(
1382 lines: &[&str],
1383 start: usize,
1384 input_name: &str,
1385 recount: bool,
1386) -> Result<(Hunk, usize)> {
1387 let header = lines[start];
1388 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)
1389 .map_err(|e| Error::Message(format!("invalid hunk header: {header}: {e}")))?;
1390
1391 let mut hunk = Hunk {
1392 old_start,
1393 old_count,
1394 new_start,
1395 new_count,
1396 first_body_line: start + 2,
1397 lines: Vec::new(),
1398 };
1399
1400 let mut old_seen = 0usize;
1404 let mut new_seen = 0usize;
1405 let mut i = start + 1;
1406 while i < lines.len() {
1407 let line = lines[i];
1408 if line.starts_with("@@ ") || line.starts_with("diff --git ") {
1409 break;
1410 }
1411 if line.starts_with("--- ") || line.starts_with("+++ ") {
1414 break;
1415 }
1416 if line == "-- " {
1417 break;
1419 }
1420 if let Some(rest) = line.strip_prefix('+') {
1421 hunk.lines.push(HunkLine::Add(rest.to_string()));
1422 new_seen += 1;
1423 } else if let Some(rest) = line.strip_prefix('-') {
1424 hunk.lines.push(HunkLine::Remove(rest.to_string()));
1425 old_seen += 1;
1426 } else if line.is_empty() {
1427 hunk.lines.push(HunkLine::Context(String::new()));
1428 old_seen += 1;
1429 new_seen += 1;
1430 } else if let Some(rest) = line.strip_prefix(' ') {
1431 hunk.lines.push(HunkLine::Context(rest.to_string()));
1433 old_seen += 1;
1434 new_seen += 1;
1435 } else if line.starts_with('\\') {
1436 hunk.lines.push(HunkLine::NoNewline);
1437 } else {
1438 break;
1440 }
1441 i += 1;
1442 }
1443
1444 if recount {
1445 hunk.old_count = old_seen;
1446 hunk.new_count = new_seen;
1447 } else if old_seen < old_count || new_seen < new_count {
1448 return Err(ApplyError::CorruptPatch {
1449 input: input_name.to_owned(),
1450 line: i + 1,
1451 }
1452 .into());
1453 }
1454
1455 Ok((hunk, i))
1456}
1457
1458fn parse_hunk_header(line: &str) -> Result<(usize, usize, usize, usize)> {
1460 let trimmed = line.trim_start_matches('@').trim_start();
1462 let end = trimmed.find(" @@").unwrap_or(trimmed.len());
1463 let range_part = &trimmed[..end];
1464
1465 let parts: Vec<&str> = range_part.split_whitespace().collect();
1466 if parts.len() < 2 {
1467 return Err(Error::Message(
1468 "expected old and new range in hunk header".to_string(),
1469 ));
1470 }
1471
1472 let (old_start, old_count) = parse_range(parts[0].trim_start_matches('-'))?;
1473 let (new_start, new_count) = parse_range(parts[1].trim_start_matches('+'))?;
1474
1475 Ok((old_start, old_count, new_start, new_count))
1476}
1477
1478fn parse_range(s: &str) -> Result<(usize, usize)> {
1480 if let Some((start_s, count_s)) = s.split_once(',') {
1481 let start = start_s
1482 .parse::<usize>()
1483 .map_err(|e| Error::Message(e.to_string()))?;
1484 let count = count_s
1485 .parse::<usize>()
1486 .map_err(|e| Error::Message(e.to_string()))?;
1487 Ok((start, count))
1488 } else {
1489 let n: usize = s
1490 .parse()
1491 .map_err(|e: std::num::ParseIntError| Error::Message(e.to_string()))?;
1492 Ok((n, 1))
1493 }
1494}
1495
1496mod blob_apply;
1497mod index_apply;
1498
1499pub use blob_apply::{apply_patch_to_blob, BlobApplyOptions};
1500pub use index_apply::{
1501 apply_patch_to_index, apply_patch_to_index_and_write, IndexApplyOptions, IndexApplyOutcome,
1502};
1503
1504#[cfg(test)]
1505mod tests {
1506 use super::*;
1507
1508 #[test]
1509 fn binary_bodies_resume_at_following_git_patch() {
1510 let old = b"before\0";
1511 let new = b"after\0";
1512 let body = write_git_binary_patch(old, new, "a/bin", "b/bin").expect("binary body");
1513 for (header, git_style) in [
1514 ("diff --git a/bin b/bin\n", true),
1515 ("diff --git a/bin b/bin\n--- a/bin\n+++ b/bin\n", true),
1516 ("--- a/bin\n+++ b/bin\n", false),
1517 ] {
1518 let input = format!(
1519 "{header}{body}diff --git a/text b/text\n--- a/text\n+++ b/text\n@@ -1 +1 @@\n-old\n+new\n"
1520 );
1521 let files = parse_patch(&input, 1, "mixed", false, None).expect("parse mixed patch");
1522 assert_eq!(files.len(), 2);
1523 let binary = &files[0];
1524 assert_eq!(binary.effective_path(), Some("bin"));
1525 assert_eq!(binary.is_toplevel_relative, git_style);
1526 assert_eq!(binary.old_oid.is_some(), git_style);
1527 assert!(binary.is_binary);
1528 assert!(binary.hunks.is_empty());
1529 let payload = binary.binary_patch.as_ref().expect("binary payload");
1530 assert_eq!(materialize_binary_patch_forward(payload, old).unwrap(), new);
1531 assert_eq!(materialize_binary_patch_reverse(payload, new).unwrap(), old);
1532 assert_eq!(files[1].effective_path(), Some("text"));
1533 assert!(files[1].is_toplevel_relative);
1534 assert!(!files[1].is_binary);
1535 assert_eq!(files[1].hunks.len(), 1);
1536 assert_eq!(files[1].hunks[0].first_body_line, input.lines().count() - 1);
1537 }
1538 }
1539
1540 #[test]
1541 fn git_headers_keep_index_after_file_headers() {
1542 let input = "diff --git a/f b/f\nold mode 100644\nnew mode 100755\n\
1543 --- a/f\n+++ b/f\nindex abc..def\nindex def..123\n";
1544 let files = parse_patch(input, 1, "modes", false, None).expect("parse headers");
1545 assert_eq!(files.len(), 1);
1546 assert_eq!(files[0].old_oid.as_deref(), Some("def"));
1547 assert_eq!(files[0].new_oid.as_deref(), Some("123"));
1548 assert_eq!(files[0].old_mode.as_deref(), Some("100644"));
1549 assert_eq!(files[0].new_mode.as_deref(), Some("100755"));
1550 assert_eq!(files[0].old_mode_line, Some(2));
1551 assert_eq!(files[0].new_mode_line, Some(3));
1552 assert!(files[0].saw_old_header && files[0].saw_new_header);
1553 assert!(files[0].hunks.is_empty());
1554 }
1555
1556 #[test]
1557 fn corrupt_hunks_keep_line_numbers_for_both_header_styles() {
1558 for header in [
1559 "--- a/f\n+++ b/f\n",
1560 "diff --git a/f b/f\n--- a/f\n+++ b/f\n",
1561 ] {
1562 let input = format!("{header}@@ -1,3 +1,3 @@\n one\n");
1563 let err = parse_patch(&input, 1, "mixed", false, None).unwrap_err();
1564 assert!(matches!(
1565 err,
1566 Error::Apply(ApplyError::CorruptPatch { input, line })
1567 if input == "mixed" && line == header.lines().count() + 3
1568 ));
1569 }
1570 }
1571
1572 #[test]
1573 fn parses_simple_git_diff_into_one_file_patch() {
1574 let input = "diff --git a/foo.txt b/foo.txt\n\
1575 index e69de29..d95f3ad 100644\n\
1576 --- a/foo.txt\n\
1577 +++ b/foo.txt\n\
1578 @@ -0,0 +1 @@\n\
1579 +hello\n";
1580 let patches = parse_patch(input, 1, "<test>", false, None).expect("parse");
1581 assert_eq!(patches.len(), 1);
1582 let fp = &patches[0];
1583 assert_eq!(fp.old_path.as_deref(), Some("foo.txt"));
1584 assert_eq!(fp.new_path.as_deref(), Some("foo.txt"));
1585 assert_eq!(fp.hunks.len(), 1);
1586 let hunk = &fp.hunks[0];
1587 assert_eq!(hunk.new_count, 1);
1588 assert!(matches!(hunk.lines.as_slice(), [HunkLine::Add(s)] if s == "hello"));
1589 }
1590
1591 #[test]
1592 fn parses_new_file_mode_and_deletion() {
1593 let new_file = "diff --git a/n b/n\n\
1594 new file mode 100644\n\
1595 index 0000000..9daeafb\n\
1596 --- /dev/null\n\
1597 +++ b/n\n\
1598 @@ -0,0 +1 @@\n\
1599 +x\n";
1600 let patches = parse_patch(new_file, 1, "<test>", false, None).expect("parse");
1601 assert_eq!(patches.len(), 1);
1602 assert!(patches[0].is_new);
1603 assert_eq!(patches[0].new_mode.as_deref(), Some("100644"));
1604
1605 let deleted = "diff --git a/d b/d\n\
1606 deleted file mode 100644\n\
1607 index 9daeafb..0000000\n\
1608 --- a/d\n\
1609 +++ /dev/null\n\
1610 @@ -1 +0,0 @@\n\
1611 -x\n";
1612 let patches = parse_patch(deleted, 1, "<test>", false, None).expect("parse");
1613 assert!(patches[0].is_deleted);
1614 }
1615
1616 #[test]
1617 fn corrupt_hunk_is_reported_with_input_name_and_line() {
1618 let input = "--- a/x\n\
1620 +++ b/x\n\
1621 @@ -1,3 +1,3 @@\n\
1622 one\n";
1623 let err = parse_patch(input, 1, "patch", false, None).unwrap_err();
1624 assert!(matches!(
1625 err,
1626 Error::Apply(ApplyError::CorruptPatch {
1627 ref input,
1628 line: 4
1629 }) if input == "patch"
1630 ));
1631 assert_eq!(
1632 err.git_stderr_message(),
1633 crate::diagnostics::error_line("corrupt patch at patch:4")
1634 );
1635 }
1636
1637 #[test]
1638 fn parse_hunk_header_parses_ranges() {
1639 assert_eq!(parse_hunk_header("@@ -1,3 +2,4 @@").unwrap(), (1, 3, 2, 4));
1640 assert_eq!(parse_hunk_header("@@ -5 +6 @@ ctx").unwrap(), (5, 1, 6, 1));
1641 }
1642
1643 #[test]
1644 fn invalid_hunk_header_chains_inner_message() {
1645 let err = parse_hunk_header("@@ -x +1 @@").unwrap_err();
1646 assert_eq!(err.to_string(), "invalid digit found in string");
1648 }
1649
1650 #[cfg_attr(
1651 not(feature = "full-tests"),
1652 ignore = "starts system git or is heavy; run with --features full-tests"
1653 )]
1654 #[test]
1655 fn write_git_binary_patch_applies_with_system_git() {
1656 use std::fs;
1657 use std::process::Command;
1658
1659 let old = b"\0BIN\0";
1660 let new = b"\0BIN2\0";
1661 let body = write_git_binary_patch(old, new, "a/b", "b/b").expect("write");
1662 let patch = format!("diff --git a/b b/b\n{body}");
1663 let dir = tempfile::tempdir().expect("tempdir");
1664 let b = dir.path().join("b");
1665 fs::write(&b, old).expect("write base");
1666 let patch_path = dir.path().join("p.diff");
1667 fs::write(&patch_path, &patch).expect("write patch");
1668 let check = Command::new("git")
1669 .current_dir(dir.path())
1670 .args(["apply", "--check", patch_path.to_str().expect("utf8")])
1671 .output()
1672 .expect("git apply --check");
1673 assert!(
1674 check.status.success(),
1675 "git apply --check rejected grit binary patch: {}",
1676 String::from_utf8_lossy(&check.stderr)
1677 );
1678 let apply = Command::new("git")
1679 .current_dir(dir.path())
1680 .args(["apply", patch_path.to_str().expect("utf8")])
1681 .output()
1682 .expect("git apply");
1683 assert!(apply.status.success(), "git apply failed");
1684 assert_eq!(fs::read(&b).expect("read"), new);
1685 }
1686
1687 fn shattered_prefix_fixture() -> Vec<u8> {
1688 std::fs::read(
1689 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1690 .join("tests/data/shattered-1-prefix.bin"),
1691 )
1692 .expect("fixture")
1693 }
1694
1695 #[test]
1696 fn apply_accepts_shattered_result_blob_like_git() {
1697 let shattered = shattered_prefix_fixture();
1698 let body = write_git_binary_patch(b"ok", &shattered, "a/f", "b/f").expect("binary patch");
1699 let patch = format!("diff --git a/f b/f\n{body}");
1700 let patches = parse_patch(&patch, 1, "p", false, None).expect("parse");
1701 let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1702 let materialized =
1703 materialize_binary_patch_forward(payload, b"ok").expect("materialize shattered blob");
1704 assert_eq!(materialized, shattered);
1705 }
1706
1707 #[test]
1708 fn write_git_binary_patch_roundtrips_through_parse_and_inflate() {
1709 let old = b"\0BIN\0";
1710 let new = b"\0BIN2\0";
1711 let body = write_git_binary_patch(old, new, "a/b", "b/b").expect("write");
1712 let patch = format!("diff --git a/b b/b\n{body}");
1713 assert!(patch.contains("GIT binary patch"));
1714 let patches = parse_patch(&patch, 1, "p", false, None).expect("parse");
1715 assert_eq!(patches.len(), 1);
1716 let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1717 let forward = materialize_binary_patch_forward(payload, old).expect("materialize new");
1718 let reverse = materialize_binary_patch_reverse(payload, new).expect("materialize old");
1719 assert_eq!(forward, new);
1720 assert_eq!(reverse, old);
1721 }
1722
1723 #[cfg_attr(
1724 not(feature = "full-tests"),
1725 ignore = "starts system git or is heavy; run with --features full-tests"
1726 )]
1727 #[test]
1728 fn git_produced_delta_binary_patch_parses_and_materializes() {
1729 use std::process::Command;
1730
1731 let mut old = Vec::from(&b"\0BIN"[..]);
1732 old.extend((0u8..=255).cycle().take(996));
1733 assert_eq!(old.len(), 1000);
1734 let mut new = old.clone();
1735 new[500..510].copy_from_slice(b"CHANGED!!!");
1736
1737 let dir = tempfile::tempdir().expect("tempdir");
1738 let old_path = dir.path().join("old.bin");
1739 let new_path = dir.path().join("new.bin");
1740 std::fs::write(&old_path, &old).expect("write old");
1741 std::fs::write(&new_path, &new).expect("write new");
1742 let out = Command::new("git")
1743 .args([
1744 "diff",
1745 "--no-index",
1746 "--binary",
1747 old_path.to_str().expect("utf8"),
1748 new_path.to_str().expect("utf8"),
1749 ])
1750 .output()
1751 .expect("git diff");
1752 assert!(
1753 out.status.code() == Some(1) || out.status.success(),
1754 "git diff failed: {}",
1755 String::from_utf8_lossy(&out.stderr)
1756 );
1757 let patch = String::from_utf8_lossy(&out.stdout);
1758 assert!(
1759 patch.contains("delta "),
1760 "expected git to emit delta sections: {patch}"
1761 );
1762 let patches = parse_patch(&patch, 1, "p", false, None).expect("parse git binary patch");
1763 let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1764 assert_eq!(payload.forward.kind, BinaryPatchSectionKind::Delta);
1765 let got = materialize_binary_patch_forward(payload, &old).expect("apply forward delta");
1766 assert_eq!(got, new);
1767 }
1768}