diff --git a/Cargo.lock b/Cargo.lock index d6b5329..0b7b0d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 25a186a..183bbe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 ..` 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" diff --git a/src/cli.rs b/src/cli.rs index 29121b1..d88ff08 100644 --- a/src/cli.rs +++ b/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, diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..935b1bc --- /dev/null +++ b/src/diff.rs @@ -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), + /// `-` — present in the stock file only. + Removed(Vec), + /// `+` — present in the patched file only. + Added(Vec), +} + +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, + /// 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/`, with the `b/` prefix stripped and separators left as `/`. + pub path: String, + /// The abbreviated blob hash of the stock file, from `index ..`. `None` when the + /// diff has no `index` line, which makes rung 1 unavailable for this file — see the module + /// docs. + pub pre_blob: Option, + pub post_blob: Option, + pub hunks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Patch { + pub files: Vec, +} + +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::>() + .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 { + let lines = split_lines(data); + let mut files: Vec = 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 ..[ ]`. 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 { + 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 { + 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 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::>() + .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() + ); + } +} diff --git a/src/install.rs b/src/install.rs index b2a8d88..2adde12 100644 --- a/src/install.rs +++ b/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(), } } diff --git a/src/lib.rs b/src/lib.rs index e3e4fcf..35fb1d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/overlay.rs b/src/overlay.rs index 375706a..83b7d0d 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -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, /// SHA256 per shipped file, keyed `overlay/...` and `patches/...`. pub files: BTreeMap, } @@ -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(), diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..a2ffec5 --- /dev/null +++ b/src/patch.rs @@ -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 }, + /// 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, + edits: Vec, + }, + /// The patch will not be placed, and why. + Refused(Refusal), +} + +impl Resolution { + pub fn rung(&self) -> Option { + 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, +} + +/// 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>> = 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> { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + let hits: Vec = (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 { + 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, +} + +#[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, + pub companions: Vec, +} + +#[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/.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, 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, + pub unsupported_servuo: bool, + pub patches: Vec, + pub companions: Vec, +} + +#[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, +} + +#[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::>() +} + +#[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 { + 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 { + 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" + ); + } +} diff --git a/src/paths.rs b/src/paths.rs index f3415a2..edb9ec7 100644 --- a/src/paths.rs +++ b/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 { diff --git a/src/record.rs b/src/record.rs index 588a04a..3bf92c4 100644 --- a/src/record.rs +++ b/src/record.rs @@ -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, - /// 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, /// Anything a newer installer wrote that this one has no name for. @@ -203,6 +209,19 @@ impl InstallRecord { pub fn link_record(&self) -> Option { 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 { + self.patches + .iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() + } } pub fn now_rfc3339() -> String { diff --git a/src/tier.rs b/src/tier.rs new file mode 100644 index 0000000..131c29a --- /dev/null +++ b/src/tier.rs @@ -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, + /// 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 { + 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 { + 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::>().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 { + let previous = patch::index_records(prior); + let mut records: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut lost: Vec<&str> = Vec::new(); + let mut applied_patches = 0usize; + let mut core_rebuild = false; + let mut core_targets: Vec = 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 = 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, + content: Vec, + resolution: Resolution, +} + +fn resolve_feature(root: &ServUoRoot, unpacked: &Path, feature: &Feature) -> Result> { + 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::>() + .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 = tier.features.iter().map(|f| applied(&f.name)).collect(); + let none: Vec = 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 { + 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 = 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 = 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(_) + )); + } +} diff --git a/src/util.rs b/src/util.rs index f0e994d..abc3ccc 100644 --- a/src/util.rs +++ b/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 { 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 ..` 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()); diff --git a/tests/fixtures/commandlogging-event.patch b/tests/fixtures/commandlogging-event.patch new file mode 100644 index 0000000..37d5f3f --- /dev/null +++ b/tests/fixtures/commandlogging-event.patch @@ -0,0 +1,33 @@ +--- a/Scripts/Commands/Logging.cs ++++ b/Scripts/Commands/Logging.cs +@@ -75,16 +75,27 @@ + return o; + } + ++ /// ++ /// 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. ++ /// ++ public static event Action 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; + diff --git a/tests/fixtures/patch_tier.json b/tests/fixtures/patch_tier.json new file mode 100644 index 0000000..662c8f6 --- /dev/null +++ b/tests/fixtures/patch_tier.json @@ -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" + } + ] + } + ] +} diff --git a/tests/fixtures/playervendor-sale-eventsink.patch b/tests/fixtures/playervendor-sale-eventsink.patch new file mode 100644 index 0000000..d8714c7 --- /dev/null +++ b/tests/fixtures/playervendor-sale-eventsink.patch @@ -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) diff --git a/tests/fixtures/playervendor-sale-gump.patch b/tests/fixtures/playervendor-sale-gump.patch new file mode 100644 index 0000000..1eedccd --- /dev/null +++ b/tests/fixtures/playervendor-sale-gump.patch @@ -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. + } + } diff --git a/tests/real_patches.rs b/tests/real_patches.rs new file mode 100644 index 0000000..e0ece08 --- /dev/null +++ b/tests/real_patches.rs @@ -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 { + 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 { + 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 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 = 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::(&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 = tier + .features + .iter() + .flat_map(|f| f.patches.iter()) + .map(|p| p.file.replace("patches/", "")) + .collect(); + declared.sort(); + + let mut shipped: Vec = 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 + ); + } + } +}