//! Unified-diff parsing. //! //! The patch tier resolves a file through the rung ladder of `docs/installer/PLAN.md` §2.2.1, and //! every rung is answered from the diff itself: a unified diff already carries the stock text of //! each region it edits — the context lines plus the `-` lines **are** the pre-image, and the //! context lines plus the `+` lines are the post-image. Nothing else has to be shipped alongside //! the patch for the installer to know what it is looking for. //! //! Three properties of this parser are load-bearing rather than tidiness: //! //! - **Everything is bytes, never `String`.** The three ServUO files the tier edits are CRLF and //! are not guaranteed to be UTF-8; a lossy decode would corrupt bytes on write-back, and a strict //! one would refuse to patch a shard over a stray `0x92` in a comment. Line content is compared //! after normalization but written back verbatim. //! - **The `index` line is optional and its absence is not a defect.** `commandlogging-event.patch` //! is a plain `---`/`+++` diff with no `diff --git` header at all, so rung 1 (whole-file //! pre-image hash) is structurally unavailable for it. That is fine: rung 2 matches on content //! and is the stronger guarantee anyway. A parser that required the header would have rejected a //! patch we ship. //! - **Hunk line counts are checked against the lines actually present.** A truncated or //! hand-edited diff whose `@@` header promises more lines than it carries would otherwise //! reconstruct a short pre-image, which is exactly the sort of thing that then matches somewhere //! it should not. use anyhow::{bail, Context, Result}; /// One line inside a hunk body. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HunkLine { /// ` ` — present on both sides. Context(Vec), /// `-` — present in the stock file only. Removed(Vec), /// `+` — present in the patched file only. Added(Vec), } impl HunkLine { fn content(&self) -> &[u8] { match self { Self::Context(b) | Self::Removed(b) | Self::Added(b) => b, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hunk { /// 1-based line number in the stock file, from the `@@` header. **Advisory only** — the match /// is made by content, since an insertion anywhere above shifts every number below it. It is /// used to prefer the nearest candidate when reporting, and nowhere else. pub old_start: usize, pub old_count: usize, pub new_start: usize, pub new_count: usize, pub lines: Vec, /// The stock file's last line has no trailing newline (`\ No newline at end of file` after a /// `-` or context line). pub old_no_newline: bool, /// Likewise for the patched file. pub new_no_newline: bool, } impl Hunk { /// The stock text this hunk expects to find: context + removed, in order. pub fn pre_image(&self) -> Vec<&[u8]> { self.lines .iter() .filter(|l| !matches!(l, HunkLine::Added(_))) .map(HunkLine::content) .collect() } /// The text this hunk leaves behind: context + added, in order. pub fn post_image(&self) -> Vec<&[u8]> { self.lines .iter() .filter(|l| !matches!(l, HunkLine::Removed(_))) .map(HunkLine::content) .collect() } /// Whether this hunk changes anything. A hunk of pure context is a no-op and must not be /// counted as applied work — nor searched for, since its pre- and post-images are identical /// and rung 0 could never be distinguished from rung 2. pub fn is_noop(&self) -> bool { !self .lines .iter() .any(|l| matches!(l, HunkLine::Added(_) | HunkLine::Removed(_))) } } /// One file section of a patch. Our patches carry one each, but a diff may hold several and /// silently applying the first would be a quiet way to half-patch a tree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FilePatch { /// From `+++ b/`, with the `b/` prefix stripped and separators left as `/`. pub path: String, /// The abbreviated blob hash of the stock file, from `index ..`. `None` when the /// diff has no `index` line, which makes rung 1 unavailable for this file — see the module /// docs. pub pre_blob: Option, pub post_blob: Option, pub hunks: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Patch { pub files: Vec, } impl Patch { /// The single file this patch edits. /// /// The tier declares one target per patch (`tier.json`), so a diff that turned out to edit two /// files would mean the declaration and the artifact disagree — and the installer would have /// checked only one of them against the rung ladder before writing both. pub fn single_file(&self) -> Result<&FilePatch> { match self.files.as_slice() { [only] => Ok(only), [] => bail!("this patch contains no file sections"), many => bail!( "this patch edits {} files ({}); the tier declares one target per patch", many.len(), many.iter() .map(|f| f.path.as_str()) .collect::>() .join(", ") ), } } } /// Splits a buffer into lines **without** their terminators, tolerating CRLF, LF and a final line /// with no terminator at all. /// /// A trailing newline does not produce a final empty line: `b"a\n"` is one line, matching how every /// diff tool counts them. pub fn split_lines(data: &[u8]) -> Vec<&[u8]> { let mut out = Vec::new(); let mut start = 0usize; for (i, b) in data.iter().enumerate() { if *b == b'\n' { let mut end = i; if end > start && data[end - 1] == b'\r' { end -= 1; } out.push(&data[start..end]); start = i + 1; } } if start < data.len() { let mut end = data.len(); if end > start && data[end - 1] == b'\r' { end -= 1; } out.push(&data[start..end]); } out } /// The comparison form of a line: trailing whitespace removed. /// /// This is the *whole* of the licence PLAN.md §2.2.1 grants — line-ending and trailing-whitespace /// normalization, nothing else. There is no fuzz and no context reduction: dropping context to /// force a match is precisely how a hunk lands in the wrong method. Line endings are already gone /// by the time this runs, since [`split_lines`] strips them. pub fn normalize(line: &[u8]) -> &[u8] { let mut end = line.len(); while end > 0 && (line[end - 1] == b' ' || line[end - 1] == b'\t' || line[end - 1] == b'\r') { end -= 1; } &line[..end] } /// Parses a `.patch` file. pub fn parse(data: &[u8]) -> Result { let lines = split_lines(data); let mut files: Vec = Vec::new(); let mut pending_blobs: Option<(String, String)> = None; let mut i = 0usize; while i < lines.len() { let line = lines[i]; if line.starts_with(b"index ") { // `index ..[ ]`. Abbreviated to 7+ hex characters by git, so rung 1 // compares by prefix rather than for equality. pending_blobs = parse_index(line); i += 1; continue; } if line.starts_with(b"--- ") && i + 1 < lines.len() && lines[i + 1].starts_with(b"+++ ") { let path = header_path(lines[i + 1], b"+++ ") .or_else(|| header_path(lines[i], b"--- ")) .with_context(|| { format!( "cannot read the target path from the diff header at line {}", i + 2 ) })?; let (pre_blob, post_blob) = match pending_blobs.take() { Some((a, b)) => (Some(a), Some(b)), None => (None, None), }; i += 2; let mut hunks = Vec::new(); while i < lines.len() && lines[i].starts_with(b"@@") { let (hunk, next) = parse_hunk(&lines, i)?; hunks.push(hunk); i = next; } if hunks.is_empty() { bail!("the diff section for {path} contains no hunks"); } files.push(FilePatch { path, pre_blob, post_blob, hunks, }); continue; } // `diff --git`, `new file mode`, `similarity index`, a covering-letter preamble — anything // outside a hunk body is skipped. Only `index` is worth keeping. i += 1; } if files.is_empty() { bail!("no unified-diff sections found — this file is not a patch"); } Ok(Patch { files }) } /// `--- a/Scripts/Commands/Logging.cs` → `Scripts/Commands/Logging.cs`. /// /// The trailing tab-separated timestamp some tools append is dropped, and so is the one-letter /// prefix directory git uses. `/dev/null` yields `None`, which makes a pure-creation diff fall back /// to the other header rather than producing a file called `dev/null`. fn header_path(line: &[u8], marker: &[u8]) -> Option { let rest = line.strip_prefix(marker)?; let rest = match rest.iter().position(|b| *b == b'\t') { Some(tab) => &rest[..tab], None => rest, }; let text = String::from_utf8_lossy(rest).trim().replace('\\', "/"); if text.is_empty() || text == "/dev/null" { return None; } // git writes `a/` and `b/`; `-p1` semantics. A path with no prefix (`patch -p0` style) is left // alone rather than having its first directory eaten. for prefix in ["a/", "b/", "i/", "w/", "c/", "o/"] { if let Some(stripped) = text.strip_prefix(prefix) { return Some(stripped.to_string()); } } Some(text) } fn parse_index(line: &[u8]) -> Option<(String, String)> { let rest = String::from_utf8_lossy(line.strip_prefix(b"index ")?).to_string(); let head = rest.split_whitespace().next()?; let (old, new) = head.split_once("..")?; let hex = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit()); if !hex(old) || !hex(new) { return None; } Some((old.to_ascii_lowercase(), new.to_ascii_lowercase())) } /// Parses one hunk, starting at the `@@` header. Returns the hunk and the index of the line after /// it. fn parse_hunk(lines: &[&[u8]], start: usize) -> Result<(Hunk, usize)> { let header = String::from_utf8_lossy(lines[start]).to_string(); let (old_start, old_count, new_start, new_count) = parse_hunk_header(&header) .with_context(|| format!("cannot parse hunk header at line {}: {header}", start + 1))?; let mut body = Vec::new(); let mut old_no_newline = false; let mut new_no_newline = false; let mut seen_old = 0usize; let mut seen_new = 0usize; let mut i = start + 1; while i < lines.len() { let line = lines[i]; // The marker describes the line *above* it, and which side it applies to depends on that // line's kind: a `-` line means the stock file ended there, a `+` line the patched one, and // a context line both. if line.starts_with(b"\\ ") { match body.last() { Some(HunkLine::Removed(_)) => old_no_newline = true, Some(HunkLine::Added(_)) => new_no_newline = true, Some(HunkLine::Context(_)) => { old_no_newline = true; new_no_newline = true; } None => {} } i += 1; continue; } if seen_old >= old_count && seen_new >= new_count { break; } let (kind, rest) = match line.first() { Some(b' ') => (0u8, &line[1..]), Some(b'-') => (1, &line[1..]), Some(b'+') => (2, &line[1..]), // git emits a genuinely empty line for an empty context line rather than a lone space, // and trailing whitespace is routinely stripped in transit. Treating it as context is // what every patch tool does. None => (0, line), // Anything else ends the hunk — the next `@@`, `diff --git`, or trailing prose. Some(_) => break, }; match kind { 0 => { seen_old += 1; seen_new += 1; body.push(HunkLine::Context(rest.to_vec())); } 1 => { seen_old += 1; body.push(HunkLine::Removed(rest.to_vec())); } _ => { seen_new += 1; body.push(HunkLine::Added(rest.to_vec())); } } i += 1; } // A header that promises more than the body delivers reconstructs a short pre-image, which is // then liable to match a place the author never meant. Refusing is the only safe reading. if seen_old != old_count || seen_new != new_count { bail!( "hunk at line {} declares -{old_start},{old_count} +{new_start},{new_count} but \ carries {seen_old} old and {seen_new} new lines — the patch is truncated or malformed", start + 1 ); } Ok(( Hunk { old_start, old_count, new_start, new_count, lines: body, old_no_newline, new_no_newline, }, i, )) } /// `@@ -75,16 +75,27 @@ optional section heading` → `(75, 16, 75, 27)`. /// /// A count may be omitted, which means 1 (`@@ -75 +75,2 @@`), and a count of 0 is legal for a pure /// insertion or deletion. fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> { let inner = header.strip_prefix("@@")?; let end = inner.find("@@")?; let mut parts = inner[..end].split_whitespace(); let old = parts.next()?.strip_prefix('-')?; let new = parts.next()?.strip_prefix('+')?; let range = |s: &str| -> Option<(usize, usize)> { match s.split_once(',') { Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)), None => Some((s.parse().ok()?, 1)), } }; let (old_start, old_count) = range(old)?; let (new_start, new_count) = range(new)?; Some((old_start, old_count, new_start, new_count)) } #[cfg(test)] mod tests { use super::*; fn parse_str(text: &str) -> Result { parse(text.as_bytes()) } const SIMPLE: &str = "\ diff --git a/Scripts/Commands/Logging.cs b/Scripts/Commands/Logging.cs index 5dd3f54..9ab1c22 100644 --- a/Scripts/Commands/Logging.cs +++ b/Scripts/Commands/Logging.cs @@ -75,4 +75,6 @@ namespace Server.Commands return o; } + public static event Action OnWrite; + public static void WriteLine(Mobile from, string text) "; #[test] fn a_git_format_patch_parses_whole() { let patch = parse_str(SIMPLE).unwrap(); let file = patch.single_file().unwrap(); assert_eq!(file.path, "Scripts/Commands/Logging.cs"); assert_eq!(file.pre_blob.as_deref(), Some("5dd3f54")); assert_eq!(file.post_blob.as_deref(), Some("9ab1c22")); assert_eq!(file.hunks.len(), 1); let hunk = &file.hunks[0]; assert_eq!((hunk.old_start, hunk.old_count), (75, 4)); assert_eq!((hunk.new_start, hunk.new_count), (75, 6)); assert_eq!(hunk.pre_image().len(), 4); assert_eq!(hunk.post_image().len(), 6); assert!(!hunk.is_noop()); } #[test] fn a_plain_diff_without_a_git_header_parses_and_offers_no_blob() { // commandlogging-event.patch is exactly this shape. Rung 1 is unavailable for it, which is // a fact to report — not a parse error, and certainly not a reason to skip the patch. let text = SIMPLE .lines() .filter(|l| !l.starts_with("diff --git") && !l.starts_with("index ")) .collect::>() .join("\n"); let patch = parse_str(&text).unwrap(); let file = patch.single_file().unwrap(); assert_eq!(file.path, "Scripts/Commands/Logging.cs"); assert_eq!(file.pre_blob, None); assert_eq!(file.hunks.len(), 1); } #[test] fn a_crlf_patch_yields_the_same_lines_as_an_lf_one() { // Every .patch this tier ships is CRLF in a Windows checkout and LF in the tarball CI // builds. The two must parse identically or a patch would apply on one platform and not // the other. let crlf = SIMPLE.replace('\n', "\r\n"); assert_eq!(parse_str(&crlf).unwrap(), parse_str(SIMPLE).unwrap()); } #[test] fn pre_and_post_images_are_the_two_sides_of_the_hunk() { let patch = parse_str( "\ --- a/x +++ b/x @@ -1,3 +1,3 @@ keep -old +new tail ", ) .unwrap(); let hunk = &patch.single_file().unwrap().hunks[0]; assert_eq!(hunk.pre_image(), vec![&b"keep"[..], b"old", b"tail"]); assert_eq!(hunk.post_image(), vec![&b"keep"[..], b"new", b"tail"]); } #[test] fn the_no_newline_marker_attaches_to_the_side_it_describes() { // Three cases, because the marker follows the line it describes and the side depends on // that line's kind. Getting this wrong writes a spurious trailing newline into a file that // never had one — a one-byte change that shows up in every future hash comparison. let old_only = parse_str( "--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n", ) .unwrap(); let hunk = &old_only.single_file().unwrap().hunks[0]; assert!(hunk.old_no_newline && !hunk.new_no_newline); let new_only = parse_str( "--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n+new\n\\ No newline at end of file\n", ) .unwrap(); let hunk = &new_only.single_file().unwrap().hunks[0]; assert!(!hunk.old_no_newline && hunk.new_no_newline); let both = parse_str( "--- a/x\n+++ b/x\n@@ -1,1 +1,2 @@\n+added\n same\n\\ No newline at end of file\n", ) .unwrap(); let hunk = &both.single_file().unwrap().hunks[0]; assert!(hunk.old_no_newline && hunk.new_no_newline); } #[test] fn several_hunks_and_several_files_are_all_kept() { let patch = parse_str( "\ diff --git a/one b/one index aaaaaaa..bbbbbbb 100644 --- a/one +++ b/one @@ -1,1 +1,2 @@ a +b @@ -10,1 +11,2 @@ c +d diff --git a/two b/two --- a/two +++ b/two @@ -5,1 +5,2 @@ e +f ", ) .unwrap(); assert_eq!(patch.files.len(), 2); assert_eq!(patch.files[0].hunks.len(), 2); assert_eq!(patch.files[1].hunks.len(), 1); // The second file has no index line of its own and must not inherit the first's. assert_eq!(patch.files[1].pre_blob, None); // And a multi-file patch is refused where the tier expects one target, rather than being // silently half-applied. let err = patch.single_file().unwrap_err().to_string(); assert!(err.contains("edits 2 files"), "{err}"); } #[test] fn an_omitted_count_means_one() { let patch = parse_str("--- a/x\n+++ b/x\n@@ -7 +7,2 @@\n a\n+b\n").unwrap(); let hunk = &patch.single_file().unwrap().hunks[0]; assert_eq!((hunk.old_start, hunk.old_count), (7, 1)); assert_eq!((hunk.new_start, hunk.new_count), (7, 2)); } #[test] fn a_truncated_hunk_is_rejected() { // The header promises four old lines; two are present. Reconstructing the short pre-image // and hunting for it is how a patch lands somewhere nobody intended. let err = parse_str("--- a/x\n+++ b/x\n@@ -1,4 +1,4 @@\n a\n b\n") .unwrap_err() .to_string(); assert!(err.contains("truncated or malformed"), "{err}"); } #[test] fn a_file_that_is_not_a_patch_is_rejected() { assert!(parse_str("# patches\n\nUnified diffs against stock ServUO.\n").is_err()); assert!(parse_str("").is_err()); } #[test] fn line_splitting_agrees_with_how_diffs_count_lines() { assert_eq!(split_lines(b"a\nb\n"), vec![&b"a"[..], b"b"]); assert_eq!(split_lines(b"a\r\nb"), vec![&b"a"[..], b"b"]); assert_eq!(split_lines(b""), Vec::<&[u8]>::new()); assert_eq!(split_lines(b"\n"), vec![&b""[..]]); assert_eq!(split_lines(b"a"), vec![&b"a"[..]]); } #[test] fn normalization_covers_line_endings_and_trailing_space_and_nothing_else() { assert_eq!(normalize(b"code \t"), b"code"); assert_eq!(normalize(b"code\r"), b"code"); // Leading indentation is content: two methods differing only in nesting are different // places, and collapsing them is how an anchor becomes ambiguous. assert_ne!(normalize(b" code"), normalize(b"code")); assert_eq!(normalize(b" "), b""); } #[test] fn a_pure_context_hunk_is_a_noop() { let patch = parse_str("--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n b\n").unwrap(); assert!(patch.single_file().unwrap().hunks[0].is_noop()); } #[test] fn an_empty_context_line_written_without_its_space_is_still_context() { // Mailers and editors strip the trailing space off a blank context line routinely, and // every patch tool tolerates it. Both spellings must produce the same pre-image. let padded = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n \n b\n+c\n").unwrap(); let bare = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n\n b\n+c\n").unwrap(); assert_eq!( padded.single_file().unwrap().hunks[0].pre_image(), bare.single_file().unwrap().hunks[0].pre_image() ); } }