This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-git / src / patch_apply.rs
25 kB 733 lines
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}