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