feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
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>
This commit is contained in:
317
tests/real_patches.rs
Normal file
317
tests/real_patches.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user