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:
890
src/patch.rs
Normal file
890
src/patch.rs
Normal file
@@ -0,0 +1,890 @@
|
||||
//! 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(|a, b| b.start.cmp(&a.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(|a, b| b.start.cmp(&a.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user