Files
installer/src/patch.rs
wtclaude 02c5ad9839
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
fix(installer): satisfy clippy's unnecessary_sort_by on the CI toolchain
The two descending sorts in the applier used an explicit comparator. CI runs
clippy 1.97, where `unnecessary_sort_by` flags that and `-D warnings` turns it
into a build failure; the local toolchain here is 1.94, which does not have the
lint. `sort_by_key` with `Reverse` says the same thing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 20:01:37 -05:00

891 lines
36 KiB
Rust

//! 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<HunkPlacement> },
/// 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<HunkPlacement>,
edits: Vec<Edit>,
},
/// The patch will not be placed, and why.
Refused(Refusal),
}
impl Resolution {
pub fn rung(&self) -> Option<Rung> {
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<u8>,
}
/// 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<Option<Vec<usize>>> = 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<Vec<usize>> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
let hits: Vec<usize> = (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<u8> {
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<Feature>,
}
#[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<PatchRef>,
pub companions: Vec<Companion>,
}
#[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/<file>.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<u8>, 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<String>,
pub unsupported_servuo: bool,
pub patches: Vec<AppliedPatch>,
pub companions: Vec<CompanionRecord>,
}
#[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<HunkPlacement>,
}
#[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::<BTreeMap<_, _>>()
}
#[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<u8> {
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<u8> {
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"
);
}
}