Files
installer/tests/real_patches.rs
wtclaude 52d330167b
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
feat(installer): implement Phase 3 — the patch tier
Two features need edits to stock ServUO sources, because the events they
depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the
unsupported-version path of §2.2.2, and the record and cache Phase 4 will read.

Three decisions were not settled by the plan:

* The engine is fully native, with no `git`. §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. Rung 1 keeps its distinct, stronger verdict — the whole file
  reproduced the diff's `index` pre-image, computed as a git blob SHA1 in
  process — while the write goes through the same code path as rung 2. On the
  real trees here that is not academic: the shipped .patch files are CRLF in a
  Windows checkout and two of their three targets are LF, so `git apply`
  refuses patches this applies correctly.

* Per-patch metadata is declared by the release, with a built-in fallback.
  Which patches form one all-or-nothing unit, which companion .cs follows
  which, whether a CORE rebuild is needed and what declining costs are not
  derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in
  the current bundle and declares nothing, so a built-in copy stands in for it.
  A checked-in fixture of the release workflow's own jq output asserts the two
  descriptions are identical, so the repos cannot drift quietly.

* Pre-images are cached in the state directory. The tier edits files the
  operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here
  are the hunks we added" into a revert anyone can verify — kept out of the
  ServUO tree, which uninstall has promised never to clean up.

Everything else follows §2.2.1: exact matching with only line-ending and
trailing-whitespace normalization, exactly one occurrence or it fails,
all-or-nothing per patch file and again per feature, and a byte-preserving
splice so nothing outside a hunk can be reformatted.

Verified against the ServUO 57.4 tree on this machine across four scratch
roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the
real EventSink.cs, its blob matching the patch's declared pre-image), a
mixed-rung feature, a tree with edits inside two patched regions (rung 3 —
nothing written, nothing held back applied, no companions copied), and a
non-57.4 tree both with and without the extra consent flag. Three consecutive
runs left install.json byte-identical and the cached pre-image still pre-patch.

Three reporting defects the live runs caught are fixed with tests: a dry run
and a held-back patch both claimed to be "applied", the core-rebuild warning
fired when nothing had been written and named a Scripts file as core, and a
declined tier announced the loss of features install.json showed as applied.
Refused patches are now cached too, since the refusal message names that path.

Refs: docs/installer/PLAN.md §2.2, §5 Phase 3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:54:36 -05:00

318 lines
13 KiB
Rust

