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:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -406,6 +406,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
@@ -509,6 +510,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
|
||||
@@ -38,6 +38,13 @@ tar = "0.4"
|
||||
# (PLAN.md §3), which makes this load-bearing rather than a nicety.
|
||||
sha2 = "0.11"
|
||||
|
||||
# SHA1 is here for one reason only: a patch's `index <old>..<new>` line carries
|
||||
# git blob hashes, and reproducing one is how the patch tier answers rung 1 —
|
||||
# "is this whole file still stock?" (PLAN.md §2.2.1). It is never used as a
|
||||
# security primitive. Computing it natively is what keeps `git` off the shard
|
||||
# host, which is the whole point of shipping the plugin as a release tarball.
|
||||
sha1 = "0.11"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
16
src/cli.rs
16
src/cli.rs
@@ -2,20 +2,20 @@
|
||||
//!
|
||||
//! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and
|
||||
//! fixes every command and flag an operator can type. This module parses that surface *whole*, even
|
||||
//! though Phase 1 implements only part of it — a parser written once against the published contract
|
||||
//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this
|
||||
//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it
|
||||
//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO
|
||||
//! files were touched when nothing was.
|
||||
//! where a later phase implements it — a parser written once against the published contract cannot
|
||||
//! drift from it, and a flag belonging to an unbuilt phase gets an explicit notice at the point
|
||||
//! where it would have taken effect (see `install.rs`). The tri-state on `--patches` is the part
|
||||
//! that carries weight: "not mentioned" has to stay distinguishable from "explicitly declined",
|
||||
//! because only the first may prompt and only an explicit yes may edit a stock ServUO file.
|
||||
//!
|
||||
//! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand
|
||||
//! trees. A parsing crate would be larger than the code it replaced.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// The verb. `Install` is the only one Phase 1 implements; the rest parse so that running them
|
||||
/// reports which phase they arrive in rather than "unrecognized argument", which would read as a
|
||||
/// typo rather than as an unfinished tool.
|
||||
/// The verb. `Install` is the only one built so far; the rest parse so that running them reports
|
||||
/// which phase they arrive in rather than "unrecognized argument", which would read as a typo
|
||||
/// rather than as an unfinished tool.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Install,
|
||||
|
||||
591
src/diff.rs
Normal file
591
src/diff.rs
Normal file
@@ -0,0 +1,591 @@
|
||||
//! Unified-diff parsing.
|
||||
//!
|
||||
//! The patch tier resolves a file through the rung ladder of `docs/installer/PLAN.md` §2.2.1, and
|
||||
//! every rung is answered from the diff itself: a unified diff already carries the stock text of
|
||||
//! each region it edits — the context lines plus the `-` lines **are** the pre-image, and the
|
||||
//! context lines plus the `+` lines are the post-image. Nothing else has to be shipped alongside
|
||||
//! the patch for the installer to know what it is looking for.
|
||||
//!
|
||||
//! Three properties of this parser are load-bearing rather than tidiness:
|
||||
//!
|
||||
//! - **Everything is bytes, never `String`.** The three ServUO files the tier edits are CRLF and
|
||||
//! are not guaranteed to be UTF-8; a lossy decode would corrupt bytes on write-back, and a strict
|
||||
//! one would refuse to patch a shard over a stray `0x92` in a comment. Line content is compared
|
||||
//! after normalization but written back verbatim.
|
||||
//! - **The `index` line is optional and its absence is not a defect.** `commandlogging-event.patch`
|
||||
//! is a plain `---`/`+++` diff with no `diff --git` header at all, so rung 1 (whole-file
|
||||
//! pre-image hash) is structurally unavailable for it. That is fine: rung 2 matches on content
|
||||
//! and is the stronger guarantee anyway. A parser that required the header would have rejected a
|
||||
//! patch we ship.
|
||||
//! - **Hunk line counts are checked against the lines actually present.** A truncated or
|
||||
//! hand-edited diff whose `@@` header promises more lines than it carries would otherwise
|
||||
//! reconstruct a short pre-image, which is exactly the sort of thing that then matches somewhere
|
||||
//! it should not.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// One line inside a hunk body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HunkLine {
|
||||
/// ` ` — present on both sides.
|
||||
Context(Vec<u8>),
|
||||
/// `-` — present in the stock file only.
|
||||
Removed(Vec<u8>),
|
||||
/// `+` — present in the patched file only.
|
||||
Added(Vec<u8>),
|
||||
}
|
||||
|
||||
impl HunkLine {
|
||||
fn content(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Context(b) | Self::Removed(b) | Self::Added(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Hunk {
|
||||
/// 1-based line number in the stock file, from the `@@` header. **Advisory only** — the match
|
||||
/// is made by content, since an insertion anywhere above shifts every number below it. It is
|
||||
/// used to prefer the nearest candidate when reporting, and nowhere else.
|
||||
pub old_start: usize,
|
||||
pub old_count: usize,
|
||||
pub new_start: usize,
|
||||
pub new_count: usize,
|
||||
pub lines: Vec<HunkLine>,
|
||||
/// The stock file's last line has no trailing newline (`\ No newline at end of file` after a
|
||||
/// `-` or context line).
|
||||
pub old_no_newline: bool,
|
||||
/// Likewise for the patched file.
|
||||
pub new_no_newline: bool,
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
/// The stock text this hunk expects to find: context + removed, in order.
|
||||
pub fn pre_image(&self) -> Vec<&[u8]> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter(|l| !matches!(l, HunkLine::Added(_)))
|
||||
.map(HunkLine::content)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text this hunk leaves behind: context + added, in order.
|
||||
pub fn post_image(&self) -> Vec<&[u8]> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter(|l| !matches!(l, HunkLine::Removed(_)))
|
||||
.map(HunkLine::content)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether this hunk changes anything. A hunk of pure context is a no-op and must not be
|
||||
/// counted as applied work — nor searched for, since its pre- and post-images are identical
|
||||
/// and rung 0 could never be distinguished from rung 2.
|
||||
pub fn is_noop(&self) -> bool {
|
||||
!self
|
||||
.lines
|
||||
.iter()
|
||||
.any(|l| matches!(l, HunkLine::Added(_) | HunkLine::Removed(_)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One file section of a patch. Our patches carry one each, but a diff may hold several and
|
||||
/// silently applying the first would be a quiet way to half-patch a tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FilePatch {
|
||||
/// From `+++ b/<path>`, with the `b/` prefix stripped and separators left as `/`.
|
||||
pub path: String,
|
||||
/// The abbreviated blob hash of the stock file, from `index <old>..<new>`. `None` when the
|
||||
/// diff has no `index` line, which makes rung 1 unavailable for this file — see the module
|
||||
/// docs.
|
||||
pub pre_blob: Option<String>,
|
||||
pub post_blob: Option<String>,
|
||||
pub hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Patch {
|
||||
pub files: Vec<FilePatch>,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
/// The single file this patch edits.
|
||||
///
|
||||
/// The tier declares one target per patch (`tier.json`), so a diff that turned out to edit two
|
||||
/// files would mean the declaration and the artifact disagree — and the installer would have
|
||||
/// checked only one of them against the rung ladder before writing both.
|
||||
pub fn single_file(&self) -> Result<&FilePatch> {
|
||||
match self.files.as_slice() {
|
||||
[only] => Ok(only),
|
||||
[] => bail!("this patch contains no file sections"),
|
||||
many => bail!(
|
||||
"this patch edits {} files ({}); the tier declares one target per patch",
|
||||
many.len(),
|
||||
many.iter()
|
||||
.map(|f| f.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a buffer into lines **without** their terminators, tolerating CRLF, LF and a final line
|
||||
/// with no terminator at all.
|
||||
///
|
||||
/// A trailing newline does not produce a final empty line: `b"a\n"` is one line, matching how every
|
||||
/// diff tool counts them.
|
||||
pub fn split_lines(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0usize;
|
||||
for (i, b) in data.iter().enumerate() {
|
||||
if *b == b'\n' {
|
||||
let mut end = i;
|
||||
if end > start && data[end - 1] == b'\r' {
|
||||
end -= 1;
|
||||
}
|
||||
out.push(&data[start..end]);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if start < data.len() {
|
||||
let mut end = data.len();
|
||||
if end > start && data[end - 1] == b'\r' {
|
||||
end -= 1;
|
||||
}
|
||||
out.push(&data[start..end]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The comparison form of a line: trailing whitespace removed.
|
||||
///
|
||||
/// This is the *whole* of the licence PLAN.md §2.2.1 grants — line-ending and trailing-whitespace
|
||||
/// normalization, nothing else. There is no fuzz and no context reduction: dropping context to
|
||||
/// force a match is precisely how a hunk lands in the wrong method. Line endings are already gone
|
||||
/// by the time this runs, since [`split_lines`] strips them.
|
||||
pub fn normalize(line: &[u8]) -> &[u8] {
|
||||
let mut end = line.len();
|
||||
while end > 0 && (line[end - 1] == b' ' || line[end - 1] == b'\t' || line[end - 1] == b'\r') {
|
||||
end -= 1;
|
||||
}
|
||||
&line[..end]
|
||||
}
|
||||
|
||||
/// Parses a `.patch` file.
|
||||
pub fn parse(data: &[u8]) -> Result<Patch> {
|
||||
let lines = split_lines(data);
|
||||
let mut files: Vec<FilePatch> = Vec::new();
|
||||
let mut pending_blobs: Option<(String, String)> = None;
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
|
||||
if line.starts_with(b"index ") {
|
||||
// `index <old>..<new>[ <mode>]`. Abbreviated to 7+ hex characters by git, so rung 1
|
||||
// compares by prefix rather than for equality.
|
||||
pending_blobs = parse_index(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with(b"--- ") && i + 1 < lines.len() && lines[i + 1].starts_with(b"+++ ") {
|
||||
let path = header_path(lines[i + 1], b"+++ ")
|
||||
.or_else(|| header_path(lines[i], b"--- "))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"cannot read the target path from the diff header at line {}",
|
||||
i + 2
|
||||
)
|
||||
})?;
|
||||
let (pre_blob, post_blob) = match pending_blobs.take() {
|
||||
Some((a, b)) => (Some(a), Some(b)),
|
||||
None => (None, None),
|
||||
};
|
||||
i += 2;
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
while i < lines.len() && lines[i].starts_with(b"@@") {
|
||||
let (hunk, next) = parse_hunk(&lines, i)?;
|
||||
hunks.push(hunk);
|
||||
i = next;
|
||||
}
|
||||
if hunks.is_empty() {
|
||||
bail!("the diff section for {path} contains no hunks");
|
||||
}
|
||||
files.push(FilePatch {
|
||||
path,
|
||||
pre_blob,
|
||||
post_blob,
|
||||
hunks,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// `diff --git`, `new file mode`, `similarity index`, a covering-letter preamble — anything
|
||||
// outside a hunk body is skipped. Only `index` is worth keeping.
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
bail!("no unified-diff sections found — this file is not a patch");
|
||||
}
|
||||
Ok(Patch { files })
|
||||
}
|
||||
|
||||
/// `--- a/Scripts/Commands/Logging.cs` → `Scripts/Commands/Logging.cs`.
|
||||
///
|
||||
/// The trailing tab-separated timestamp some tools append is dropped, and so is the one-letter
|
||||
/// prefix directory git uses. `/dev/null` yields `None`, which makes a pure-creation diff fall back
|
||||
/// to the other header rather than producing a file called `dev/null`.
|
||||
fn header_path(line: &[u8], marker: &[u8]) -> Option<String> {
|
||||
let rest = line.strip_prefix(marker)?;
|
||||
let rest = match rest.iter().position(|b| *b == b'\t') {
|
||||
Some(tab) => &rest[..tab],
|
||||
None => rest,
|
||||
};
|
||||
let text = String::from_utf8_lossy(rest).trim().replace('\\', "/");
|
||||
if text.is_empty() || text == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
// git writes `a/` and `b/`; `-p1` semantics. A path with no prefix (`patch -p0` style) is left
|
||||
// alone rather than having its first directory eaten.
|
||||
for prefix in ["a/", "b/", "i/", "w/", "c/", "o/"] {
|
||||
if let Some(stripped) = text.strip_prefix(prefix) {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn parse_index(line: &[u8]) -> Option<(String, String)> {
|
||||
let rest = String::from_utf8_lossy(line.strip_prefix(b"index ")?).to_string();
|
||||
let head = rest.split_whitespace().next()?;
|
||||
let (old, new) = head.split_once("..")?;
|
||||
let hex = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit());
|
||||
if !hex(old) || !hex(new) {
|
||||
return None;
|
||||
}
|
||||
Some((old.to_ascii_lowercase(), new.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Parses one hunk, starting at the `@@` header. Returns the hunk and the index of the line after
|
||||
/// it.
|
||||
fn parse_hunk(lines: &[&[u8]], start: usize) -> Result<(Hunk, usize)> {
|
||||
let header = String::from_utf8_lossy(lines[start]).to_string();
|
||||
let (old_start, old_count, new_start, new_count) = parse_hunk_header(&header)
|
||||
.with_context(|| format!("cannot parse hunk header at line {}: {header}", start + 1))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
let mut old_no_newline = false;
|
||||
let mut new_no_newline = false;
|
||||
let mut seen_old = 0usize;
|
||||
let mut seen_new = 0usize;
|
||||
let mut i = start + 1;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
// The marker describes the line *above* it, and which side it applies to depends on that
|
||||
// line's kind: a `-` line means the stock file ended there, a `+` line the patched one, and
|
||||
// a context line both.
|
||||
if line.starts_with(b"\\ ") {
|
||||
match body.last() {
|
||||
Some(HunkLine::Removed(_)) => old_no_newline = true,
|
||||
Some(HunkLine::Added(_)) => new_no_newline = true,
|
||||
Some(HunkLine::Context(_)) => {
|
||||
old_no_newline = true;
|
||||
new_no_newline = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if seen_old >= old_count && seen_new >= new_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let (kind, rest) = match line.first() {
|
||||
Some(b' ') => (0u8, &line[1..]),
|
||||
Some(b'-') => (1, &line[1..]),
|
||||
Some(b'+') => (2, &line[1..]),
|
||||
// git emits a genuinely empty line for an empty context line rather than a lone space,
|
||||
// and trailing whitespace is routinely stripped in transit. Treating it as context is
|
||||
// what every patch tool does.
|
||||
None => (0, line),
|
||||
// Anything else ends the hunk — the next `@@`, `diff --git`, or trailing prose.
|
||||
Some(_) => break,
|
||||
};
|
||||
|
||||
match kind {
|
||||
0 => {
|
||||
seen_old += 1;
|
||||
seen_new += 1;
|
||||
body.push(HunkLine::Context(rest.to_vec()));
|
||||
}
|
||||
1 => {
|
||||
seen_old += 1;
|
||||
body.push(HunkLine::Removed(rest.to_vec()));
|
||||
}
|
||||
_ => {
|
||||
seen_new += 1;
|
||||
body.push(HunkLine::Added(rest.to_vec()));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// A header that promises more than the body delivers reconstructs a short pre-image, which is
|
||||
// then liable to match a place the author never meant. Refusing is the only safe reading.
|
||||
if seen_old != old_count || seen_new != new_count {
|
||||
bail!(
|
||||
"hunk at line {} declares -{old_start},{old_count} +{new_start},{new_count} but \
|
||||
carries {seen_old} old and {seen_new} new lines — the patch is truncated or malformed",
|
||||
start + 1
|
||||
);
|
||||
}
|
||||
|
||||
Ok((
|
||||
Hunk {
|
||||
old_start,
|
||||
old_count,
|
||||
new_start,
|
||||
new_count,
|
||||
lines: body,
|
||||
old_no_newline,
|
||||
new_no_newline,
|
||||
},
|
||||
i,
|
||||
))
|
||||
}
|
||||
|
||||
/// `@@ -75,16 +75,27 @@ optional section heading` → `(75, 16, 75, 27)`.
|
||||
///
|
||||
/// A count may be omitted, which means 1 (`@@ -75 +75,2 @@`), and a count of 0 is legal for a pure
|
||||
/// insertion or deletion.
|
||||
fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
|
||||
let inner = header.strip_prefix("@@")?;
|
||||
let end = inner.find("@@")?;
|
||||
let mut parts = inner[..end].split_whitespace();
|
||||
let old = parts.next()?.strip_prefix('-')?;
|
||||
let new = parts.next()?.strip_prefix('+')?;
|
||||
|
||||
let range = |s: &str| -> Option<(usize, usize)> {
|
||||
match s.split_once(',') {
|
||||
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
|
||||
None => Some((s.parse().ok()?, 1)),
|
||||
}
|
||||
};
|
||||
let (old_start, old_count) = range(old)?;
|
||||
let (new_start, new_count) = range(new)?;
|
||||
Some((old_start, old_count, new_start, new_count))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_str(text: &str) -> Result<Patch> {
|
||||
parse(text.as_bytes())
|
||||
}
|
||||
|
||||
const SIMPLE: &str = "\
|
||||
diff --git a/Scripts/Commands/Logging.cs b/Scripts/Commands/Logging.cs
|
||||
index 5dd3f54..9ab1c22 100644
|
||||
--- a/Scripts/Commands/Logging.cs
|
||||
+++ b/Scripts/Commands/Logging.cs
|
||||
@@ -75,4 +75,6 @@ namespace Server.Commands
|
||||
return o;
|
||||
}
|
||||
|
||||
+ public static event Action<Mobile, string> OnWrite;
|
||||
+
|
||||
public static void WriteLine(Mobile from, string text)
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_git_format_patch_parses_whole() {
|
||||
let patch = parse_str(SIMPLE).unwrap();
|
||||
let file = patch.single_file().unwrap();
|
||||
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
|
||||
assert_eq!(file.pre_blob.as_deref(), Some("5dd3f54"));
|
||||
assert_eq!(file.post_blob.as_deref(), Some("9ab1c22"));
|
||||
assert_eq!(file.hunks.len(), 1);
|
||||
|
||||
let hunk = &file.hunks[0];
|
||||
assert_eq!((hunk.old_start, hunk.old_count), (75, 4));
|
||||
assert_eq!((hunk.new_start, hunk.new_count), (75, 6));
|
||||
assert_eq!(hunk.pre_image().len(), 4);
|
||||
assert_eq!(hunk.post_image().len(), 6);
|
||||
assert!(!hunk.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_diff_without_a_git_header_parses_and_offers_no_blob() {
|
||||
// commandlogging-event.patch is exactly this shape. Rung 1 is unavailable for it, which is
|
||||
// a fact to report — not a parse error, and certainly not a reason to skip the patch.
|
||||
let text = SIMPLE
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("diff --git") && !l.starts_with("index "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let patch = parse_str(&text).unwrap();
|
||||
let file = patch.single_file().unwrap();
|
||||
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
|
||||
assert_eq!(file.pre_blob, None);
|
||||
assert_eq!(file.hunks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crlf_patch_yields_the_same_lines_as_an_lf_one() {
|
||||
// Every .patch this tier ships is CRLF in a Windows checkout and LF in the tarball CI
|
||||
// builds. The two must parse identically or a patch would apply on one platform and not
|
||||
// the other.
|
||||
let crlf = SIMPLE.replace('\n', "\r\n");
|
||||
assert_eq!(parse_str(&crlf).unwrap(), parse_str(SIMPLE).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_and_post_images_are_the_two_sides_of_the_hunk() {
|
||||
let patch = parse_str(
|
||||
"\
|
||||
--- a/x
|
||||
+++ b/x
|
||||
@@ -1,3 +1,3 @@
|
||||
keep
|
||||
-old
|
||||
+new
|
||||
tail
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let hunk = &patch.single_file().unwrap().hunks[0];
|
||||
assert_eq!(hunk.pre_image(), vec![&b"keep"[..], b"old", b"tail"]);
|
||||
assert_eq!(hunk.post_image(), vec![&b"keep"[..], b"new", b"tail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_no_newline_marker_attaches_to_the_side_it_describes() {
|
||||
// Three cases, because the marker follows the line it describes and the side depends on
|
||||
// that line's kind. Getting this wrong writes a spurious trailing newline into a file that
|
||||
// never had one — a one-byte change that shows up in every future hash comparison.
|
||||
let old_only = parse_str(
|
||||
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n",
|
||||
)
|
||||
.unwrap();
|
||||
let hunk = &old_only.single_file().unwrap().hunks[0];
|
||||
assert!(hunk.old_no_newline && !hunk.new_no_newline);
|
||||
|
||||
let new_only = parse_str(
|
||||
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n+new\n\\ No newline at end of file\n",
|
||||
)
|
||||
.unwrap();
|
||||
let hunk = &new_only.single_file().unwrap().hunks[0];
|
||||
assert!(!hunk.old_no_newline && hunk.new_no_newline);
|
||||
|
||||
let both = parse_str(
|
||||
"--- a/x\n+++ b/x\n@@ -1,1 +1,2 @@\n+added\n same\n\\ No newline at end of file\n",
|
||||
)
|
||||
.unwrap();
|
||||
let hunk = &both.single_file().unwrap().hunks[0];
|
||||
assert!(hunk.old_no_newline && hunk.new_no_newline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_hunks_and_several_files_are_all_kept() {
|
||||
let patch = parse_str(
|
||||
"\
|
||||
diff --git a/one b/one
|
||||
index aaaaaaa..bbbbbbb 100644
|
||||
--- a/one
|
||||
+++ b/one
|
||||
@@ -1,1 +1,2 @@
|
||||
a
|
||||
+b
|
||||
@@ -10,1 +11,2 @@
|
||||
c
|
||||
+d
|
||||
diff --git a/two b/two
|
||||
--- a/two
|
||||
+++ b/two
|
||||
@@ -5,1 +5,2 @@
|
||||
e
|
||||
+f
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(patch.files.len(), 2);
|
||||
assert_eq!(patch.files[0].hunks.len(), 2);
|
||||
assert_eq!(patch.files[1].hunks.len(), 1);
|
||||
// The second file has no index line of its own and must not inherit the first's.
|
||||
assert_eq!(patch.files[1].pre_blob, None);
|
||||
|
||||
// And a multi-file patch is refused where the tier expects one target, rather than being
|
||||
// silently half-applied.
|
||||
let err = patch.single_file().unwrap_err().to_string();
|
||||
assert!(err.contains("edits 2 files"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_omitted_count_means_one() {
|
||||
let patch = parse_str("--- a/x\n+++ b/x\n@@ -7 +7,2 @@\n a\n+b\n").unwrap();
|
||||
let hunk = &patch.single_file().unwrap().hunks[0];
|
||||
assert_eq!((hunk.old_start, hunk.old_count), (7, 1));
|
||||
assert_eq!((hunk.new_start, hunk.new_count), (7, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_hunk_is_rejected() {
|
||||
// The header promises four old lines; two are present. Reconstructing the short pre-image
|
||||
// and hunting for it is how a patch lands somewhere nobody intended.
|
||||
let err = parse_str("--- a/x\n+++ b/x\n@@ -1,4 +1,4 @@\n a\n b\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("truncated or malformed"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_a_patch_is_rejected() {
|
||||
assert!(parse_str("# patches\n\nUnified diffs against stock ServUO.\n").is_err());
|
||||
assert!(parse_str("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_splitting_agrees_with_how_diffs_count_lines() {
|
||||
assert_eq!(split_lines(b"a\nb\n"), vec![&b"a"[..], b"b"]);
|
||||
assert_eq!(split_lines(b"a\r\nb"), vec![&b"a"[..], b"b"]);
|
||||
assert_eq!(split_lines(b""), Vec::<&[u8]>::new());
|
||||
assert_eq!(split_lines(b"\n"), vec![&b""[..]]);
|
||||
assert_eq!(split_lines(b"a"), vec![&b"a"[..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_covers_line_endings_and_trailing_space_and_nothing_else() {
|
||||
assert_eq!(normalize(b"code \t"), b"code");
|
||||
assert_eq!(normalize(b"code\r"), b"code");
|
||||
// Leading indentation is content: two methods differing only in nesting are different
|
||||
// places, and collapsing them is how an anchor becomes ambiguous.
|
||||
assert_ne!(normalize(b" code"), normalize(b"code"));
|
||||
assert_eq!(normalize(b" "), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pure_context_hunk_is_a_noop() {
|
||||
let patch = parse_str("--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n b\n").unwrap();
|
||||
assert!(patch.single_file().unwrap().hunks[0].is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_context_line_written_without_its_space_is_still_context() {
|
||||
// Mailers and editors strip the trailing space off a blank context line routinely, and
|
||||
// every patch tool tolerates it. Both spellings must produce the same pre-image.
|
||||
let padded = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n \n b\n+c\n").unwrap();
|
||||
let bare = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n\n b\n+c\n").unwrap();
|
||||
assert_eq!(
|
||||
padded.single_file().unwrap().hunks[0].pre_image(),
|
||||
bare.single_file().unwrap().hunks[0].pre_image()
|
||||
);
|
||||
}
|
||||
}
|
||||
144
src/install.rs
144
src/install.rs
@@ -1,19 +1,17 @@
|
||||
//! The `install` command.
|
||||
//!
|
||||
//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
||||
//! the overlay, install the sidecar and register its service, record what was deployed, and print
|
||||
//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so
|
||||
//! in as many words rather than ending on a success line that would read as a finished install. An
|
||||
//! operator who cannot tell which half ran is the failure this whole tool exists to remove.
|
||||
//! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
||||
//! the overlay, run the optional patch tier, install the sidecar and register its service, record
|
||||
//! what was deployed, and print the values the website needs.
|
||||
//!
|
||||
//! The order of the run is not incidental:
|
||||
//!
|
||||
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
|
||||
//! platform, the ServUO root, and whether this process can write where it must. A run that
|
||||
//! cannot finish should end before a single file enters the ServUO tree.
|
||||
//! 2. **Overlay, then sidecar.** The sidecar is what the shard dials out to, but the shard is
|
||||
//! stopped throughout; deploying code the shard will compile is the step with a running-process
|
||||
//! hazard attached, so it happens while the check that guards it is freshest.
|
||||
//! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree
|
||||
//! happens together, on the near side of the running-shard check that guards it — and the tier
|
||||
//! comes second because a feature's companion `.cs` lands in a directory the overlay creates.
|
||||
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
|
||||
//! the file the service definition points at, so the service is never started against a config
|
||||
//! that does not exist yet.
|
||||
@@ -24,14 +22,14 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::cli::{Cli, PatchChoice};
|
||||
use crate::cli::Cli;
|
||||
use crate::record::{
|
||||
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
|
||||
ServUoRef, ServiceRecord, SCHEMA,
|
||||
};
|
||||
use crate::servuo::ServUoRoot;
|
||||
use crate::util::TempDir;
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, ui};
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<()> {
|
||||
let layout = paths::layout();
|
||||
@@ -192,8 +190,21 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// ── What this build does not do ──────────────────────────────────────────
|
||||
report_patch_tier(cli);
|
||||
// ── The patch tier ───────────────────────────────────────────────────────
|
||||
// After the overlay, because a feature's companion `.cs` lands in the directory the overlay
|
||||
// creates, and before the sidecar, because everything that edits the ServUO tree belongs on the
|
||||
// near side of the running-shard check that guards it.
|
||||
let tier = tier::run(
|
||||
cli,
|
||||
&root,
|
||||
&unpacked,
|
||||
manifest.patch_tier.as_ref(),
|
||||
&layout,
|
||||
&prior
|
||||
.as_ref()
|
||||
.map(|p| p.patch_records())
|
||||
.unwrap_or_default(),
|
||||
)?;
|
||||
|
||||
// ── The sidecar and its service ──────────────────────────────────────────
|
||||
let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?;
|
||||
@@ -201,12 +212,16 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
// ── Record ───────────────────────────────────────────────────────────────
|
||||
let record = build_record(
|
||||
prior.as_ref(),
|
||||
&bundle,
|
||||
&bundle_url,
|
||||
&root,
|
||||
&manifest,
|
||||
&planned,
|
||||
sidecar.as_ref(),
|
||||
&Deployment {
|
||||
bundle: &bundle,
|
||||
bundle_url: &bundle_url,
|
||||
root: &root,
|
||||
manifest: &manifest,
|
||||
planned: &planned,
|
||||
sidecar: sidecar.as_ref(),
|
||||
tier: &tier,
|
||||
verify: cli.verify,
|
||||
},
|
||||
);
|
||||
|
||||
if cli.verify {
|
||||
@@ -232,7 +247,17 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
|
||||
// ── Closing notes ────────────────────────────────────────────────────────
|
||||
println!();
|
||||
if summary.writes_anything() && !cli.verify {
|
||||
if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
} else if summary.writes_anything() || tier.core_rebuild {
|
||||
if tier.core_rebuild {
|
||||
// Said again here, after everything else, because it is the one step whose omission
|
||||
// produces a shard that boots perfectly and never emits the events it was patched for.
|
||||
println!(
|
||||
"A CORE ServUO file was patched — rebuild the solution before starting:\n \
|
||||
dotnet build ServUO.sln\n"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\
|
||||
Start your shard when ready; the installer does not start it for you.\n\
|
||||
@@ -240,8 +265,6 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \
|
||||
run `[bridge status` in game (INSTALL.md §6)."
|
||||
);
|
||||
} else if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
} else {
|
||||
println!("The ServUO tree already has this overlay — nothing was changed there.");
|
||||
}
|
||||
@@ -306,32 +329,6 @@ fn prior_overlay_files<'a>(
|
||||
prior.overlay_files()
|
||||
}
|
||||
|
||||
fn report_patch_tier(cli: &Cli) {
|
||||
println!();
|
||||
match cli.patches {
|
||||
// --patches must never pass silently: an operator who asked for the tier and got a clean
|
||||
// run would reasonably conclude that EventSink.cs had been patched.
|
||||
PatchChoice::Yes => {
|
||||
ui::warn(
|
||||
"Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \
|
||||
build (Phase 3).\n \
|
||||
No stock ServUO file has been touched. Apply the patches by hand if you need \
|
||||
them: INSTALL.md Appendix A2.",
|
||||
);
|
||||
}
|
||||
PatchChoice::No => {
|
||||
ui::row("Patch tier", "skipped (--no-patches)");
|
||||
}
|
||||
PatchChoice::Ask => {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"skipped (not implemented in this build — Phase 3)",
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
||||
}
|
||||
|
||||
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
|
||||
struct SidecarOutcome {
|
||||
record: LinkRecord,
|
||||
@@ -568,15 +565,36 @@ fn resolve_host(cli: &Cli) -> String {
|
||||
answer.unwrap_or(detected)
|
||||
}
|
||||
|
||||
fn build_record(
|
||||
prior: Option<&InstallRecord>,
|
||||
bundle: &bundle::Bundle,
|
||||
bundle_url: &str,
|
||||
root: &ServUoRoot,
|
||||
manifest: &overlay::Manifest,
|
||||
planned: &[overlay::PlannedFile],
|
||||
sidecar: Option<&SidecarOutcome>,
|
||||
) -> InstallRecord {
|
||||
/// Everything one run produced, gathered so the record can be built from a single value.
|
||||
///
|
||||
/// The three halves are assembled at different points and the record needs all of them, which is
|
||||
/// how this grew a parameter per step. A struct keeps the call site readable and, more usefully,
|
||||
/// makes it obvious at a glance that nothing else feeds `install.json`.
|
||||
struct Deployment<'a> {
|
||||
bundle: &'a bundle::Bundle,
|
||||
bundle_url: &'a str,
|
||||
root: &'a ServUoRoot,
|
||||
manifest: &'a overlay::Manifest,
|
||||
planned: &'a [overlay::PlannedFile],
|
||||
sidecar: Option<&'a SidecarOutcome>,
|
||||
tier: &'a tier::Outcome,
|
||||
/// A dry run reports everything and records nothing, so every "carry the previous value
|
||||
/// through" branch below turns on it.
|
||||
verify: bool,
|
||||
}
|
||||
|
||||
fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord {
|
||||
let Deployment {
|
||||
bundle,
|
||||
bundle_url,
|
||||
root,
|
||||
manifest,
|
||||
planned,
|
||||
sidecar,
|
||||
tier,
|
||||
verify,
|
||||
} = *run;
|
||||
|
||||
InstallRecord {
|
||||
schema: SCHEMA,
|
||||
installer: InstallerInfo {
|
||||
@@ -608,7 +626,19 @@ fn build_record(
|
||||
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
|
||||
None => prior.and_then(|p| p.link.clone()),
|
||||
},
|
||||
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||
// The tier's own records replace the section only when it actually ran and wrote. A
|
||||
// declined tier, a refused one, and a `--verify` dry run all leave the previous record
|
||||
// exactly as it was — an install where the operator said "not this time" must not erase
|
||||
// the evidence of patches applied on an earlier one.
|
||||
patches: match (tier.ran && !verify, prior) {
|
||||
(true, _) => tier
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|r| serde_json::to_value(r).ok())
|
||||
.collect(),
|
||||
(false, Some(previous)) => previous.patches.clone(),
|
||||
(false, None) => Vec::new(),
|
||||
},
|
||||
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||
//! existed, is `docs/installer/INSTALL.md`.
|
||||
//!
|
||||
//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation,
|
||||
//! the overlay sync, `install.json`, the uo-link sidecar and its service, and the token handoff.
|
||||
//! The patch tier (Phase 3) and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||
//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation,
|
||||
//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service,
|
||||
//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and
|
||||
//! each of them says so when reached rather than failing as though it were a typo.
|
||||
//!
|
||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||
@@ -28,14 +28,17 @@
|
||||
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod diff;
|
||||
pub mod install;
|
||||
pub mod net;
|
||||
pub mod overlay;
|
||||
pub mod patch;
|
||||
pub mod paths;
|
||||
pub mod record;
|
||||
pub mod service;
|
||||
pub mod servuo;
|
||||
pub mod sidecar;
|
||||
pub mod tier;
|
||||
pub mod ui;
|
||||
pub mod util;
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ pub struct Manifest {
|
||||
/// boots — which is why it is checked against the bundle before anything is written.
|
||||
pub protocol: u32,
|
||||
pub servuo: ServUoCompat,
|
||||
/// The patch tier this release ships, generated from `servuo-plugins/patches/tier.json`
|
||||
/// (PLAN.md §2.2). `None` for a release that predates the declaration — see
|
||||
/// [`crate::patch::Tier::resolve`], which substitutes a built-in description rather than
|
||||
/// leaving the tier silently empty.
|
||||
#[serde(default)]
|
||||
pub patch_tier: Option<crate::patch::Tier>,
|
||||
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
|
||||
pub files: BTreeMap<String, String>,
|
||||
}
|
||||
@@ -674,6 +680,7 @@ mod tests {
|
||||
min_version: "57.4".into(),
|
||||
patches_verified_against: "57.4".into(),
|
||||
},
|
||||
patch_tier: None,
|
||||
files: BTreeMap::from([(
|
||||
cfg_rel.to_string(),
|
||||
sha256_file(&fx.overlay_dir.join(cfg_rel)).unwrap(),
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
21
src/paths.rs
21
src/paths.rs
@@ -47,6 +47,27 @@ impl Layout {
|
||||
self.data_dir.join("uo-link.db")
|
||||
}
|
||||
|
||||
/// `/etc/runicgateway/patches` — the cached patch set (INSTALL.md §3).
|
||||
///
|
||||
/// Every patch the tier *evaluated* is cached here, not only the ones that applied. `uninstall`
|
||||
/// needs the applied ones to print the exact hunks to revert long after the release tarball is
|
||||
/// gone (PLAN.md §5), and a refused one is the file the run just told the operator to apply by
|
||||
/// hand — pointing them at a path that only exists on success would be the less useful half.
|
||||
pub fn patches_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("patches")
|
||||
}
|
||||
|
||||
/// `/etc/runicgateway/patches/originals` — each patched file exactly as it was before the tier
|
||||
/// first touched it, mirroring its path in the ServUO tree.
|
||||
///
|
||||
/// The tier edits files the operator owns, so the pre-image is what turns "here are the hunks
|
||||
/// we added" into a revert anyone can verify. It lives here rather than beside the file it
|
||||
/// copies, because an installer-owned file inside the ServUO tree is one `uninstall` has
|
||||
/// promised never to clean up.
|
||||
pub fn patch_originals_dir(&self) -> PathBuf {
|
||||
self.patches_dir().join("originals")
|
||||
}
|
||||
|
||||
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
|
||||
/// layout, where no service is registered at all.
|
||||
pub fn systemd_unit(&self) -> PathBuf {
|
||||
|
||||
@@ -43,7 +43,13 @@ pub struct InstallRecord {
|
||||
/// in the other direction. Read it with [`InstallRecord::link_record`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<serde_json::Value>,
|
||||
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
||||
/// The patch tier: one entry per feature actually in place, with the rung that applied each of
|
||||
/// its patches (Phase 3).
|
||||
///
|
||||
/// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer
|
||||
/// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`].
|
||||
/// Only features that are *applied* appear: a declined or refused one left no trace in the
|
||||
/// tree, and recording it would make `doctor` and `uninstall` report work nobody did.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub patches: Vec<serde_json::Value>,
|
||||
/// Anything a newer installer wrote that this one has no name for.
|
||||
@@ -203,6 +209,19 @@ impl InstallRecord {
|
||||
pub fn link_record(&self) -> Option<LinkRecord> {
|
||||
serde_json::from_value(self.link.clone()?).ok()
|
||||
}
|
||||
|
||||
/// The patch-tier entries this build understands.
|
||||
///
|
||||
/// An entry it cannot parse is dropped from the returned list but still carried through on
|
||||
/// save, exactly as with [`Self::link_record`]. The consequence of a dropped entry is that this
|
||||
/// run re-derives that feature's state from the tree — which the rung ladder answers correctly
|
||||
/// on its own — rather than an older installer refusing to run on a newer host.
|
||||
pub fn patch_records(&self) -> Vec<crate::patch::FeatureRecord> {
|
||||
self.patches
|
||||
.iter()
|
||||
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_rfc3339() -> String {
|
||||
|
||||
894
src/tier.rs
Normal file
894
src/tier.rs
Normal file
@@ -0,0 +1,894 @@
|
||||
//! Running the patch tier as part of an `install`.
|
||||
//!
|
||||
//! [`crate::patch`] decides what may be written; this module decides whether it is offered at all,
|
||||
//! writes it, reports it, and records it. The split matters because the two halves fail
|
||||
//! differently: a wrong answer in `patch` corrupts a stock ServUO file, and a wrong answer here
|
||||
//! means an operator was never asked, or believes something was patched that was not.
|
||||
//!
|
||||
//! ## Consent (PLAN.md §2.2.2)
|
||||
//!
|
||||
//! The tier is opt-in on every tree, and on a tree that is not stock ServUO 57.4 it is opt-in
|
||||
//! *twice*:
|
||||
//!
|
||||
//! - The interactive prompt defaults to **no**, and on a non-57.4 tree prints an unmissable banner
|
||||
//! before it is even offered — that 57.4 is the only supported version, that the operator is on
|
||||
//! their own, and that a bad outcome may not surface until the shard is running.
|
||||
//! - `--patches` alone is **not** consent there. An unattended run must also pass
|
||||
//! `--patches-unsupported-servuo`, because a flag someone had to look up cannot be hit by
|
||||
//! accident in a script copied from somewhere else.
|
||||
//!
|
||||
//! Withholding that second flag **skips the tier loudly; it does not fail the run.** By the time
|
||||
//! this runs the overlay is deployed and the sidecar is about to be installed, and turning a
|
||||
//! completed base install into exit 1 over a tier that is documented as optional would cost the
|
||||
//! operator more than the tier is worth. Saying nothing would be the real failure, so the skip is
|
||||
//! reported at the point it happens and again in the closing summary.
|
||||
//!
|
||||
//! ## All-or-nothing, at two levels
|
||||
//!
|
||||
//! `patch::resolve` is all-or-nothing per patch file: if one hunk reaches rung 3, none of that
|
||||
//! patch's hunks are written. This module adds the second level — **per feature**. The two
|
||||
//! vendor-sale patches are one unit (`EventSink.cs` grows the event, `PlayerVendorGumps.cs` raises
|
||||
//! it, and the companion `BridgeVendorSale.cs` subscribes to it); applying either alone produces a
|
||||
//! tree that either does not compile or silently never emits. So every patch in a feature is
|
||||
//! resolved first, and nothing is written unless all of them can be.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{Cli, PatchChoice};
|
||||
use crate::patch::{
|
||||
self, AppliedPatch, CompanionRecord, Feature, FeatureRecord, Rebuild, Resolution, Rung, Tier,
|
||||
};
|
||||
use crate::servuo::{self, ServUoRoot};
|
||||
use crate::util::{sha256_file, write_atomic};
|
||||
use crate::{paths, ui};
|
||||
|
||||
/// What the tier did, for the closing summary and the record.
|
||||
pub struct Outcome {
|
||||
/// One entry per feature now in place. Written to `install.json`.
|
||||
pub records: Vec<FeatureRecord>,
|
||||
/// The tier ran (as opposed to being declined, skipped or unavailable).
|
||||
pub ran: bool,
|
||||
/// A feature whose patches touch a core file was applied, so the solution must be rebuilt —
|
||||
/// ServUO's dynamic script build is not enough and will not say so.
|
||||
pub core_rebuild: bool,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
fn skipped() -> Self {
|
||||
Self {
|
||||
records: Vec::new(),
|
||||
ran: false,
|
||||
core_rebuild: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether the tier runs, then runs it.
|
||||
///
|
||||
/// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than
|
||||
/// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
cli: &Cli,
|
||||
root: &ServUoRoot,
|
||||
unpacked: &Path,
|
||||
declared: Option<&Tier>,
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
) -> Result<Outcome> {
|
||||
let tier = Tier::resolve(declared);
|
||||
if tier.features.is_empty() {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"not offered — this overlay declares no patches",
|
||||
);
|
||||
return Ok(Outcome::skipped());
|
||||
}
|
||||
let supported = root.is_supported_version();
|
||||
|
||||
match consent(cli, root, supported, &tier)? {
|
||||
Consent::Yes => {}
|
||||
Consent::No(reason) => {
|
||||
ui::row("Patch tier", &reason);
|
||||
print_cost(&tier, prior);
|
||||
return Ok(Outcome::skipped());
|
||||
}
|
||||
Consent::RefusedUnsupported => {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"Patch tier REQUESTED BUT NOT RUN — this tree reports ServUO {}, and \
|
||||
{} is the\n only supported version. --patches on its own is not consent here.\n \
|
||||
No stock ServUO file has been touched.\n\n \
|
||||
To run it anyway, unsupported and untested, add --patches-unsupported-servuo.\n \
|
||||
Read INSTALL.md §4 first, and back up your ServUO tree.",
|
||||
root.version_display(),
|
||||
servuo::SUPPORTED_VERSION
|
||||
));
|
||||
print_cost(&tier, prior);
|
||||
return Ok(Outcome::skipped());
|
||||
}
|
||||
}
|
||||
|
||||
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
|
||||
}
|
||||
|
||||
enum Consent {
|
||||
Yes,
|
||||
/// Not selected, with the reason to print.
|
||||
No(String),
|
||||
/// Asked for on a tree where `--patches` alone is not enough.
|
||||
RefusedUnsupported,
|
||||
}
|
||||
|
||||
fn consent(cli: &Cli, root: &ServUoRoot, supported: bool, tier: &Tier) -> Result<Consent> {
|
||||
if cli.patches == PatchChoice::No {
|
||||
return Ok(Consent::No("skipped (--no-patches)".into()));
|
||||
}
|
||||
if cli.patches == PatchChoice::Yes {
|
||||
return Ok(if supported || cli.patches_unsupported_servuo {
|
||||
Consent::Yes
|
||||
} else {
|
||||
Consent::RefusedUnsupported
|
||||
});
|
||||
}
|
||||
|
||||
// PatchChoice::Ask — offer it.
|
||||
ui::heading("Patch tier (optional)");
|
||||
println!(
|
||||
" {} feature(s) need edits to stock ServUO files. Without them:",
|
||||
tier.features.len()
|
||||
);
|
||||
for feature in &tier.features {
|
||||
println!(" - {}", feature.summary);
|
||||
}
|
||||
if !supported {
|
||||
print_unsupported_banner(root);
|
||||
} else {
|
||||
println!(
|
||||
"\n Every patch is checked before anything is written, and any whose target lines are\n \
|
||||
no longer stock is reported for you to apply by hand rather than forced."
|
||||
);
|
||||
}
|
||||
|
||||
// The default is no on every tree (INSTALL.md §2). `--yes` takes that default, which makes an
|
||||
// unattended run that did not ask for the tier safely skip it.
|
||||
match ui::confirm("Apply the patch tier?", false, cli.assume_yes) {
|
||||
Ok(true) => Ok(Consent::Yes),
|
||||
Ok(false) => Ok(Consent::No("not selected".into())),
|
||||
// A piped run with no --yes cannot answer, and the base install has already succeeded.
|
||||
// Declining for it is the documented default, so this is a note rather than a failure.
|
||||
Err(_) => Ok(Consent::No(
|
||||
"not selected (no terminal to ask; pass --patches to enable)".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_unsupported_banner(root: &ServUoRoot) {
|
||||
println!();
|
||||
println!(" ┌──────────────────────────────────────────────────────────────────────────────┐");
|
||||
println!(" │ ⚠ UNSUPPORTED, UNTESTED, NOT GUARANTEED TO WORK │");
|
||||
println!(" └──────────────────────────────────────────────────────────────────────────────┘");
|
||||
println!(
|
||||
" This tree reports ServUO {}. Runic Gateway is designed, built and tested against\n \
|
||||
stock ServUO {} — that is the only supported version.",
|
||||
root.version_display(),
|
||||
servuo::SUPPORTED_VERSION
|
||||
);
|
||||
println!(
|
||||
"\n You may run the tier here. If you do, you are on your own: it is not covered by\n \
|
||||
support, and a bad outcome may not show up until your shard is live, because ServUO's\n \
|
||||
script build reports success even when it failed and quietly keeps running the previous\n \
|
||||
Scripts.dll."
|
||||
);
|
||||
println!(
|
||||
"\n A patch is still refused wherever the exact lines it edits have changed — but matching\n \
|
||||
text is not matching behaviour. A hunk can land correctly and still be wrong for a tree\n \
|
||||
that has diverged around it."
|
||||
);
|
||||
println!(
|
||||
"\n Back up your ServUO tree first, and verify your shard boots and compiles afterwards."
|
||||
);
|
||||
}
|
||||
|
||||
/// What declining costs — counting only the features that are not already in place.
|
||||
///
|
||||
/// A decline does not inspect the tree, so the previous run's record is the only evidence
|
||||
/// available. Ignoring it produced a run that skipped the tier and then announced the loss of two
|
||||
/// features `install.json` shows as applied, which is worse than saying nothing: an operator
|
||||
/// reading it would go looking for a problem that does not exist.
|
||||
fn print_cost(tier: &Tier, prior: &[FeatureRecord]) {
|
||||
let applied = patch::index_records(prior);
|
||||
let lost: Vec<&str> = tier
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| !applied.contains_key(f.name.as_str()))
|
||||
.map(|f| f.lost.as_str())
|
||||
.collect();
|
||||
|
||||
if lost.is_empty() && !applied.is_empty() {
|
||||
println!(" Everything this tier provides is already applied — nothing was changed.");
|
||||
} else {
|
||||
println!(" Without it: {}.", lost.join(", "));
|
||||
if !applied.is_empty() {
|
||||
println!(
|
||||
" ({} already applied by an earlier run and left in place.)",
|
||||
applied.keys().cloned().collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves and applies every feature.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_tier(
|
||||
cli: &Cli,
|
||||
root: &ServUoRoot,
|
||||
unpacked: &Path,
|
||||
tier: &Tier,
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
supported: bool,
|
||||
) -> Result<Outcome> {
|
||||
let previous = patch::index_records(prior);
|
||||
let mut records: Vec<FeatureRecord> = Vec::new();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut lost: Vec<&str> = Vec::new();
|
||||
let mut applied_patches = 0usize;
|
||||
let mut core_rebuild = false;
|
||||
let mut core_targets: Vec<String> = Vec::new();
|
||||
|
||||
for feature in &tier.features {
|
||||
let resolved = resolve_feature(root, unpacked, feature)?;
|
||||
let placeable = resolved.iter().all(|r| r.resolution.rung().is_some());
|
||||
|
||||
for r in &resolved {
|
||||
lines.push(render(feature, r, placeable, layout, cli.verify));
|
||||
}
|
||||
|
||||
// Every patch the tier *evaluated* is cached, applied or not. `uninstall` needs the applied
|
||||
// ones to print the exact hunks to revert long after the release tarball is gone (PLAN.md
|
||||
// §5) — and a refused one is the file this run has just told the operator to apply by hand,
|
||||
// so pointing them at a path that only exists on success would be the less useful half.
|
||||
if !cli.verify {
|
||||
cache_patches(&resolved, layout)?;
|
||||
}
|
||||
|
||||
if !placeable {
|
||||
lost.push(&feature.lost);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !cli.verify {
|
||||
write_feature(root, unpacked, feature, &resolved, layout)?;
|
||||
}
|
||||
applied_patches += resolved.len();
|
||||
|
||||
// The rebuild warning is about files this run *edited*, not about every file the feature
|
||||
// covers. A feature whose patches were all already present changed nothing, so telling the
|
||||
// operator to rebuild the core would be noise — and on a re-run, noise that recurs forever.
|
||||
// The list names only the files actually written, since a core feature can also carry
|
||||
// patches against Scripts files, and calling one of those a core file is simply wrong.
|
||||
let written: Vec<String> = resolved
|
||||
.iter()
|
||||
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
||||
.map(|r| r.target.clone())
|
||||
.collect();
|
||||
if feature.rebuild == Rebuild::Core && !written.is_empty() {
|
||||
core_rebuild = true;
|
||||
core_targets.extend(written);
|
||||
}
|
||||
records.push(record_for(
|
||||
feature,
|
||||
&resolved,
|
||||
root,
|
||||
supported,
|
||||
previous.get(feature.name.as_str()).copied(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────────
|
||||
println!();
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
&format!(
|
||||
"{applied_patches} of {} {}",
|
||||
tier.patch_count(),
|
||||
if cli.verify {
|
||||
"would be applied [--verify]"
|
||||
} else {
|
||||
"applied"
|
||||
}
|
||||
),
|
||||
);
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
if core_rebuild {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"{} — a CORE ServUO file was patched. Rebuild the solution:\n \
|
||||
dotnet build ServUO.sln\n \
|
||||
A shard restart is not enough; ServUO's dynamic script build does not rebuild the \
|
||||
core, and it will not tell you so.",
|
||||
core_targets.join(", ")
|
||||
));
|
||||
}
|
||||
if !lost.is_empty() {
|
||||
println!();
|
||||
println!(" Not applied, so you do not get: {}.", lost.join(", "));
|
||||
println!(
|
||||
" Everything else works. Apply the hunks by hand if you want them, then re-run \
|
||||
install to record it."
|
||||
);
|
||||
}
|
||||
if cli.verify {
|
||||
println!("\n VERIFY only. No ServUO file was edited and no patch was cached.");
|
||||
}
|
||||
|
||||
Ok(Outcome {
|
||||
records,
|
||||
ran: true,
|
||||
core_rebuild: core_rebuild && !cli.verify,
|
||||
})
|
||||
}
|
||||
|
||||
/// One patch, resolved against the tree.
|
||||
struct Resolved {
|
||||
name: String,
|
||||
/// Relative to the ServUO root.
|
||||
target: String,
|
||||
/// Relative to the extracted release.
|
||||
file: String,
|
||||
bytes: Vec<u8>,
|
||||
content: Vec<u8>,
|
||||
resolution: Resolution,
|
||||
}
|
||||
|
||||
fn resolve_feature(root: &ServUoRoot, unpacked: &Path, feature: &Feature) -> Result<Vec<Resolved>> {
|
||||
let mut out = Vec::with_capacity(feature.patches.len());
|
||||
for declared in &feature.patches {
|
||||
let (bytes, parsed) = patch::load(unpacked, declared)?;
|
||||
let target = patch::join(&root.path, &declared.target);
|
||||
|
||||
let (content, resolution) = match std::fs::read(&target) {
|
||||
Ok(content) => {
|
||||
let resolution = patch::resolve(&parsed, &content);
|
||||
(content, resolution)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
(Vec::new(), Resolution::Refused(patch::Refusal::Missing))
|
||||
}
|
||||
Err(error) => (
|
||||
Vec::new(),
|
||||
Resolution::Refused(patch::Refusal::Unreadable(error.to_string())),
|
||||
),
|
||||
};
|
||||
|
||||
out.push(Resolved {
|
||||
name: declared.name.clone(),
|
||||
target: declared.target.clone(),
|
||||
file: declared.file.clone(),
|
||||
bytes,
|
||||
content,
|
||||
resolution,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copies every evaluated patch into the state directory.
|
||||
///
|
||||
/// Deliberately separate from [`write_feature`] and called for **refused** features too, because
|
||||
/// the refusal message names this path as the file to apply by hand. Caching only what applied
|
||||
/// would make that message point at a file the run had chosen not to write.
|
||||
fn cache_patches(resolved: &[Resolved], layout: &paths::Layout) -> Result<()> {
|
||||
for r in resolved {
|
||||
let cached = layout.patches_dir().join(file_name(&r.file));
|
||||
write_atomic(&cached, &r.bytes).with_context(|| {
|
||||
format!(
|
||||
"cannot cache {} — the run needs somewhere to put the patch it is about to \
|
||||
reference",
|
||||
r.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes one feature: the patched files and its companion sources.
|
||||
///
|
||||
/// Reached only when every patch in the feature is placeable, so a partial write is not a state
|
||||
/// this function can produce. The pre-image is cached **before** the file is written and never
|
||||
/// overwritten afterwards, so it stays the content from before the tier first touched it.
|
||||
fn write_feature(
|
||||
root: &ServUoRoot,
|
||||
unpacked: &Path,
|
||||
feature: &Feature,
|
||||
resolved: &[Resolved],
|
||||
layout: &paths::Layout,
|
||||
) -> Result<()> {
|
||||
for r in resolved {
|
||||
if let Resolution::Applicable { edits, .. } = &r.resolution {
|
||||
let original = patch::join(&layout.patch_originals_dir(), &r.target);
|
||||
if !original.exists() {
|
||||
write_atomic(&original, &r.content)
|
||||
.with_context(|| format!("cannot save the pre-patch copy of {}", r.target))?;
|
||||
}
|
||||
let patched = patch::apply(&r.content, edits);
|
||||
let path = patch::join(&root.path, &r.target);
|
||||
write_atomic(&path, &patched)
|
||||
.with_context(|| format!("cannot write the patched {}", r.target))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Companions last, and only now: they reference symbols the patches introduce, so a companion
|
||||
// copied beside an unpatched file is a shard that does not compile — and ServUO would report a
|
||||
// clean boot anyway (PLAN.md §2.1).
|
||||
for companion in &feature.companions {
|
||||
let src = patch::join(unpacked, &companion.file);
|
||||
let dst = patch::join(&root.path, &companion.install_to);
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(&src, &dst).with_context(|| {
|
||||
format!(
|
||||
"cannot copy {} into the ServUO tree — the patches applied, so this file is \
|
||||
required for the shard to compile",
|
||||
companion.install_to
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the `install.json` entry for an applied feature.
|
||||
///
|
||||
/// **A rung-0 result reuses the previous record whole.** The rung is the support-relevant fact —
|
||||
/// how did this land? — and re-deriving it on a later run answers `already-present` for something
|
||||
/// that first landed as `region-match`. That flip would rewrite `install.json` on the second run of
|
||||
/// an otherwise-identical install, which is the same class of bug as the `Bridge.cfg` comparison in
|
||||
/// `overlay::plan`: a record that describes the run instead of the state.
|
||||
fn record_for(
|
||||
feature: &Feature,
|
||||
resolved: &[Resolved],
|
||||
root: &ServUoRoot,
|
||||
supported: bool,
|
||||
prior: Option<&FeatureRecord>,
|
||||
) -> FeatureRecord {
|
||||
let all_already_present = resolved
|
||||
.iter()
|
||||
.all(|r| r.resolution.rung() == Some(Rung::AlreadyPresent));
|
||||
if all_already_present {
|
||||
if let Some(prior) = prior {
|
||||
return prior.clone();
|
||||
}
|
||||
}
|
||||
|
||||
FeatureRecord {
|
||||
feature: feature.name.clone(),
|
||||
rebuild: feature.rebuild,
|
||||
servuo_version: root.version.clone(),
|
||||
unsupported_servuo: !supported,
|
||||
patches: resolved
|
||||
.iter()
|
||||
.map(|r| AppliedPatch {
|
||||
name: r.name.clone(),
|
||||
target: r.target.clone(),
|
||||
rung: r
|
||||
.resolution
|
||||
.rung()
|
||||
.map(Rung::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
sha256: crate::util::sha256_bytes(&r.bytes),
|
||||
hunks: r.resolution.placements().to_vec(),
|
||||
})
|
||||
.collect(),
|
||||
companions: feature
|
||||
.companions
|
||||
.iter()
|
||||
.map(|c| CompanionRecord {
|
||||
path: c.install_to.clone(),
|
||||
// Hashed from the tree after the copy, so the record describes what is actually
|
||||
// there — which is what lets `doctor` notice a companion that was later edited.
|
||||
sha256: sha256_file(&patch::join(&root.path, &c.install_to)).unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One reported line per patch, in the layout INSTALL.md §4 illustrates.
|
||||
fn render(
|
||||
feature: &Feature,
|
||||
r: &Resolved,
|
||||
placeable: bool,
|
||||
layout: &paths::Layout,
|
||||
verify: bool,
|
||||
) -> String {
|
||||
let mark = if placeable { "✓" } else { "✗" };
|
||||
let mut out = format!(" {mark} {:<30} {}", r.name, r.target);
|
||||
|
||||
let detail = match &r.resolution {
|
||||
Resolution::AlreadyPresent { .. } => Rung::AlreadyPresent.detail().to_string(),
|
||||
Resolution::Applicable { rung, hunks, .. } => {
|
||||
let at = hunks
|
||||
.iter()
|
||||
.map(|h| h.matched_line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
// "applied" is only ever claimed for something that was actually written. A dry run
|
||||
// wrote nothing, and a patch held back by its feature's all-or-nothing rule was
|
||||
// resolvable but left alone — reporting either as applied is the exact misreading this
|
||||
// whole tier is built to avoid.
|
||||
let verb = match (placeable, verify) {
|
||||
(true, false) => "applied at line",
|
||||
(true, true) => "would be applied at line",
|
||||
(false, _) => "could have been placed at line",
|
||||
};
|
||||
format!("{} — {verb} {at}", rung.detail())
|
||||
}
|
||||
Resolution::Refused(refusal) => refusal.detail(),
|
||||
};
|
||||
out.push_str(&format!("\n {detail}"));
|
||||
|
||||
// A feature is all-or-nothing, so a patch that could have been placed is still not written when
|
||||
// a sibling could not. Saying "applied" there would be a lie the operator finds out about later.
|
||||
if !placeable {
|
||||
if r.resolution.rung().is_some() {
|
||||
out.push_str(&format!(
|
||||
"\n held back — {} is applied as one unit and a sibling patch could not be \
|
||||
placed",
|
||||
feature.name
|
||||
));
|
||||
}
|
||||
if !verify {
|
||||
out.push_str(&format!(
|
||||
"\n apply this by hand, then re-run install to record it:\n {}",
|
||||
layout.patches_dir().join(file_name(&r.file)).display()
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn file_name(rel: &str) -> &str {
|
||||
rel.rsplit('/').next().unwrap_or(rel)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::patch::{Companion, PatchRef};
|
||||
|
||||
fn feature() -> Feature {
|
||||
Feature {
|
||||
name: "vendor-sale".into(),
|
||||
summary: "vendor.sale events".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(),
|
||||
}],
|
||||
companions: vec![Companion {
|
||||
file: "patches/BridgeVendorSale.cs".into(),
|
||||
install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved(resolution: Resolution) -> Resolved {
|
||||
Resolved {
|
||||
name: "playervendor-sale-eventsink".into(),
|
||||
target: "Server/EventSink.cs".into(),
|
||||
file: "patches/playervendor-sale-eventsink.patch".into(),
|
||||
bytes: b"--- a/x\n".to_vec(),
|
||||
content: Vec::new(),
|
||||
resolution,
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ServUoRoot {
|
||||
ServUoRoot {
|
||||
path: std::path::PathBuf::from("/opt/ServUO"),
|
||||
version: Some("57.4".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout() -> paths::Layout {
|
||||
paths::Layout {
|
||||
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_patch_points_at_the_cached_file_to_apply_by_hand() {
|
||||
let r = resolved(Resolution::Refused(patch::Refusal::RegionModified {
|
||||
hunk: 2,
|
||||
}));
|
||||
let line = render(&feature(), &r, false, &layout(), false);
|
||||
assert!(line.contains('✗'), "{line}");
|
||||
assert!(line.contains("patched region has been modified"), "{line}");
|
||||
assert!(
|
||||
line.contains("playervendor-sale-eventsink.patch"),
|
||||
"the operator needs the path of the file to apply: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeable_patch_held_back_by_its_sibling_says_so() {
|
||||
// The failure this prevents: reporting a patch as applied because it *could* have been,
|
||||
// when the feature's all-or-nothing rule meant nothing was written.
|
||||
let r = resolved(Resolution::Applicable {
|
||||
rung: Rung::RegionMatch,
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 171,
|
||||
matched_line: 173,
|
||||
}],
|
||||
edits: Vec::new(),
|
||||
});
|
||||
let line = render(&feature(), &r, false, &layout(), false);
|
||||
assert!(line.contains("held back"), "{line}");
|
||||
assert!(line.contains("as one unit"), "{line}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declining_does_not_claim_a_loss_that_an_earlier_run_already_prevented() {
|
||||
// Caught live: `--no-patches` on a host whose install.json shows both features applied
|
||||
// still announced the loss of both. A decline inspects nothing, so the record is the only
|
||||
// evidence there is — and ignoring it sends an operator looking for a problem they do not
|
||||
// have.
|
||||
let tier = Tier::builtin();
|
||||
let applied = |name: &str| FeatureRecord {
|
||||
feature: name.into(),
|
||||
rebuild: Rebuild::Core,
|
||||
servuo_version: Some("57.4".into()),
|
||||
unsupported_servuo: false,
|
||||
patches: Vec::new(),
|
||||
companions: Vec::new(),
|
||||
};
|
||||
|
||||
let all: Vec<FeatureRecord> = tier.features.iter().map(|f| applied(&f.name)).collect();
|
||||
let none: Vec<FeatureRecord> = Vec::new();
|
||||
|
||||
// The three cases differ only in what the record holds, so assert on the filtering itself
|
||||
// rather than on captured stdout.
|
||||
let remaining = |prior: &[FeatureRecord]| -> Vec<String> {
|
||||
let have = patch::index_records(prior);
|
||||
tier.features
|
||||
.iter()
|
||||
.filter(|f| !have.contains_key(f.name.as_str()))
|
||||
.map(|f| f.lost.clone())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(remaining(&none).len(), 2, "a fresh host loses both");
|
||||
assert!(
|
||||
remaining(&all).is_empty(),
|
||||
"a fully patched host loses nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
remaining(&[applied("vendor-sale")]),
|
||||
vec!["no in-game moderation audit forwarding".to_string()],
|
||||
"only the feature that is genuinely absent is named"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_that_was_not_written_is_reported_as_applied() {
|
||||
// Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch
|
||||
// held back by its sibling said "applied" on the line above the one explaining it was not.
|
||||
let r = || {
|
||||
resolved(Resolution::Applicable {
|
||||
rung: Rung::RegionMatch,
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 75,
|
||||
matched_line: 75,
|
||||
}],
|
||||
edits: Vec::new(),
|
||||
})
|
||||
};
|
||||
let dry = render(&feature(), &r(), true, &layout(), true);
|
||||
assert!(dry.contains("would be applied at line 75"), "{dry}");
|
||||
|
||||
let held = render(&feature(), &r(), false, &layout(), false);
|
||||
assert!(held.contains("could have been placed at line 75"), "{held}");
|
||||
assert!(!held.contains("— applied at"), "{held}");
|
||||
|
||||
let real = render(&feature(), &r(), true, &layout(), false);
|
||||
assert!(real.contains("applied at line 75"), "{real}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_applied_patch_reports_the_line_it_matched_not_the_one_it_declared() {
|
||||
// The declared line is advisory: an insertion above the region shifts it. Printing the
|
||||
// declared number would send an operator to the wrong place in their own file.
|
||||
let r = resolved(Resolution::Applicable {
|
||||
rung: Rung::RegionMatch,
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 171,
|
||||
matched_line: 1180,
|
||||
}],
|
||||
edits: Vec::new(),
|
||||
});
|
||||
let line = render(&feature(), &r, true, &layout(), false);
|
||||
assert!(line.contains("applied at line 1180"), "{line}");
|
||||
assert!(!line.contains("171"), "{line}");
|
||||
assert!(
|
||||
line.contains("file modified, patched region stock"),
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rung_zero_rerun_keeps_the_rung_that_first_applied_it() {
|
||||
// Idempotence: re-deriving would answer `already-present` for something that landed as
|
||||
// `region-match`, rewriting install.json on the second run of an identical install.
|
||||
let first = record_for(
|
||||
&feature(),
|
||||
&[resolved(Resolution::Applicable {
|
||||
rung: Rung::RegionMatch,
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 171,
|
||||
matched_line: 173,
|
||||
}],
|
||||
edits: Vec::new(),
|
||||
})],
|
||||
&root(),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
assert_eq!(first.patches[0].rung, "region-match");
|
||||
|
||||
let second = record_for(
|
||||
&feature(),
|
||||
&[resolved(Resolution::AlreadyPresent {
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 171,
|
||||
matched_line: 173,
|
||||
}],
|
||||
})],
|
||||
&root(),
|
||||
true,
|
||||
Some(&first),
|
||||
);
|
||||
assert_eq!(
|
||||
second, first,
|
||||
"a second run must record exactly the same thing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_patched_tree_with_no_prior_record_is_recorded_as_already_present() {
|
||||
// The other half: someone applied the hunks by hand per INSTALL.md Appendix A2, then ran
|
||||
// the installer. There is nothing to preserve, so the state it finds is what it records.
|
||||
let record = record_for(
|
||||
&feature(),
|
||||
&[resolved(Resolution::AlreadyPresent {
|
||||
hunks: vec![patch::HunkPlacement {
|
||||
declared_line: 171,
|
||||
matched_line: 171,
|
||||
}],
|
||||
})],
|
||||
&root(),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
assert_eq!(record.patches[0].rung, "already-present");
|
||||
assert!(!record.unsupported_servuo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsupported_tree_labels_the_record_it_writes() {
|
||||
// The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able
|
||||
// to see it from install.json without being told.
|
||||
let mut root = root();
|
||||
root.version = Some("58.1".into());
|
||||
let record = record_for(
|
||||
&feature(),
|
||||
&[resolved(Resolution::Applicable {
|
||||
rung: Rung::RegionMatch,
|
||||
hunks: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
})],
|
||||
&root,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(record.unsupported_servuo);
|
||||
assert_eq!(record.servuo_version.as_deref(), Some("58.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_core_rebuild_warning_names_only_files_this_run_wrote() {
|
||||
// Caught on a live tree whose vendor-sale patches were already applied by hand: the run
|
||||
// wrote nothing and still demanded a core rebuild, listing a Scripts file as core. On a
|
||||
// re-run that warning would recur forever, which is how a real one stops being read.
|
||||
let already = [resolved(Resolution::AlreadyPresent { hunks: Vec::new() })];
|
||||
let written: Vec<String> = already
|
||||
.iter()
|
||||
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
||||
.map(|r| r.target.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
written.is_empty(),
|
||||
"nothing was written, so nothing to rebuild"
|
||||
);
|
||||
|
||||
let fresh = [resolved(Resolution::Applicable {
|
||||
rung: Rung::StockHash,
|
||||
hunks: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
})];
|
||||
let written: Vec<String> = fresh
|
||||
.iter()
|
||||
.filter(|r| matches!(r.resolution, Resolution::Applicable { .. }))
|
||||
.map(|r| r.target.clone())
|
||||
.collect();
|
||||
assert_eq!(written, vec!["Server/EventSink.cs".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patches_alone_is_not_consent_on_an_unsupported_tree() {
|
||||
let mut cli = Cli {
|
||||
patches: PatchChoice::Yes,
|
||||
..Cli::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
||||
Consent::RefusedUnsupported
|
||||
));
|
||||
|
||||
// ...and the separate flag is.
|
||||
cli.patches_unsupported_servuo = true;
|
||||
assert!(matches!(
|
||||
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
||||
Consent::Yes
|
||||
));
|
||||
|
||||
// On a supported tree the extra flag is not needed and is simply ignored.
|
||||
let plain = Cli {
|
||||
patches: PatchChoice::Yes,
|
||||
..Cli::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
consent(&plain, &root(), true, &Tier::builtin()).unwrap(),
|
||||
Consent::Yes
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_patches_declines_without_asking_anything() {
|
||||
let cli = Cli {
|
||||
patches: PatchChoice::No,
|
||||
..Cli::default()
|
||||
};
|
||||
// False for `supported` too: an explicit decline is never escalated into a prompt.
|
||||
assert!(matches!(
|
||||
consent(&cli, &root(), false, &Tier::builtin()).unwrap(),
|
||||
Consent::No(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unattended_run_that_did_not_ask_for_the_tier_takes_the_no_default() {
|
||||
// --yes means "take the default answer", and the default is no on every tree (INSTALL.md
|
||||
// §2). An unattended install must not start editing stock files because nobody objected.
|
||||
let cli = Cli {
|
||||
patches: PatchChoice::Ask,
|
||||
assume_yes: true,
|
||||
..Cli::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
consent(&cli, &root(), true, &Tier::builtin()).unwrap(),
|
||||
Consent::No(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
46
src/util.rs
46
src/util.rs
@@ -23,9 +23,11 @@ pub fn hex(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a
|
||||
/// straight-line hash of the same bytes; the run itself only ever hashes files and streams.
|
||||
#[cfg(test)]
|
||||
/// Hashes a buffer.
|
||||
///
|
||||
/// The run hashes files and streams almost everywhere, since they are large. The exception is the
|
||||
/// patch tier, which already holds each `.patch` in memory to parse it and would otherwise re-read
|
||||
/// from disk purely to hash a few kilobytes it is looking at.
|
||||
pub fn sha256_bytes(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
@@ -52,6 +54,25 @@ pub fn sha256_file(path: &Path) -> Result<String> {
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
/// The git object name of a buffer treated as a blob: `sha1("blob " + len + "\0" + content)`.
|
||||
///
|
||||
/// This is what `git hash-object` prints and what a patch's `index <old>..<new>` line records, so
|
||||
/// reproducing it is how the patch tier answers rung 1 — "is this whole file still the one the
|
||||
/// patch was written against?" (PLAN.md §2.2.1). Computed here rather than by shelling out, because
|
||||
/// the entire reason the plugin ships as a release tarball is that a shard host has no git on it
|
||||
/// (§1).
|
||||
///
|
||||
/// The bytes are hashed exactly as they sit on disk. That matters: the three files this tier edits
|
||||
/// are CRLF, and the recorded hashes were taken from those CRLF bytes, so any normalization here
|
||||
/// would make every rung-1 check miss.
|
||||
pub fn git_blob_hash(content: &[u8]) -> String {
|
||||
use sha1::{Digest as _, Sha1};
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(format!("blob {}\0", content.len()).as_bytes());
|
||||
hasher.update(content);
|
||||
hex(&hasher.finalize())
|
||||
}
|
||||
|
||||
/// A [`Write`] that hashes everything passing through it.
|
||||
///
|
||||
/// Downloads are verified *while* being written rather than by re-reading the finished file: it
|
||||
@@ -247,6 +268,25 @@ mod tests {
|
||||
assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hashing_matches_git_hash_object() {
|
||||
// These are the values `git hash-object` prints, and the same ones a patch's `index` line
|
||||
// carries. If this drifts, rung 1 silently stops recognising a stock file and every patch
|
||||
// falls through to the region match — which still works, and would hide the bug for a long
|
||||
// time.
|
||||
assert_eq!(
|
||||
git_blob_hash(b""),
|
||||
"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
|
||||
);
|
||||
assert_eq!(
|
||||
git_blob_hash(b"hello\n"),
|
||||
"ce013625030ba8dba906f756967f9e9ca394464a"
|
||||
);
|
||||
// CRLF is hashed as it sits on disk — the ServUO files this is used on are all CRLF, and
|
||||
// normalizing here would make every rung-1 check miss.
|
||||
assert_ne!(git_blob_hash(b"a\r\n"), git_blob_hash(b"a\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hashing_writer_sees_what_was_written() {
|
||||
let mut w = HashingWriter::new(Vec::new());
|
||||
|
||||
33
tests/fixtures/commandlogging-event.patch
vendored
Normal file
33
tests/fixtures/commandlogging-event.patch
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
--- a/Scripts/Commands/Logging.cs
|
||||
+++ b/Scripts/Commands/Logging.cs
|
||||
@@ -75,16 +75,27 @@
|
||||
return o;
|
||||
}
|
||||
|
||||
+ /// <summary>
|
||||
+ /// Raised for every staff command log line — even when file logging is disabled — so an
|
||||
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
|
||||
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
|
||||
+ /// </summary>
|
||||
+ public static event Action<Mobile, string> OnWrite;
|
||||
+
|
||||
public static void WriteLine(Mobile from, string format, params object[] args)
|
||||
{
|
||||
- if (!m_Enabled)
|
||||
- return;
|
||||
-
|
||||
WriteLine(from, String.Format(format, args));
|
||||
}
|
||||
|
||||
public static void WriteLine(Mobile from, string text)
|
||||
{
|
||||
+ var onWrite = OnWrite;
|
||||
+ if (onWrite != null)
|
||||
+ {
|
||||
+ try { onWrite(from, text); }
|
||||
+ catch { }
|
||||
+ }
|
||||
+
|
||||
if (!m_Enabled)
|
||||
return;
|
||||
|
||||
47
tests/fixtures/patch_tier.json
vendored
Normal file
47
tests/fixtures/patch_tier.json
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"name": "vendor-sale",
|
||||
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
|
||||
"lost": "no vendor.sale events",
|
||||
"rebuild": "core",
|
||||
"patches": [
|
||||
{
|
||||
"name": "playervendor-sale-eventsink",
|
||||
"file": "patches/playervendor-sale-eventsink.patch",
|
||||
"target": "Server/EventSink.cs"
|
||||
},
|
||||
{
|
||||
"name": "playervendor-sale-gump",
|
||||
"file": "patches/playervendor-sale-gump.patch",
|
||||
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
|
||||
}
|
||||
],
|
||||
"companions": [
|
||||
{
|
||||
"file": "patches/BridgeVendorSale.cs",
|
||||
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moderation-audit",
|
||||
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
66
tests/fixtures/playervendor-sale-eventsink.patch
vendored
Normal file
66
tests/fixtures/playervendor-sale-eventsink.patch
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
diff --git a/Server/EventSink.cs b/Server/EventSink.cs
|
||||
index d30788f..1da2667 100644
|
||||
--- a/Server/EventSink.cs
|
||||
+++ b/Server/EventSink.cs
|
||||
@@ -171,6 +171,8 @@ namespace Server
|
||||
|
||||
public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
|
||||
|
||||
+ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
|
||||
+
|
||||
public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
|
||||
|
||||
public delegate void RepairItemEventHandler(RepairItemEventArgs e);
|
||||
@@ -1521,6 +1523,29 @@ namespace Server
|
||||
}
|
||||
}
|
||||
|
||||
+ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
|
||||
+ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
|
||||
+ // are all in scope -- the data the bridge's cheat-detection feed needs.
|
||||
+ public class PlayerVendorSaleEventArgs : EventArgs
|
||||
+ {
|
||||
+ public Mobile Buyer { get; set; }
|
||||
+ public Mobile Vendor { get; set; }
|
||||
+ public Mobile Owner { get; set; }
|
||||
+ public Item Item { get; set; }
|
||||
+ public int Price { get; set; }
|
||||
+ public int Commission { get; set; }
|
||||
+
|
||||
+ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
|
||||
+ {
|
||||
+ Buyer = buyer;
|
||||
+ Vendor = vendor;
|
||||
+ Owner = owner;
|
||||
+ Item = item;
|
||||
+ Price = price;
|
||||
+ Commission = commission;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
public class CorpseLootEventArgs : EventArgs
|
||||
{
|
||||
public Mobile Mobile { get; set; }
|
||||
@@ -1771,6 +1796,7 @@ namespace Server
|
||||
public static event TameCreatureEventHandler TameCreature;
|
||||
public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
|
||||
public static event ValidVendorSellEventHandler ValidVendorSell;
|
||||
+ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
|
||||
public static event CorpseLootEventHandler CorpseLoot;
|
||||
public static event RepairItemEventHandler RepairItem;
|
||||
public static event AlterItemEventHandler AlterItem;
|
||||
@@ -2416,6 +2442,14 @@ namespace Server
|
||||
}
|
||||
}
|
||||
|
||||
+ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
|
||||
+ {
|
||||
+ if (PlayerVendorSale != null)
|
||||
+ {
|
||||
+ PlayerVendorSale(e);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
public static void InvokeCorpseLoot(CorpseLootEventArgs e)
|
||||
{
|
||||
if (CorpseLoot != null)
|
||||
15
tests/fixtures/playervendor-sale-gump.patch
vendored
Normal file
15
tests/fixtures/playervendor-sale-gump.patch
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
|
||||
index 049aae6..f1b30d2 100644
|
||||
--- a/Scripts/Gumps/PlayerVendorGumps.cs
|
||||
+++ b/Scripts/Gumps/PlayerVendorGumps.cs
|
||||
@@ -95,6 +95,10 @@ namespace Server.Gumps
|
||||
|
||||
m_Vendor.HoldGold += m_VI.Price - commission;
|
||||
|
||||
+ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
|
||||
+ EventSink.InvokePlayerVendorSale(
|
||||
+ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
|
||||
+
|
||||
from.SendLocalizedMessage(503201); // You take the item.
|
||||
}
|
||||
}
|
||||
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