//! The patch tier — resolving and applying diffs against stock ServUO files. //! //! Most of the plugin ships as *added* files, which is why the overlay sync is a safe copy. Two //! features cannot: they need edits to stock ServUO sources, because the events they depend on do //! not exist (PLAN.md §2.2). This module is the part of the installer that edits a file the //! operator owns, and it is written to be the most conservative thing in the tool. //! //! ## The rung ladder (PLAN.md §2.2.1) //! //! A whole-file hash compare answers "is this entire file stock?", which is the wrong question: //! these patches touch three small regions of three large files, and an operator who added a //! command to `Logging.cs` has changed its hash without going near the lines the patch edits. //! Refusing on that would hand most real shards a manual job they did not need. So the decision is //! made cheapest-and-safest first, and only the last rung gives up: //! //! | Rung | Test | Outcome | //! |---|---|---| //! | 0 `already-present` | every hunk's *post*-image is in the file | no-op, recorded — keeps re-runs idempotent | //! | 1 `stock-hash` | the whole file reproduces the diff's `index` pre-image | apply | //! | 2 `region-match` | every hunk's stock-side region is still byte-identical | apply at the matched offsets | //! | 3 `region-modified` | anything else | **do not touch the file** — print it for the operator | //! //! Rungs 1 and 2 differ only in the *verdict recorded*, never in what is written: both place text //! by content match, through [`apply`]. A `region-match` apply on a modified file is a different //! support story from a clean apply to a stock tree, which is why `install.json` keeps them apart. //! //! ## The rules that make it safe //! //! - **Exact match, not fuzzy.** Only line-ending and trailing-whitespace normalization //! ([`diff::normalize`]). No `patch --fuzz`, no context reduction — dropping context to force a //! match is precisely how a hunk lands in the wrong method. //! - **Exactly one occurrence, or it fails.** Zero means the region moved or was edited; more than //! one means the anchor is ambiguous and nothing here can know which the author meant. Both are //! rung 3. //! - **All-or-nothing per patch file**, and rung 0 likewise: a file where some hunks are present //! and others are not is a hand-merge in progress, not an idempotent re-run. //! - **Untouched bytes stay byte-identical.** [`apply`] splices over the matched ranges rather than //! re-rendering the file from parsed lines, so nothing outside a hunk can be reformatted by //! accident — and inserted lines take the target file's own dominant line ending, so patching a //! CRLF file does not leave LF islands in it. //! //! ## No `git` //! //! PLAN.md §2.2.1 wrote rung 1 as "apply verbatim with `git apply`", but §1 chose the release //! tarball specifically so there would be **no git on the shard host**, and rung 2 needs a native //! applier regardless. One engine serves both: rung 1 keeps its distinct, stronger verdict (the //! whole file reproduced the pre-image hash) while the write goes through the same code path, so //! there is no second set of CRLF and whitespace behaviours to reason about and a bug report never //! has to say which engine ran. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use crate::diff::{self, FilePatch, Hunk}; use crate::util::git_blob_hash; /// How a patch was placed. Recorded in `install.json` and reported by `doctor` and `uninstall`, /// because the three are different support stories. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Rung { /// Rung 0 — the change is already in the file. Nothing was written. AlreadyPresent, /// Rung 1 — the whole file was stock. StockHash, /// Rung 2 — the file had been modified, but every patched region was still stock. RegionMatch, } impl Rung { pub fn as_str(self) -> &'static str { match self { Self::AlreadyPresent => "already-present", Self::StockHash => "stock-hash", Self::RegionMatch => "region-match", } } /// The one-line explanation an operator reads next to the patch name. pub fn detail(self) -> &'static str { match self { Self::AlreadyPresent => "already applied — nothing to do", Self::StockHash => "stock file", Self::RegionMatch => "file modified, patched region stock", } } } /// Why a patch could not be placed. Every variant is a refusal to write, and each one names /// something the operator can act on. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Refusal { /// The target file is not in the ServUO tree at all. Missing, /// A hunk's stock region is not in the file — moved, edited, or already partly merged. RegionModified { hunk: usize }, /// A hunk's stock region appears more than once, so the anchor cannot identify one place. Ambiguous { hunk: usize, occurrences: usize }, /// Some hunks are already applied and others are not: a hand-merge in progress, which is the /// one state where "finish the job" is the most dangerous thing the installer could do. PartiallyApplied, /// The file could not be read. Unreadable(String), } impl Refusal { pub fn detail(&self) -> String { match self { Self::Missing => "the file is not in this ServUO tree — not applied".into(), Self::RegionModified { hunk } => { format!("patched region has been modified (hunk {hunk}) — not applied") } Self::Ambiguous { hunk, occurrences } => { format!("hunk {hunk}'s region appears {occurrences} times — ambiguous, not applied") } Self::PartiallyApplied => { "partly applied already — a hand merge in progress, not touched".into() } Self::Unreadable(why) => format!("cannot be read ({why}) — not applied"), } } } /// What resolving one patch against one tree concluded. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Resolution { /// Nothing to do — the change is already in the file. AlreadyPresent { hunks: Vec }, /// The patch can be placed. `edits` are byte ranges in the current file content, highest offset /// first, so applying them in order keeps every later offset valid. Applicable { rung: Rung, hunks: Vec, edits: Vec, }, /// The patch will not be placed, and why. Refused(Refusal), } impl Resolution { pub fn rung(&self) -> Option { match self { Self::AlreadyPresent { .. } => Some(Rung::AlreadyPresent), Self::Applicable { rung, .. } => Some(*rung), Self::Refused(_) => None, } } pub fn placements(&self) -> &[HunkPlacement] { match self { Self::AlreadyPresent { hunks } | Self::Applicable { hunks, .. } => hunks, Self::Refused(_) => &[], } } } /// Where a hunk was found, for the record and for the report. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct HunkPlacement { /// The `@@` header's stock line number — what the patch author saw. pub declared_line: usize, /// The 1-based line the region was actually found at. Insertions above the region shift this, /// which is exactly why the match is by content and this number is an output rather than an /// input. pub matched_line: usize, } /// A byte range of the file to replace with `replacement`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Edit { pub start: usize, pub end: usize, pub replacement: Vec, } /// Resolves one patch against one file's current content. /// /// The order is the ladder's: rung 0 first (so a re-run is a no-op rather than a second apply), /// then the whole-file hash, then the region match. `pre_blob` is the diff's `index` pre-image; a /// patch without an `index` line simply cannot reach rung 1, which is not a defect — rung 2 is the /// stronger check anyway. pub fn resolve(file: &FilePatch, content: &[u8]) -> Resolution { let hunks: Vec<&Hunk> = file.hunks.iter().filter(|h| !h.is_noop()).collect(); if hunks.is_empty() { return Resolution::Refused(Refusal::RegionModified { hunk: 1 }); } let lines = diff::split_lines(content); let normalized: Vec<&[u8]> = lines.iter().map(|l| diff::normalize(l)).collect(); // ── Rung 0 ─────────────────────────────────────────────────────────────── // All-or-nothing: a file where some hunks are present and others are not is a hand-merge in // progress, and "finish it" is the one thing that must not happen automatically. let post_hits: Vec>> = hunks .iter() .map(|h| find_block(&normalized, &normalize_all(&h.post_image()))) .collect(); let present = post_hits .iter() .filter(|hit| hit.as_ref().is_some_and(|m| m.len() == 1)) .count(); if present == hunks.len() { return Resolution::AlreadyPresent { hunks: hunks .iter() .zip(&post_hits) .map(|(h, hit)| HunkPlacement { declared_line: h.old_start, matched_line: hit.as_ref().map(|m| m[0] + 1).unwrap_or(0), }) .collect(), }; } if present > 0 { return Resolution::Refused(Refusal::PartiallyApplied); } // ── Rungs 1 and 2 ──────────────────────────────────────────────────────── // Both place text by content match; only the verdict differs. A file whose hash says "stock" // must match by content too — if it somehow did not, the honest answer is rung 3, not a write // made on the strength of a hash alone. let stock = file .pre_blob .as_deref() .is_some_and(|expected| git_blob_hash(content).starts_with(expected)); let eol = dominant_eol(content); let mut placements = Vec::with_capacity(hunks.len()); let mut edits = Vec::with_capacity(hunks.len()); for (index, hunk) in hunks.iter().enumerate() { let needle = normalize_all(&hunk.pre_image()); let matches = match find_block(&normalized, &needle) { Some(m) => m, None => return Resolution::Refused(Refusal::RegionModified { hunk: index + 1 }), }; if matches.len() > 1 { return Resolution::Refused(Refusal::Ambiguous { hunk: index + 1, occurrences: matches.len(), }); } let at = matches[0]; placements.push(HunkPlacement { declared_line: hunk.old_start, matched_line: at + 1, }); edits.push(splice(content, &lines, at, needle.len(), hunk, eol)); } // Two hunks resolving onto overlapping text would corrupt the file even though each matched // uniquely. It cannot happen with a well-formed diff — git never emits overlapping hunks — but // a hand-assembled one could, and the cost of the check is nothing. let mut ordered: Vec<&Edit> = edits.iter().collect(); ordered.sort_by_key(|e| e.start); if ordered.windows(2).any(|w| w[0].end > w[1].start) { return Resolution::Refused(Refusal::RegionModified { hunk: 1 }); } // Highest offset first, so applying one edit never invalidates the next one's range. edits.sort_by_key(|e| std::cmp::Reverse(e.start)); Resolution::Applicable { rung: if stock { Rung::StockHash } else { Rung::RegionMatch }, hunks: placements, edits, } } /// Builds the replacement for one hunk: the byte range the matched pre-image occupies, and the /// post-image rendered with the file's own line ending. /// /// Working in byte ranges rather than rebuilding the file from parsed lines is what guarantees the /// rest of the file comes out unchanged down to the byte — including any mixed line endings, /// trailing whitespace, or unusual encoding elsewhere in it, none of which is this tool's business. fn splice( content: &[u8], lines: &[&[u8]], at: usize, span: usize, hunk: &Hunk, eol: &[u8], ) -> Edit { let start = offset_of(content, lines, at); let last = at + span - 1; // The end of the matched block, terminator included — unless it is the file's last line and // the file has no trailing newline. let end = if last + 1 < lines.len() { offset_of(content, lines, last + 1) } else { content.len() }; let trailing_newline = end > 0 && content[end - 1] == b'\n'; let post = hunk.post_image(); let mut replacement = Vec::with_capacity(end - start); for (i, line) in post.iter().enumerate() { replacement.extend_from_slice(line); let is_last = i + 1 == post.len(); // The last line keeps whatever the region it replaces had: a hunk in the middle of a file // is always terminated, and one at the end of a file with no final newline must not gain // one. `\ No newline at end of file` on the new side says the same thing explicitly. if !is_last || (trailing_newline && !hunk.new_no_newline) { replacement.extend_from_slice(eol); } } Edit { start, end, replacement, } } /// The byte offset at which line `index` starts. /// /// Derived from the slice's position inside the buffer rather than by re-scanning: [`split_lines`] /// borrows from `content`, so the arithmetic is exact and cannot disagree with the split. fn offset_of(content: &[u8], lines: &[&[u8]], index: usize) -> usize { if index >= lines.len() { return content.len(); } lines[index].as_ptr() as usize - content.as_ptr() as usize } /// The line ending the file mostly uses, for inserted lines. /// /// The three files this tier edits are CRLF. Writing LF into them would leave islands of the wrong /// ending inside a method — harmless to the C# compiler, and a permanent source of noise in every /// diff the operator takes afterwards. fn dominant_eol(content: &[u8]) -> &'static [u8] { let total = content.iter().filter(|b| **b == b'\n').count(); let crlf = content.windows(2).filter(|w| w == b"\r\n").count(); if total > 0 && crlf * 2 >= total { b"\r\n" } else { b"\n" } } fn normalize_all<'a>(lines: &[&'a [u8]]) -> Vec<&'a [u8]> { lines.iter().map(|l| diff::normalize(l)).collect() } /// Every starting line at which `needle` appears in `haystack`, comparing normalized content. /// /// Returns `None` for an empty needle rather than "matches everywhere", which is the difference /// between refusing a degenerate hunk and splicing at line 1 of the file. fn find_block(haystack: &[&[u8]], needle: &[&[u8]]) -> Option> { if needle.is_empty() || needle.len() > haystack.len() { return None; } let hits: Vec = (0..=haystack.len() - needle.len()) .filter(|start| haystack[*start..*start + needle.len()] == *needle) .collect(); if hits.is_empty() { None } else { Some(hits) } } /// Applies the edits of an [`Resolution::Applicable`] to a buffer. pub fn apply(content: &[u8], edits: &[Edit]) -> Vec { let mut out = content.to_vec(); // The edits arrive highest-offset-first from `resolve`, so each splice leaves every remaining // range valid. Re-sorting here rather than trusting the caller keeps that a local property. let mut ordered: Vec<&Edit> = edits.iter().collect(); ordered.sort_by_key(|e| std::cmp::Reverse(e.start)); for edit in ordered { out.splice(edit.start..edit.end, edit.replacement.iter().copied()); } out } // ───────────────────────────────────────────────────────────────────────────── // The tier's declared shape // ───────────────────────────────────────────────────────────────────────────── /// The patch tier as the overlay release declares it (`patch_tier` in `manifest.json`, generated /// from `servuo-plugins/patches/tier.json`). /// /// A `.patch` does not carry enough on its own: which patches form one all-or-nothing unit, which /// companion `.cs` may only be copied once that unit lands, whether a **core** solution rebuild is /// needed, and what the operator loses by declining are all things the diffs cannot say. Declaring /// them in the release means adding a patch regenerates release metadata rather than requiring an /// installer release — the same rule PLAN.md §7.1 applies to the bundle. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Tier { pub features: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Feature { pub name: String, /// What the operator gains, for the offer. pub summary: String, /// What they lose by declining, phrased to complete "Without it: …". pub lost: String, /// `core` — the ServUO solution must be rebuilt (`dotnet build ServUO.sln`); the dynamic script /// build is not enough. `scripts` — a shard restart suffices. pub rebuild: Rebuild, pub patches: Vec, pub companions: Vec, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Rebuild { Core, Scripts, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PatchRef { pub name: String, /// Relative to the extracted tarball root, i.e. `patches/.patch`. pub file: String, /// Relative to the ServUO root, `/`-separated. pub target: String, } /// A source file that may only be copied once its feature's patches have landed, because it /// references symbols they introduce. Shipping these in the base overlay would break the build on /// every unpatched install, which is why they live in `patches/`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Companion { /// Relative to the extracted tarball root. pub file: String, /// Relative to the ServUO root. pub install_to: String, } impl Tier { /// The tier an overlay declares, or the built-in description of the one that shipped before /// `patch_tier` existed. /// /// The fallback is not a convenience: overlay `v0.1.1` is in the current bundle and declares /// nothing, so without it this whole phase would be unusable until a new overlay release /// existed. It describes exactly the three patches that release ships. An overlay that declares /// its own tier always wins, so the fallback goes quiet the moment it is wrong. pub fn resolve(declared: Option<&Tier>) -> Tier { declared.cloned().unwrap_or_else(Tier::builtin) } /// Mirrors `servuo-plugins/patches/tier.json`, which is the source of truth. Only reached for /// an overlay released before that file existed. pub fn builtin() -> Tier { Tier { features: vec![ Feature { name: "vendor-sale".into(), summary: "vendor.sale events — player-vendor purchases with buyer, owner, \ item, price and commission" .into(), lost: "no vendor.sale events".into(), rebuild: Rebuild::Core, patches: vec![ PatchRef { name: "playervendor-sale-eventsink".into(), file: "patches/playervendor-sale-eventsink.patch".into(), target: "Server/EventSink.cs".into(), }, PatchRef { name: "playervendor-sale-gump".into(), file: "patches/playervendor-sale-gump.patch".into(), target: "Scripts/Gumps/PlayerVendorGumps.cs".into(), }, ], companions: vec![Companion { file: "patches/BridgeVendorSale.cs".into(), install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(), }], }, Feature { name: "moderation-audit".into(), summary: "in-game moderation actions ([ban, [kick, [bcast) forwarded to the \ website as admin.audit" .into(), lost: "no in-game moderation audit forwarding".into(), rebuild: Rebuild::Scripts, patches: vec![PatchRef { name: "commandlogging-event".into(), file: "patches/commandlogging-event.patch".into(), target: "Scripts/Commands/Logging.cs".into(), }], companions: vec![Companion { file: "patches/BridgeModerationAudit.cs".into(), install_to: "Scripts/Custom/Bridge/BridgeModerationAudit.cs".into(), }], }, ], } } pub fn patch_count(&self) -> usize { self.features.iter().map(|f| f.patches.len()).sum() } } /// Joins a `/`-separated relative path onto a root, using this platform's separator. pub fn join(root: &Path, rel: &str) -> PathBuf { root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) } /// Reads and parses one declared patch, checking that the diff edits the file the tier says it /// does. /// /// The cross-check is not redundant with the release gate: the gate gives up if a patch is renamed /// in one place and not the other, but an operator can also be running an installer against an /// overlay whose tier declaration and patches were assembled by hand. The installer is about to /// edit a stock file on the strength of that pairing, so it verifies it rather than assuming. pub fn load(unpacked: &Path, patch: &PatchRef) -> Result<(Vec, FilePatch)> { let path = join(unpacked, &patch.file); let bytes = std::fs::read(&path) .with_context(|| format!("cannot read {} from the overlay release", patch.file))?; let parsed = diff::parse(&bytes) .with_context(|| format!("{} is not a patch this installer can read", patch.file))?; let file = parsed .single_file() .with_context(|| format!("{} cannot be applied as a single-target patch", patch.file))? .clone(); if file.path != patch.target { bail!( "{} edits {} but the overlay declares its target as {} — refusing to patch a file the \ release does not claim it patches", patch.file, file.path, patch.target ); } Ok((bytes, file)) } /// The applied patch tier, as `install.json` records it. /// /// Only features that are actually in place are recorded: a declined or refused feature left no /// trace in the tree, and a record of it would make `doctor` and `uninstall` report work that was /// never done. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FeatureRecord { pub feature: String, pub rebuild: Rebuild, /// The ServUO version detected when the tier ran, and whether it ran on an unsupported one. /// /// This is the label that follows the install (PLAN.md §2.2.2): `doctor` shows it on every /// later run and the uninstall report carries it, so whoever inherits this shard can see it /// without being told. pub servuo_version: Option, pub unsupported_servuo: bool, pub patches: Vec, pub companions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AppliedPatch { pub name: String, /// Relative to the ServUO root. pub target: String, /// `stock-hash`, `region-match` or `already-present`. pub rung: String, /// SHA256 of the `.patch` file, which is also cached beside `install.json`. pub sha256: String, pub hunks: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CompanionRecord { /// Relative to the ServUO root. pub path: String, pub sha256: String, } /// Indexes recorded features by name, for the idempotence rule in `install.rs`. pub fn index_records(records: &[FeatureRecord]) -> BTreeMap<&str, &FeatureRecord> { records .iter() .map(|r| (r.feature.as_str(), r)) .collect::>() } #[cfg(test)] mod tests { use super::*; use crate::diff::parse; /// A file with the patched region buried in enough surrounding text that a match is meaningful. fn stock_file() -> Vec { let mut s = String::new(); for i in 0..40 { s.push_str(&format!("// filler {i}\n")); } s.push_str(" public void Alpha()\n"); s.push_str(" {\n"); s.push_str(" Work();\n"); s.push_str(" }\n"); for i in 0..40 { s.push_str(&format!("// tail {i}\n")); } s.into_bytes() } /// Adds a line inside the patched region. const PATCH: &str = "\ --- a/Target.cs +++ b/Target.cs @@ -41,4 +41,5 @@ public void Alpha() { + Hook(); Work(); } "; fn file_patch(text: &str) -> FilePatch { parse(text.as_bytes()) .unwrap() .single_file() .unwrap() .clone() } fn applied(patch: &FilePatch, content: &[u8]) -> Vec { match resolve(patch, content) { Resolution::Applicable { edits, .. } => apply(content, &edits), other => panic!("expected an applicable resolution, got {other:?}"), } } #[test] fn a_stock_file_reaches_rung_one_and_applies() { let content = stock_file(); // A real diff's index line names the stock blob; build one so the hash rung is exercised // against a hash this test did not also invent. let patch = file_patch(&PATCH.replace( "--- a/Target.cs", &format!( "index {}..0000000 100644\n--- a/Target.cs", git_blob_hash(&content) ), )); let resolution = resolve(&patch, &content); assert_eq!(resolution.rung(), Some(Rung::StockHash)); let out = apply( &content, match &resolution { Resolution::Applicable { edits, .. } => edits, _ => unreachable!(), }, ); let text = String::from_utf8(out).unwrap(); assert!( text.contains(" Hook();\n Work();\n"), "{text}" ); } #[test] fn an_edit_far_from_the_region_still_reaches_rung_two() { // The whole point of the ladder: most real shards are hand-modified somewhere, and // refusing on a whole-file hash would hand them a manual job they did not need. let mut content = stock_file(); content.extend_from_slice(b"// the operator added their own command down here\n"); let patch = file_patch(PATCH); let resolution = resolve(&patch, &content); assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); assert_eq!(resolution.placements()[0].matched_line, 41); assert!(String::from_utf8(applied(&patch, &content)) .unwrap() .contains("Hook();")); } #[test] fn an_edit_inside_the_region_is_refused_and_writes_nothing() { let content = String::from_utf8(stock_file()) .unwrap() .replace(" Work();", " Work(withMyArgument);") .into_bytes(); match resolve(&file_patch(PATCH), &content) { Resolution::Refused(Refusal::RegionModified { hunk }) => assert_eq!(hunk, 1), other => panic!("expected a refusal, got {other:?}"), } } #[test] fn a_region_that_appears_twice_fails_rather_than_picking_the_first() { // The rule that keeps a hunk out of the wrong method. Both copies are legitimate code; // nothing here can know which one the patch author meant, so it must not guess. let mut content = stock_file(); content.extend_from_slice( b" public void Alpha()\n {\n Work();\n }\n", ); match resolve(&file_patch(PATCH), &content) { Resolution::Refused(Refusal::Ambiguous { occurrences, .. }) => { assert_eq!(occurrences, 2) } other => panic!("expected an ambiguity refusal, got {other:?}"), } } #[test] fn an_already_patched_file_is_rung_zero_and_a_re_run_is_a_no_op() { let content = stock_file(); let patch = file_patch(PATCH); let once = applied(&patch, &content); let resolution = resolve(&patch, &once); assert_eq!(resolution.rung(), Some(Rung::AlreadyPresent)); assert!(matches!(resolution, Resolution::AlreadyPresent { .. })); // ...and there is nothing to apply, so a third run cannot double up. assert_eq!(applied(&patch, &content), once); } #[test] fn a_half_merged_file_is_refused() { // Some hunks present, others not. "Finish the job" is the most dangerous thing available // here, because the operator is evidently mid-merge. let two_hunks = "\ --- a/Target.cs +++ b/Target.cs @@ -41,2 +41,3 @@ public void Alpha() { + Hook(); @@ -60,1 +61,2 @@ // tail 15 +// second hook "; let content = stock_file(); let patch = file_patch(two_hunks); let both = applied(&patch, &content); // Undo only the second hunk, leaving the first in place. let half = String::from_utf8(both) .unwrap() .replace("// tail 15\n// second hook\n", "// tail 15\n") .into_bytes(); assert!(matches!( resolve(&patch, &half), Resolution::Refused(Refusal::PartiallyApplied) )); } #[test] fn a_crlf_file_keeps_its_line_endings_and_its_other_bytes() { // All three ServUO files this tier edits are CRLF. Inserting LF lines into one would leave // islands of the wrong ending inside a method and pollute every later diff. let content = String::from_utf8(stock_file()) .unwrap() .replace('\n', "\r\n") .into_bytes(); let out = applied(&file_patch(PATCH), &content); assert!(!String::from_utf8_lossy(&out).contains("Hook();\n Work")); assert!(String::from_utf8_lossy(&out).contains("Hook();\r\n Work")); // Every line is still CRLF — no islands. assert_eq!( out.iter().filter(|b| **b == b'\n').count(), out.windows(2).filter(|w| w == b"\r\n").count() ); } #[test] fn everything_outside_the_hunk_comes_back_byte_identical() { // The splice, not a re-render. Unusual bytes elsewhere in the file are the operator's // business and must survive untouched. let mut content = stock_file(); content.extend_from_slice(b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n"); let out = applied(&file_patch(PATCH), &content); let tail = b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n"; assert!(out.ends_with(tail)); assert_eq!(out.len(), content.len() + b" Hook();\n".len()); } #[test] fn a_file_without_a_trailing_newline_does_not_gain_one() { let content = b"alpha\nbeta".to_vec(); let patch = file_patch("--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n alpha\n+middle\n beta\n"); let out = applied(&patch, &content); assert_eq!(out, b"alpha\nmiddle\nbeta"); } #[test] fn trailing_whitespace_differences_do_not_block_a_match() { // Editors and mail transports strip trailing whitespace routinely; PLAN.md §2.2.1 allows // exactly this much normalization and no more. let content = String::from_utf8(stock_file()) .unwrap() .replace(" {\n", " { \n") .into_bytes(); assert!(resolve(&file_patch(PATCH), &content).rung().is_some()); } #[test] fn a_hash_that_says_stock_but_content_that_does_not_is_refused() { // A contradiction: the file claims to be the pre-image but the region is not there. The // only safe reading is rung 3 — a write made on the strength of a hash alone is exactly // what the ladder exists to avoid. let content = b"nothing like the patched file at all\n".to_vec(); let patch = file_patch(&PATCH.replace( "--- a/Target.cs", &format!( "index {}..0000000 100644\n--- a/Target.cs", git_blob_hash(&content) ), )); assert!(matches!( resolve(&patch, &content), Resolution::Refused(Refusal::RegionModified { .. }) )); } #[test] fn the_builtin_tier_describes_the_release_that_predates_the_declaration() { // Mirrors servuo-plugins/patches/tier.json. If that file gains a feature, this fallback is // only ever used for older overlays, which do not have it — so it stays as it is. let tier = Tier::resolve(None); assert_eq!(tier.features.len(), 2); assert_eq!(tier.patch_count(), 3); let vendor = &tier.features[0]; assert_eq!(vendor.rebuild, Rebuild::Core, "EventSink.cs is a core file"); assert_eq!( vendor.patches.len(), 2, "the two vendor patches are one unit" ); for feature in &tier.features { assert!(!feature.companions.is_empty()); for companion in &feature.companions { assert!(companion.file.starts_with("patches/"), "{companion:?}"); assert!( companion.install_to.starts_with("Scripts/Custom/Bridge/"), "{companion:?}" ); } } } #[test] fn a_declared_tier_wins_over_the_builtin_one() { let declared = Tier { features: vec![Feature { name: "future-feature".into(), summary: "something later".into(), lost: "nothing yet".into(), rebuild: Rebuild::Scripts, patches: vec![], companions: vec![], }], }; assert_eq!(Tier::resolve(Some(&declared)), declared); } #[test] fn the_tier_round_trips_through_the_manifests_json_shape() { // The exact shape servuo-plugins/patches/tier.json produces, so a mismatch shows up here // rather than as a silently empty tier on an operator's shard. let json = r#"{ "features": [{ "name": "moderation-audit", "summary": "in-game moderation actions forwarded as admin.audit", "lost": "no in-game moderation audit forwarding", "rebuild": "scripts", "patches": [{ "name": "commandlogging-event", "file": "patches/commandlogging-event.patch", "target": "Scripts/Commands/Logging.cs" }], "companions": [{ "file": "patches/BridgeModerationAudit.cs", "install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs" }] }] }"#; let tier: Tier = serde_json::from_str(json).unwrap(); assert_eq!(tier.features[0].rebuild, Rebuild::Scripts); assert_eq!(tier.patch_count(), 1); assert_eq!( tier.features[0].patches[0].target, "Scripts/Commands/Logging.cs" ); } }