//! The rung ladder against the patches this tier actually ships.
//!
//! `src/patch.rs` proves the engine's rules on synthetic diffs, which is the right place to make a
//! rule fail on purpose. This file proves the same engine handles the three real ones — because
//! every property that matters here is a property of *those* files rather than of unified diffs in
//! general:
//!
//! - `commandlogging-event.patch` has no `diff --git` and no `index` line, so rung 1 is
//! structurally unavailable for it and it must still apply through rung 2.
//! - `playervendor-sale-eventsink.patch` carries four hunks against one file, so the all-or-nothing
//! rule, the descending-offset splice and the overlap check all get exercised at once.
//! - Every one of the three is CRLF in a Windows checkout and LF in the tarball CI builds, and both
//! spellings have to behave identically.
//!
//! The fixtures are copies of `servuo-plugins/patches/*.patch`. The *targets* are synthesized
//! rather than vendored: the real ones are ServUO's own sources, and reproducing the region a hunk
//! expects — surrounded by filler that is deliberately not ServUO — is a stricter test of a
//! content match than pasting in 2,600 lines that happen to contain it.
use rgdeploy::diff;
use rgdeploy::patch::{self, Refusal, Resolution, Rung};
use rgdeploy::util::git_blob_hash;
const PATCHES: &[(&str, &str)] = &[
("commandlogging-event.patch", "Scripts/Commands/Logging.cs"),
("playervendor-sale-eventsink.patch", "Server/EventSink.cs"),
(
"playervendor-sale-gump.patch",
"Scripts/Gumps/PlayerVendorGumps.cs",
),
];
fn fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
fn parse(name: &str) -> diff::FilePatch {
diff::parse(&fixture(name))
.unwrap_or_else(|e| panic!("{name} did not parse: {e}"))
.single_file()
.unwrap_or_else(|e| panic!("{name} is not single-target: {e}"))
.clone()
}
/// A stand-in for the stock ServUO file: each hunk's pre-image, in order, separated by filler that
/// could not be mistaken for context.
fn synthesize_target(file: &diff::FilePatch, eol: &str) -> Vec<u8> {
let mut out = String::new();
for (i, hunk) in file.hunks.iter().enumerate() {
for f in 0..12 {
out.push_str(&format!("// unrelated shard code {i}/{f}{eol}"));
}
for line in hunk.pre_image() {
out.push_str(&String::from_utf8_lossy(line));
out.push_str(eol);
}
}
out.push_str(&format!("// end of file{eol}"));
out.into_bytes()
}
fn edits_of(resolution: &Resolution) -> &[patch::Edit] {
match resolution {
Resolution::Applicable { edits, .. } => edits,
other => panic!("expected an applicable resolution, got {other:?}"),
}
}
#[test]
fn every_shipped_patch_parses_and_names_its_declared_target() {
for (name, target) in PATCHES {
let file = parse(name);
assert_eq!(&file.path, target, "{name}");
assert!(!file.hunks.is_empty(), "{name}");
assert!(
file.hunks.iter().any(|h| !h.is_noop()),
"{name} changes nothing"
);
}
}
#[test]
fn a_patch_without_an_index_line_still_applies_through_rung_two() {
// commandlogging-event.patch is a plain ---/+++ diff. Rung 1 cannot be reached for it at all,
// which must be a fact the tier reports rather than a reason to skip the patch.
let file = parse("commandlogging-event.patch");
assert_eq!(file.pre_blob, None, "this fixture has no index line");
let target = synthesize_target(&file, "\r\n");
let resolution = patch::resolve(&file, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch));
let patched = patch::apply(&target, edits_of(&resolution));
let text = String::from_utf8(patched).unwrap();
assert!(
text.contains("public static event Action<Mobile, string> OnWrite;"),
"{text}"
);
// The `m_Enabled` early return is deleted from the two-argument overload — a removal, not just
// an insertion, so the splice is doing more than appending.
assert_eq!(text.matches("if (!m_Enabled)").count(), 1, "{text}");
}
#[test]
fn the_four_hunk_patch_applies_all_of_them_at_the_right_offsets() {
// EventSink.cs is the one with several hunks in one file: the descending-offset splice, the
// overlap check and the all-or-nothing rule are all exercised together here.
let file = parse("playervendor-sale-eventsink.patch");
assert_eq!(file.hunks.len(), 4);
let target = synthesize_target(&file, "\r\n");
let resolution = patch::resolve(&file, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch));
assert_eq!(resolution.placements().len(), 4);
// Every hunk landed somewhere different, and in the order the diff declares them.
let lines: Vec<usize> = resolution
.placements()
.iter()
.map(|p| p.matched_line)
.collect();
assert!(lines.windows(2).all(|w| w[0] < w[1]), "{lines:?}");
let text = String::from_utf8(patch::apply(&target, edits_of(&resolution))).unwrap();
for expected in [
"public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);",
"public class PlayerVendorSaleEventArgs : EventArgs",
"public static event PlayerVendorSaleEventHandler PlayerVendorSale;",
"public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)",
] {
assert!(text.contains(expected), "missing: {expected}");
}
}
#[test]
fn a_stock_target_reaches_rung_one() {
// The synthesized file is not ServUO's, so its blob hash is not the one the diff records. Feed
// the diff the hash of the file it is about to be resolved against, which is exactly the
// situation on a genuinely stock tree.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\r\n");
let stated = diff::FilePatch {
pre_blob: Some(git_blob_hash(&target)[..7].to_string()),
..file
};
assert_eq!(
patch::resolve(&stated, &target).rung(),
Some(Rung::StockHash),
"{name}"
);
}
}
#[test]
fn applying_twice_is_a_no_op_for_every_shipped_patch() {
// The idempotence promise: `install` is documented as safe to re-run, and the tier is the part
// of it that edits files the operator owns.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\r\n");
let first = patch::apply(&target, edits_of(&patch::resolve(&file, &target)));
let second = patch::resolve(&file, &first);
assert_eq!(second.rung(), Some(Rung::AlreadyPresent), "{name}");
assert!(
matches!(second, Resolution::AlreadyPresent { .. }),
"{name} would be applied a second time"
);
}
}
#[test]
fn an_edit_inside_a_patched_region_is_refused_for_every_shipped_patch() {
// Rung 3 is the outcome most real shards will see on at least one patch, so it must be the one
// that never writes. The edit is placed on the first line the hunk actually removes or keeps.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\n");
let anchor = file.hunks[0]
.pre_image()
.iter()
.map(|l| String::from_utf8_lossy(l).to_string())
.find(|l| l.trim().len() > 12)
.expect("a substantial context line to vandalize");
let vandalized = String::from_utf8_lossy(&target)
.replacen(&anchor, &format!("{anchor} /* operator's own change */"), 1)
.into_bytes();
assert_ne!(vandalized, target, "{name}: the fixture was not modified");
match patch::resolve(&file, &vandalized) {
Resolution::Refused(Refusal::RegionModified { .. }) => {}
other => panic!("{name}: expected a refusal, got {other:?}"),
}
}
}
#[test]
fn line_endings_do_not_change_the_verdict_or_the_result() {
// A patch is CRLF in a Windows checkout and LF in the tarball, and a target may be either. The
// rung reached must not depend on that, and the patched file must keep the ending it had.
for (name, _) in PATCHES {
let raw = fixture(name);
let as_lf = String::from_utf8_lossy(&raw)
.replace("\r\n", "\n")
.into_bytes();
let as_crlf = String::from_utf8_lossy(&as_lf)
.replace('\n', "\r\n")
.into_bytes();
let from_lf = diff::parse(&as_lf).unwrap().single_file().unwrap().clone();
let from_crlf = diff::parse(&as_crlf)
.unwrap()
.single_file()
.unwrap()
.clone();
assert_eq!(
from_lf, from_crlf,
"{name}: the two spellings parsed differently"
);
for eol in ["\n", "\r\n"] {
let target = synthesize_target(&from_lf, eol);
let resolution = patch::resolve(&from_lf, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch), "{name} {eol:?}");
let patched = patch::apply(&target, edits_of(&resolution));
let newlines = patched.iter().filter(|b| **b == b'\n').count();
let crlfs = patched.windows(2).filter(|w| w == b"\r\n").count();
if eol == "\r\n" {
assert_eq!(crlfs, newlines, "{name}: LF islands in a CRLF file");
} else {
assert_eq!(crlfs, 0, "{name}: CR appeared in an LF file");
}
}
}
}
#[test]
fn the_tier_the_release_workflow_emits_is_the_one_this_installer_reads() {
// `patch_tier.json` is the literal output of the jq filter in
// servuo-plugins/.gitea/workflows/release.yml, run over that repo's patches/tier.json. It is
// checked in so the two repos cannot drift apart quietly: a renamed field there would fail
// here rather than producing an empty tier on an operator's shard, where the only symptom is
// a patch tier that is never offered.
let json = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("patch_tier.json"),
)
.unwrap();
// Deserialized through Manifest, not through Tier, because the field's name and its
// `Option`-ness are half of the contract.
let manifest: serde_json::Value = serde_json::json!({
"component": "servuo-plugins-overlay",
"version": "0.2.0",
"commit": "0000000",
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"patch_tier": serde_json::from_str::<serde_json::Value>(&json).unwrap(),
"files": {}
});
let manifest: rgdeploy::overlay::Manifest = serde_json::from_value(manifest).unwrap();
let declared = manifest.patch_tier.expect("patch_tier must deserialize");
// The declared tier and the built-in fallback have to describe the same release, or an
// installer would behave differently against v0.1.1 and v0.2.0 of the same overlay.
assert_eq!(declared, rgdeploy::patch::Tier::builtin());
// ...and everything it names must be resolvable against the patches that ship.
for feature in &declared.features {
for declared_patch in &feature.patches {
let file = parse(&declared_patch.file.replace("patches/", ""));
assert_eq!(file.path, declared_patch.target, "{}", declared_patch.name);
}
for companion in &feature.companions {
assert!(companion.file.starts_with("patches/"), "{companion:?}");
}
}
}
#[test]
fn the_builtin_tier_names_exactly_the_patches_that_ship() {
// The fallback for overlay releases older than `patch_tier` in the manifest. It has to describe
// the release it stands in for, and the fixtures here are that release's patches.
let tier = rgdeploy::patch::Tier::resolve(None);
let mut declared: Vec<String> = tier
.features
.iter()
.flat_map(|f| f.patches.iter())
.map(|p| p.file.replace("patches/", ""))
.collect();
declared.sort();
let mut shipped: Vec<String> = PATCHES.iter().map(|(n, _)| n.to_string()).collect();
shipped.sort();
assert_eq!(declared, shipped);
for feature in &tier.features {
for declared in &feature.patches {
let file = parse(&declared.file.replace("patches/", ""));
assert_eq!(
file.path, declared.target,
"{}: the fallback declares a target the diff does not edit",
declared.name
);
}
}
}