This repository has no description
1use std::collections::BTreeMap;
2
3use knot_types::{Oid, RepoPath};
4
5use crate::error::{GitError, backend};
6use crate::objects::{EntryKind, Identity, signature};
7use crate::patch::{Hunk, LineOp, MAX_DIFF_BLOB_BYTES};
8use crate::patch_parse::{FileIntent, ParsedFile, PatchPayload};
9use crate::repo::Repo;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConflictReason {
13 AlreadyExists,
14 DoesNotExist,
15 DoesNotApply,
16}
17
18impl ConflictReason {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 ConflictReason::AlreadyExists => "file already exists",
22 ConflictReason::DoesNotExist => "file doesn't exist",
23 ConflictReason::DoesNotApply => "patch doesn't apply",
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Conflict {
30 pub path: String,
31 pub reason: ConflictReason,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum StagedAction {
36 Put { content: Vec<u8>, kind: EntryKind },
37 PutGitlink { oid: Oid },
38 Remove,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct StagedChange {
43 pub path: RepoPath,
44 pub action: StagedAction,
45}
46
47#[derive(Debug, PartialEq, Eq)]
48pub enum ApplyOutcome {
49 Clean(Vec<StagedChange>),
50 Conflicted(Vec<Conflict>),
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum ApplyError {
55 #[error(transparent)]
56 Git(#[from] GitError),
57 #[error("file touched by patch exceeds {MAX_DIFF_BLOB_BYTES}-byte limit")]
58 TooLarge,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct NewCommit {
63 pub tree: Oid,
64 pub parents: Vec<Oid>,
65 pub author: Identity,
66 pub committer: Identity,
67 pub message: String,
68 pub extra_headers: Vec<(String, Vec<u8>)>,
69}
70
71fn patch_path(raw: &str) -> Option<RepoPath> {
72 RepoPath::new(raw).ok().filter(|path| !path.names_dot_git())
73}
74
75fn split_lines(content: &[u8]) -> Vec<&[u8]> {
76 content.split_inclusive(|&byte| byte == b'\n').collect()
77}
78
79fn images(hunk: &Hunk) -> (Vec<&[u8]>, Vec<&[u8]>) {
80 let pick = |keep: fn(LineOp) -> bool| {
81 hunk.lines
82 .iter()
83 .filter(move |line| keep(line.op))
84 .map(|line| line.text.as_slice())
85 .collect()
86 };
87 (
88 pick(|op| matches!(op, LineOp::Context | LineOp::Delete)),
89 pick(|op| matches!(op, LineOp::Context | LineOp::Add)),
90 )
91}
92
93fn find_match(lines: &[&[u8]], pre: &[&[u8]], cursor: usize, expected: usize) -> Option<usize> {
94 let last = lines.len().checked_sub(pre.len())?;
95 if last < cursor {
96 return None;
97 }
98 let anchor = expected.clamp(cursor, last);
99 let matches_at = |at: usize| {
100 lines[at..at + pre.len()]
101 .iter()
102 .zip(pre)
103 .all(|(a, b)| a == b)
104 };
105 (0..=last - cursor)
106 .flat_map(|distance| [anchor.checked_add(distance), anchor.checked_sub(distance)])
107 .flatten()
108 .filter(|&at| at >= cursor && at <= last)
109 .find(|&at| matches_at(at))
110}
111
112pub(crate) fn apply_hunks(old: &[u8], hunks: &[Hunk]) -> Option<Vec<u8>> {
113 let lines = split_lines(old);
114 let (out, cursor) =
115 hunks
116 .iter()
117 .try_fold((Vec::<u8>::new(), 0usize), |(mut out, cursor), hunk| {
118 let (pre, post) = images(hunk);
119 let has_context = hunk.lines.iter().any(|line| line.op == LineOp::Context);
120 let expected = match hunk.old_lines.get() {
121 0 => hunk.old_start.get() as usize,
122 _ => (hunk.old_start.get() as usize).saturating_sub(1),
123 };
124 let at = match pre.is_empty() {
125 true => expected.clamp(cursor, lines.len()),
126 false => find_match(&lines, &pre, cursor, expected)?,
127 };
128 if !has_context && !pre.is_empty() && at + pre.len() != lines.len() {
129 return None;
130 }
131 lines
132 .get(cursor..at)?
133 .iter()
134 .for_each(|line| out.extend_from_slice(line));
135 post.iter().for_each(|line| out.extend_from_slice(line));
136 Some((out, at + pre.len()))
137 })?;
138 Some(lines.get(cursor..)?.iter().fold(out, |mut out, line| {
139 out.extend_from_slice(line);
140 out
141 }))
142}
143
144pub(crate) fn apply_delta(base: &[u8], delta: &[u8]) -> Option<Vec<u8>> {
145 let mut pos = 0usize;
146 let declared_base = read_size(delta, &mut pos)?;
147 let declared_target = read_size(delta, &mut pos)?;
148 if declared_base != base.len() as u64 || declared_target > MAX_DIFF_BLOB_BYTES {
149 return None;
150 }
151 let mut out: Vec<u8> = Vec::with_capacity(declared_target as usize);
152 std::iter::from_fn(|| {
153 let opcode = *delta.get(pos)?;
154 pos += 1;
155 Some(match opcode {
156 0 => None,
157 literal if literal & 0x80 == 0 => {
158 let take = literal as usize;
159 delta.get(pos..pos + take).map(|bytes| {
160 pos += take;
161 out.extend_from_slice(bytes);
162 })
163 }
164 copy => {
165 let mut field = |bit: u8| -> u64 {
166 match copy & bit {
167 0 => 0,
168 _ => {
169 let byte = delta.get(pos).copied().unwrap_or(0);
170 pos += 1;
171 u64::from(byte)
172 }
173 }
174 };
175 let offset = field(0x01) | field(0x02) << 8 | field(0x04) << 16 | field(0x08) << 24;
176 let size = match field(0x10) | field(0x20) << 8 | field(0x40) << 16 {
177 0 => 0x10000,
178 size => size,
179 };
180 base.get(offset as usize..(offset + size) as usize)
181 .map(|bytes| out.extend_from_slice(bytes))
182 }
183 })
184 })
185 .try_for_each(|step| step.map(|_| ()))?;
186 (pos == delta.len() && out.len() as u64 == declared_target).then_some(out)
187}
188
189fn read_size(delta: &[u8], pos: &mut usize) -> Option<u64> {
190 let mut shift = 0u32;
191 let mut acc = 0u64;
192 std::iter::from_fn(|| {
193 let byte = *delta.get(*pos)?;
194 *pos += 1;
195 acc |= u64::from(byte & 0x7f) << shift;
196 shift += 7;
197 Some(byte & 0x80 != 0)
198 })
199 .take(10)
200 .find(|more| !more)
201 .map(|_| acc)
202}
203
204enum OverlayEntry {
205 Put { content: Vec<u8>, kind: EntryKind },
206 Gitlink { oid: Oid },
207 Removed,
208}
209
210fn subproject_content(oid: Oid) -> Vec<u8> {
211 format!("Subproject commit {}\n", oid.to_hex()).into_bytes()
212}
213
214fn parse_subproject(content: &[u8]) -> Option<Oid> {
215 let text = std::str::from_utf8(content).ok()?;
216 Oid::from_hex(text.strip_prefix("Subproject commit ")?.trim()).ok()
217}
218
219struct FoundFile {
220 content: Vec<u8>,
221 kind: EntryKind,
222 oid: Option<Oid>,
223}
224
225struct StepView<'r, 'a> {
226 repo: &'r Repo,
227 base: Oid,
228 accumulated: &'a BTreeMap<RepoPath, OverlayEntry>,
229 step: BTreeMap<RepoPath, OverlayEntry>,
230}
231
232impl StepView<'_, '_> {
233 fn overlaid(&self, path: &RepoPath) -> Option<&OverlayEntry> {
234 self.step.get(path).or_else(|| self.accumulated.get(path))
235 }
236
237 fn current(&self, path: &RepoPath) -> Result<Option<FoundFile>, ApplyError> {
238 match self.overlaid(path) {
239 Some(OverlayEntry::Removed) => Ok(None),
240 Some(OverlayEntry::Put { content, kind }) => Ok(Some(FoundFile {
241 content: content.clone(),
242 kind: *kind,
243 oid: Some(overlay_oid(self.repo, content)?),
244 })),
245 Some(OverlayEntry::Gitlink { oid }) => Ok(Some(FoundFile {
246 content: subproject_content(*oid),
247 kind: EntryKind::Commit,
248 oid: Some(*oid),
249 })),
250 None => match self.repo.entry_at(self.base, path)? {
251 Some(entry)
252 if matches!(
253 entry.kind,
254 EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link
255 ) =>
256 {
257 if self.repo.blob_size(entry.oid)? > MAX_DIFF_BLOB_BYTES {
258 return Err(ApplyError::TooLarge);
259 }
260 Ok(Some(FoundFile {
261 content: self.repo.read_blob(entry.oid)?,
262 kind: entry.kind,
263 oid: Some(entry.oid),
264 }))
265 }
266 Some(entry) if entry.kind == EntryKind::Commit => Ok(Some(FoundFile {
267 content: subproject_content(entry.oid),
268 kind: EntryKind::Commit,
269 oid: Some(entry.oid),
270 })),
271 _ => Ok(None),
272 },
273 }
274 }
275
276 fn occupied(&self, path: &RepoPath) -> Result<bool, ApplyError> {
277 match self.overlaid(path) {
278 Some(OverlayEntry::Removed) => Ok(false),
279 Some(OverlayEntry::Put { .. } | OverlayEntry::Gitlink { .. }) => Ok(true),
280 None => Ok(self.repo.entry_at(self.base, path)?.is_some()),
281 }
282 }
283
284 fn prefix_is_file(&self, prefix: &RepoPath) -> Result<bool, ApplyError> {
285 match self.overlaid(prefix) {
286 Some(OverlayEntry::Removed) => Ok(false),
287 Some(OverlayEntry::Put { kind, .. }) => Ok(!matches!(kind, EntryKind::Tree)),
288 Some(OverlayEntry::Gitlink { .. }) => Ok(true),
289 None => Ok(matches!(
290 self.repo.entry_at(self.base, prefix)?,
291 Some(entry) if !matches!(entry.kind, EntryKind::Tree)
292 )),
293 }
294 }
295
296 fn ancestor_is_file(&self, path: &RepoPath) -> Result<bool, ApplyError> {
297 let parts: Vec<&str> = path.as_str().split('/').collect();
298 (1..parts.len())
299 .map(|end| parts[..end].join("/"))
300 .try_fold(false, |blocked, prefix| {
301 let prefix =
302 RepoPath::new(prefix).expect("prefix of a valid repo path is well-formed");
303 Ok(blocked || self.prefix_is_file(&prefix)?)
304 })
305 }
306
307 fn put(&mut self, path: &RepoPath, content: Vec<u8>, kind: EntryKind) {
308 self.step
309 .insert(path.clone(), OverlayEntry::Put { content, kind });
310 }
311
312 fn put_gitlink(&mut self, path: &RepoPath, oid: Oid) {
313 self.step
314 .insert(path.clone(), OverlayEntry::Gitlink { oid });
315 }
316
317 fn remove(&mut self, path: &RepoPath) {
318 self.step.insert(path.clone(), OverlayEntry::Removed);
319 }
320}
321
322fn index_matches(actual: Option<Oid>, declared: Option<Oid>) -> bool {
323 matches!((actual, declared), (Some(actual), Some(declared)) if actual == declared)
324}
325
326fn overlay_oid(repo: &Repo, content: &[u8]) -> Result<Oid, ApplyError> {
327 gix::objs::compute_hash(repo.git().object_hash(), gix::objs::Kind::Blob, content)
328 .map(Oid::from)
329 .map_err(|error| ApplyError::Git(backend(error)))
330}
331
332fn transform(
333 payload: &PatchPayload,
334 old: &[u8],
335 old_oid: Option<Oid>,
336 declared_old: Option<Oid>,
337) -> Option<Vec<u8>> {
338 match payload {
339 PatchPayload::Text(hunks) => apply_hunks(old, hunks),
340 PatchPayload::BinaryLiteral(data) => {
341 index_matches(old_oid, declared_old).then(|| data.clone())
342 }
343 PatchPayload::BinaryDelta(delta) => index_matches(old_oid, declared_old)
344 .then(|| apply_delta(old, delta))
345 .flatten(),
346 PatchPayload::BinaryOpaque => None,
347 }
348}
349
350fn fresh_content(payload: &PatchPayload) -> Option<Vec<u8>> {
351 match payload {
352 PatchPayload::Text(hunks) => apply_hunks(&[], hunks),
353 PatchPayload::BinaryLiteral(data) => Some(data.clone()),
354 PatchPayload::BinaryDelta(_) | PatchPayload::BinaryOpaque => None,
355 }
356}
357
358fn file_kind(kind: Option<EntryKind>, fallback: EntryKind) -> Option<EntryKind> {
359 match kind.unwrap_or(fallback) {
360 EntryKind::Tree => None,
361 usable => Some(usable),
362 }
363}
364
365fn stage_content(
366 overlay: &mut StepView<'_, '_>,
367 path: &RepoPath,
368 kind: EntryKind,
369 content: Vec<u8>,
370) -> Option<Conflict> {
371 match kind {
372 EntryKind::Commit => match parse_subproject(&content) {
373 Some(oid) => {
374 overlay.put_gitlink(path, oid);
375 None
376 }
377 None => Some(Conflict {
378 path: path.to_string(),
379 reason: ConflictReason::DoesNotApply,
380 }),
381 },
382 _ => {
383 overlay.put(path, content, kind);
384 None
385 }
386 }
387}
388
389fn apply_file(
390 overlay: &mut StepView<'_, '_>,
391 file: &ParsedFile,
392) -> Result<Option<Conflict>, ApplyError> {
393 let conflict = |path: &str, reason: ConflictReason| {
394 Ok(Some(Conflict {
395 path: path.to_string(),
396 reason,
397 }))
398 };
399 let Some(path) = patch_path(&file.path) else {
400 return conflict(&file.path, ConflictReason::DoesNotApply);
401 };
402 match &file.intent {
403 FileIntent::Create => {
404 let Some(kind) = file_kind(file.new_kind, EntryKind::Blob) else {
405 return conflict(&file.path, ConflictReason::DoesNotApply);
406 };
407 if overlay.occupied(&path)? {
408 return conflict(&file.path, ConflictReason::AlreadyExists);
409 }
410 if overlay.ancestor_is_file(&path)? {
411 return conflict(&file.path, ConflictReason::DoesNotApply);
412 }
413 match fresh_content(&file.payload) {
414 Some(content) => Ok(stage_content(overlay, &path, kind, content)),
415 None => conflict(&file.path, ConflictReason::DoesNotApply),
416 }
417 }
418 FileIntent::Delete => match overlay.current(&path)? {
419 None => conflict(&file.path, ConflictReason::DoesNotExist),
420 Some(found) => {
421 let emptied = match &file.payload {
422 PatchPayload::BinaryOpaque => {
423 index_matches(found.oid, file.old_index).then(Vec::new)
424 }
425 payload => transform(payload, &found.content, found.oid, file.old_index),
426 };
427 match emptied {
428 Some(rest) if rest.is_empty() => {
429 overlay.remove(&path);
430 Ok(None)
431 }
432 _ => conflict(&file.path, ConflictReason::DoesNotApply),
433 }
434 }
435 },
436 FileIntent::Modify => match overlay.current(&path)? {
437 None => conflict(&file.path, ConflictReason::DoesNotExist),
438 Some(found) => {
439 let Some(kind) = file_kind(file.new_kind, found.kind) else {
440 return conflict(&file.path, ConflictReason::DoesNotApply);
441 };
442 match evolved(&file.payload, &found, file.old_index) {
443 Some(next) => Ok(stage_content(overlay, &path, kind, next)),
444 None => conflict(&file.path, ConflictReason::DoesNotApply),
445 }
446 }
447 },
448 FileIntent::Rename { from } | FileIntent::Copy { from } => {
449 let Some(source) = patch_path(from) else {
450 return conflict(from, ConflictReason::DoesNotApply);
451 };
452 if overlay.occupied(&path)? {
453 return conflict(&file.path, ConflictReason::AlreadyExists);
454 }
455 if overlay.ancestor_is_file(&path)? {
456 return conflict(&file.path, ConflictReason::DoesNotApply);
457 }
458 match overlay.current(&source)? {
459 None => conflict(from, ConflictReason::DoesNotExist),
460 Some(found) => {
461 let Some(kind) = file_kind(file.new_kind, found.kind) else {
462 return conflict(&file.path, ConflictReason::DoesNotApply);
463 };
464 match evolved(&file.payload, &found, file.old_index) {
465 Some(next) => {
466 if matches!(&file.intent, FileIntent::Rename { .. }) {
467 overlay.remove(&source);
468 }
469 Ok(stage_content(overlay, &path, kind, next))
470 }
471 None => conflict(&file.path, ConflictReason::DoesNotApply),
472 }
473 }
474 }
475 }
476 }
477}
478
479fn evolved(
480 payload: &PatchPayload,
481 found: &FoundFile,
482 declared_old: Option<Oid>,
483) -> Option<Vec<u8>> {
484 match payload {
485 PatchPayload::Text(hunks) if hunks.is_empty() => Some(found.content.clone()),
486 payload => transform(payload, &found.content, found.oid, declared_old),
487 }
488}
489
490pub struct PatchApplier<'r> {
491 repo: &'r Repo,
492 base: Oid,
493 accumulated: BTreeMap<RepoPath, OverlayEntry>,
494}
495
496impl<'r> PatchApplier<'r> {
497 pub fn new(repo: &'r Repo, base_commit: Oid) -> Self {
498 Self {
499 repo,
500 base: base_commit,
501 accumulated: BTreeMap::new(),
502 }
503 }
504
505 pub fn step(&mut self, files: &[ParsedFile]) -> Result<ApplyOutcome, ApplyError> {
506 let mut view = StepView {
507 repo: self.repo,
508 base: self.base,
509 accumulated: &self.accumulated,
510 step: BTreeMap::new(),
511 };
512 let conflicts: Vec<Conflict> = files
513 .iter()
514 .map(|file| apply_file(&mut view, file))
515 .collect::<Result<Vec<_>, ApplyError>>()?
516 .into_iter()
517 .flatten()
518 .collect();
519 if !conflicts.is_empty() {
520 return Ok(ApplyOutcome::Conflicted(conflicts));
521 }
522 let step = view.step;
523 let staged: Vec<StagedChange> = step
524 .iter()
525 .map(|(path, entry)| StagedChange {
526 path: path.clone(),
527 action: match entry {
528 OverlayEntry::Put { content, kind } => StagedAction::Put {
529 content: content.clone(),
530 kind: *kind,
531 },
532 OverlayEntry::Gitlink { oid } => StagedAction::PutGitlink { oid: *oid },
533 OverlayEntry::Removed => StagedAction::Remove,
534 },
535 })
536 .collect();
537 self.accumulated.extend(step);
538 Ok(ApplyOutcome::Clean(staged))
539 }
540}
541
542impl Repo {
543 pub fn write_staged_tree(
544 &self,
545 base_tree: Oid,
546 staged: &[StagedChange],
547 ) -> Result<Oid, GitError> {
548 let mut editor = self
549 .git()
550 .edit_tree(base_tree.object_id())
551 .map_err(backend)?;
552 staged
553 .iter()
554 .try_for_each(|change| -> Result<(), GitError> {
555 match &change.action {
556 StagedAction::Put { content, kind } => {
557 let blob = self.git().write_blob(content).map_err(backend)?.detach();
558 editor
559 .upsert(change.path.as_str(), (*kind).into(), blob)
560 .map_err(backend)?;
561 }
562 StagedAction::PutGitlink { oid } => {
563 editor
564 .upsert(
565 change.path.as_str(),
566 EntryKind::Commit.into(),
567 oid.object_id(),
568 )
569 .map_err(backend)?;
570 }
571 StagedAction::Remove => {
572 editor.remove(change.path.as_str()).map_err(backend)?;
573 }
574 }
575 Ok(())
576 })?;
577 Ok(Oid::from(editor.write().map_err(backend)?.detach()))
578 }
579
580 pub fn write_commit(&self, new: &NewCommit) -> Result<Oid, GitError> {
581 let message = match new.message.ends_with('\n') {
582 true => new.message.clone(),
583 false => format!("{}\n", new.message),
584 };
585 let commit = gix::objs::Commit {
586 tree: new.tree.object_id(),
587 parents: new
588 .parents
589 .iter()
590 .map(|parent| parent.object_id())
591 .collect(),
592 author: signature(&new.author),
593 committer: signature(&new.committer),
594 encoding: None,
595 message: message.into(),
596 extra_headers: new
597 .extra_headers
598 .iter()
599 .map(|(name, value)| {
600 (
601 name.as_str().into(),
602 gix::bstr::BString::from(value.clone()),
603 )
604 })
605 .collect(),
606 };
607 Ok(Oid::from(
608 self.git().write_object(commit).map_err(backend)?.detach(),
609 ))
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::patch::{HunkLine, LineCount, LineNumber};
617
618 type ApplyCase<'a> = (&'a [u8], Vec<Hunk>, Option<&'a [u8]>);
619
620 fn hunk(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32, spec: &str) -> Hunk {
621 let lines = spec
622 .split('\n')
623 .filter(|line| !line.is_empty())
624 .map(|line| {
625 let (op, text) = match line.as_bytes()[0] {
626 b'-' => (LineOp::Delete, &line[1..]),
627 b'+' => (LineOp::Add, &line[1..]),
628 _ => (LineOp::Context, &line[1..]),
629 };
630 HunkLine {
631 op,
632 text: format!("{text}\n").into_bytes(),
633 }
634 })
635 .collect();
636 Hunk {
637 old_start: LineNumber::new(old_start),
638 old_lines: LineCount::new(old_lines),
639 new_start: LineNumber::new(new_start),
640 new_lines: LineCount::new(new_lines),
641 lines,
642 }
643 }
644
645 #[test]
646 fn apply_hunks_tracks_position_drift_and_boundaries() {
647 let cases: Vec<ApplyCase> = vec![
648 (
649 b"one\ntwo\nthree\n",
650 vec![hunk(1, 3, 1, 3, " one\n-two\n+TWO\n three\n")],
651 Some(b"one\nTWO\nthree\n".as_slice()),
652 ),
653 (
654 b"zero\nzero\none\ntwo\nthree\n",
655 vec![hunk(1, 3, 1, 3, " one\n-two\n+TWO\n three\n")],
656 Some(b"zero\nzero\none\nTWO\nthree\n".as_slice()),
657 ),
658 (
659 b"one\nTWO ALREADY\nthree\n",
660 vec![hunk(1, 3, 1, 3, " one\n-two\n+TWO\n three\n")],
661 None,
662 ),
663 (
664 b"",
665 vec![hunk(0, 0, 1, 2, "+alpha\n+beta\n")],
666 Some(b"alpha\nbeta\n".as_slice()),
667 ),
668 (
669 b"only\n",
670 vec![hunk(1, 1, 0, 0, "-only\n")],
671 Some(b"".as_slice()),
672 ),
673 (
674 b"a\nb\nc\nd\ne\nf\ng\n",
675 vec![
676 hunk(1, 2, 1, 3, " a\n+inserted\n b\n"),
677 hunk(6, 2, 7, 2, " f\n-g\n+G\n"),
678 ],
679 Some(b"a\ninserted\nb\nc\nd\ne\nf\nG\n".as_slice()),
680 ),
681 (
682 b"one\ntwo\nthree\nfour\n",
683 vec![hunk(2, 1, 2, 1, "-two\n+TWO\n")],
684 None,
685 ),
686 (
687 b"one\ntwo\nthree\nfour\n",
688 vec![hunk(2, 1, 1, 0, "-two\n")],
689 None,
690 ),
691 (
692 b"one\ntwo\nthree\nfour\n",
693 vec![hunk(4, 1, 4, 1, "-four\n+FOUR\n")],
694 Some(b"one\ntwo\nthree\nFOUR\n".as_slice()),
695 ),
696 ];
697 cases.iter().for_each(|(old, hunks, expected)| {
698 assert_eq!(
699 apply_hunks(old, hunks).as_deref(),
700 *expected,
701 "apply_hunks mismatch for {old:?}"
702 );
703 });
704 }
705
706 #[test]
707 fn delta_application_round_trips_copy_and_insert() {
708 let base = b"hello world";
709 let delta: Vec<u8> = vec![11, 9, 0x90, 5, 4, b'-', b'g', b'i', b'x'];
710 assert_eq!(apply_delta(base, &delta), Some(b"hello-gix".to_vec()));
711 assert_eq!(apply_delta(b"wrong size base", &delta), None);
712 assert_eq!(apply_delta(base, &delta[..5]), None);
713 }
714
715 #[test]
716 fn unsafe_paths_are_rejected() {
717 assert!(patch_path("../escape").is_none());
718 assert!(patch_path("/absolute").is_none());
719 assert!(patch_path("nested/../escape").is_none());
720 assert!(patch_path(".git/hooks/pre-receive").is_none());
721 assert!(patch_path("dir/.GIT/config").is_none());
722 assert!(patch_path("").is_none());
723 assert!(patch_path("src/lib.rs").is_some());
724 assert!(patch_path("a b/c.txt").is_some());
725 }
726
727 #[test]
728 fn subproject_text_round_trips_through_a_commit_oid() {
729 let oid = Oid::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap();
730 assert_eq!(parse_subproject(&subproject_content(oid)), Some(oid));
731 assert_eq!(parse_subproject(b"not a subproject line\n"), None);
732 }
733}