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

Three decisions were not settled by the plan:

* The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply
  verbatim with git apply", but §1 chose the release tarball specifically so
  there would be no git on the shard host, and rung 2 needs a native applier
  regardless. Rung 1 keeps its distinct, stronger verdict — the whole file
  reproduced the diff's `index` pre-image, computed as a git blob SHA1 in
  process — while the write goes through the same code path as rung 2. On the
  real trees here that is not academic: the shipped .patch files are CRLF in a
  Windows checkout and two of their three targets are LF, so `git apply`
  refuses patches this applies correctly.

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

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

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

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

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

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

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

704 lines
27 KiB
Rust

//! The plugin overlay: unpack the release, then sync it into the ServUO tree.
//!
//! The plugin ships as **C# source that ServUO compiles at boot** (PLAN.md §2.1), so deployment is
//! a hash-compare file copy rather than a DLL drop. Three rules govern it:
//!
//! - **Nothing is ever deleted.** `overlay/` mirrors the server root and only adds or overwrites.
//! That is `deploy.ps1`'s behaviour and the installer inherits it: the ServUO tree belongs to the
//! operator, and a deployment tool that removes files from it is a deployment tool that
//! eventually removes the wrong one.
//! - **`Config/Bridge.cfg` is reported, not overwritten, once it has been edited** — the single
//! deviation from `deploy.ps1` (PLAN.md §5, Phase 1). It is the only file in the overlay that is
//! *meant* to be edited in place, and it carries no code, so a stale copy cannot break the build.
//! Silently reverting it would throw away `LinkUrl`, `PublicConnectAddress` and every sweep
//! interval on an `update`.
//! - **A successful copy is not a working bridge.** ServUO ignores the script build's exit code
//! and reloads the previous `Scripts.dll` (§2.1), so nothing here may report success in terms
//! stronger than "the files are in place".
use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::bundle::ServUoCompat;
use crate::record::FileRecord;
use crate::util::sha256_file;
/// The tarball's fixed top-level directory. Fixed rather than versioned on purpose: the installer
/// looks for `overlay/`, `patches/` and `manifest.json` at known paths instead of parsing the very
/// version it is trying to read (PLAN.md §5, Phase 0 item 1).
const TOP_LEVEL_DIR: &str = "runicgateway-overlay";
/// Files the operator owns once deployed. Everything else — every `.cs` file and `Scripts.csproj` —
/// is overwritten unconditionally, because it is code and a stale copy breaks the build.
const OPERATOR_OWNED: &[&str] = &["Config/Bridge.cfg"];
/// `manifest.json`, generated by the `servuo-plugins` release workflow (PLAN.md §7.0).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
pub component: String,
pub version: String,
pub commit: String,
pub repo: String,
/// The plugin half of the compatibility contract, declared in `overlay.toml`. Nothing can
/// derive it — the plugin announces no version on the wire and none is queryable before ServUO
/// boots — which is why it is checked against the bundle before anything is written.
pub protocol: u32,
pub servuo: ServUoCompat,
/// The patch tier this release ships, generated from `servuo-plugins/patches/tier.json`
/// (PLAN.md §2.2). `None` for a release that predates the declaration — see
/// [`crate::patch::Tier::resolve`], which substitutes a built-in description rather than
/// leaving the tier silently empty.
#[serde(default)]
pub patch_tier: Option<crate::patch::Tier>,
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
pub files: BTreeMap<String, String>,
}
/// What the sync will do to one file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Add,
Change,
Unchanged,
/// The operator has edited this file since it was deployed (or it was already there before the
/// installer ever ran). Reported, left alone.
KeptOperatorModified,
}
impl Action {
/// The token written into `install.json` — the resulting *state*, not the verb.
///
/// Add, change and unchanged all leave the release's copy in the tree, so all three record
/// `deployed`. Collapsing them is what lets an unchanged re-run compare equal to the previous
/// record and write nothing (see [`crate::record::FileRecord::state`]).
pub fn state(self) -> &'static str {
match self {
Self::Add | Self::Change | Self::Unchanged => "deployed",
Self::KeptOperatorModified => "kept-operator-modified",
}
}
fn label(self) -> &'static str {
match self {
Self::Add => "ADD",
Self::Change => "CHANGE",
Self::Unchanged => "same",
Self::KeptOperatorModified => "KEEP",
}
}
fn writes(self) -> bool {
matches!(self, Self::Add | Self::Change)
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone)]
pub struct PlannedFile {
/// ServUO-tree-relative, always `/`-separated so the record is portable between platforms.
pub rel: String,
pub src: PathBuf,
pub dst: PathBuf,
pub action: Action,
pub overlay_sha256: String,
/// What is on disk now — `None` when the file does not exist yet.
pub on_disk_sha256: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Summary {
pub add: usize,
pub change: usize,
pub unchanged: usize,
pub kept: usize,
}
impl Summary {
pub fn writes_anything(&self) -> bool {
self.add + self.change > 0
}
}
/// Unpacks the release tarball and returns the directory holding `overlay/`, `patches/` and
/// `manifest.json`.
///
/// `tar`'s unpack refuses entries that escape the destination, so a malicious or malformed archive
/// cannot write outside the scratch directory — worth stating explicitly, since this is the one
/// place the installer expands untrusted-shaped data. The archive itself has already been checked
/// against the bundle's SHA256 by the time this runs.
pub fn extract(tarball: &Path, into: &Path) -> Result<PathBuf> {
let file = File::open(tarball).with_context(|| format!("cannot open {}", tarball.display()))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(into)
.with_context(|| format!("cannot unpack {}", tarball.display()))?;
// The fixed prefix is what the release workflow writes; falling back to the extraction root
// covers a tarball repackaged without it, which is a plausible operator mistake and a
// pointless thing to fail on when the three known paths are right there.
let with_prefix = into.join(TOP_LEVEL_DIR);
for candidate in [with_prefix, into.to_path_buf()] {
if candidate.join("manifest.json").is_file() && candidate.join("overlay").is_dir() {
return Ok(candidate);
}
}
bail!(
"{} does not contain {TOP_LEVEL_DIR}/manifest.json and {TOP_LEVEL_DIR}/overlay/ — \
this is not a Runic Gateway overlay release",
tarball.display()
);
}
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
let path = dir.join("manifest.json");
let body =
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
serde_json::from_str(&body).with_context(|| {
format!(
"{} is not a manifest this installer understands",
path.display()
)
})
}
/// Re-hashes every file the manifest names.
///
/// The tarball's own checksum has already been verified against the bundle, so this is not the
/// trust boundary — it is a guard against a truncated extraction, a disk error, or an archive
/// repacked by hand between download and deploy. It is also what makes the hashes recorded in
/// `install.json` trustworthy, since those come from this manifest rather than from re-reading the
/// tree later.
pub fn verify_payload(dir: &Path, manifest: &Manifest) -> Result<()> {
let mut problems = Vec::new();
for (rel, expected) in &manifest.files {
let path = dir.join(rel);
if !path.is_file() {
problems.push(format!(" missing: {rel}"));
continue;
}
let actual = sha256_file(&path)?;
if &actual != expected {
problems.push(format!(" modified: {rel}"));
}
}
if !problems.is_empty() {
bail!(
"the extracted overlay does not match its own manifest:\n{}",
problems.join("\n")
);
}
Ok(())
}
/// Decides what to do with every file in `overlay/`, without touching anything.
///
/// `prior` is the previous `install.json` file map. It is what separates "the operator edited
/// `Bridge.cfg`" from "the overlay shipped a new `Bridge.cfg`" (PLAN.md §7.0): if what is on disk
/// is exactly the copy this installer last *deployed*, the operator has not touched it and an
/// upstream change may land. Anything else — including no record at all, i.e. a tree where the
/// file was put there by hand per INSTALL.md Appendix A — is treated as the operator's.
pub fn plan(
overlay_dir: &Path,
servuo_root: &Path,
prior: Option<&BTreeMap<String, FileRecord>>,
) -> Result<Vec<PlannedFile>> {
let source = overlay_dir.join("overlay");
let mut files = Vec::new();
collect(&source, &source, &mut files)?;
files.sort();
let mut planned = Vec::with_capacity(files.len());
for rel in files {
let src = source.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
let dst = servuo_root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
let overlay_sha256 = sha256_file(&src)?;
let on_disk_sha256 = if dst.is_file() {
Some(sha256_file(&dst)?)
} else {
None
};
let action = match &on_disk_sha256 {
None => Action::Add,
Some(on_disk) if *on_disk == overlay_sha256 => Action::Unchanged,
Some(on_disk) if OPERATOR_OWNED.contains(&rel.as_str()) => {
match prior.and_then(|p| p.get(&rel)) {
// What is on disk is byte-for-byte the copy the installer itself last
// deployed, so the operator has not touched it and the release's new default
// may land.
//
// Compared against `overlay_sha256` — the release copy — and NOT against
// `on_disk_sha256`: after a file has once been kept, `on_disk_sha256` holds
// the *operator's* content, so comparing to it would find a match on the very
// next run and overwrite exactly the file this rule exists to protect. A keep
// has to stay kept for as long as the operator's edit is there.
Some(record) if record.overlay_sha256 == *on_disk => Action::Change,
_ => Action::KeptOperatorModified,
}
}
Some(_) => Action::Change,
};
planned.push(PlannedFile {
rel,
src,
dst,
action,
overlay_sha256,
on_disk_sha256,
});
}
Ok(planned)
}
/// Copies every file the plan writes. Parent directories are created; nothing is removed.
pub fn apply(planned: &[PlannedFile]) -> Result<()> {
for file in planned.iter().filter(|f| f.action.writes()) {
if let Some(parent) = file.dst.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
fs::copy(&file.src, &file.dst).with_context(|| {
format!(
"cannot write {}{}",
file.dst.display(),
"check that the shard is stopped and that you are running as root/Administrator"
)
})?;
}
Ok(())
}
pub fn summarize(planned: &[PlannedFile]) -> Summary {
let mut summary = Summary::default();
for file in planned {
match file.action {
Action::Add => summary.add += 1,
Action::Change => summary.change += 1,
Action::Unchanged => summary.unchanged += 1,
Action::KeptOperatorModified => summary.kept += 1,
}
}
summary
}
/// The per-file record for `install.json`.
pub fn file_records(planned: &[PlannedFile]) -> BTreeMap<String, FileRecord> {
planned
.iter()
.map(|f| {
// For everything the installer wrote, what is on disk afterwards *is* the overlay's
// copy. Only a kept file keeps its own hash — which is precisely what makes a later
// run able to tell that the operator, not the release, owns it.
let on_disk = match f.action {
Action::KeptOperatorModified => f
.on_disk_sha256
.clone()
.unwrap_or_else(|| f.overlay_sha256.clone()),
_ => f.overlay_sha256.clone(),
};
(
f.rel.clone(),
FileRecord {
overlay_sha256: f.overlay_sha256.clone(),
on_disk_sha256: on_disk,
state: f.action.state().to_string(),
},
)
})
.collect()
}
/// Renders the changed files, collapsing a directory full of identically-treated files into one
/// line — 22 `ADD` lines for `Scripts/Custom/Bridge/*.cs` push everything else off the screen, and
/// what an operator needs to see is that `Scripts.csproj` was overwritten.
pub fn render(planned: &[PlannedFile]) -> Vec<String> {
const GROUP_AT: usize = 4;
let mut lines = Vec::new();
let mut group: Vec<&PlannedFile> = Vec::new();
let interesting: Vec<&PlannedFile> = planned
.iter()
.filter(|f| f.action != Action::Unchanged)
.collect();
let key = |f: &PlannedFile| -> (Action, String, String) {
let (dir, name) = match f.rel.rsplit_once('/') {
Some((d, n)) => (d.to_string(), n.to_string()),
None => (String::new(), f.rel.clone()),
};
let ext = name
.rsplit_once('.')
.map(|(_, e)| e.to_string())
.unwrap_or_default();
(f.action, dir, ext)
};
let flush = |group: &mut Vec<&PlannedFile>, lines: &mut Vec<String>| {
if group.is_empty() {
return;
}
if group.len() >= GROUP_AT {
let (action, dir, ext) = key(group[0]);
let glob = if ext.is_empty() {
format!("{dir}/*")
} else {
format!("{dir}/*.{ext}")
};
lines.push(format!(
" {:<7} {:<38} ({} files)",
action.label(),
glob,
group.len()
));
} else {
for f in group.iter() {
lines.push(format!(" {:<7} {}", f.action.label(), f.rel));
}
}
group.clear();
};
for file in interesting {
if group.first().map(|g| key(g)) != Some(key(file)) {
flush(&mut group, &mut lines);
}
group.push(file);
}
flush(&mut group, &mut lines);
lines
}
/// Recursively lists files under `dir` as `/`-separated paths relative to `base`.
fn collect(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
let entries = fs::read_dir(dir).with_context(|| format!("cannot list {}", dir.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("cannot list {}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
collect(base, &path, out)?;
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.with_context(|| format!("{} is not under {}", path.display(), base.display()))?;
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
struct Fixture {
_tmp: TempDir,
overlay_dir: PathBuf,
root: PathBuf,
}
/// An overlay release laid out the way the tarball is, and an empty ServUO tree.
fn fixture() -> Fixture {
let tmp = TempDir::new("rg-test-overlay").unwrap();
let overlay_dir = tmp.path().join("runicgateway-overlay");
let root = tmp.path().join("ServUO");
let bridge = overlay_dir
.join("overlay")
.join("Scripts")
.join("Custom")
.join("Bridge");
fs::create_dir_all(&bridge).unwrap();
fs::create_dir_all(overlay_dir.join("overlay").join("Config")).unwrap();
fs::write(
overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\n",
)
.unwrap();
fs::write(
overlay_dir
.join("overlay")
.join("Scripts")
.join("Scripts.csproj"),
b"<Project/>\n",
)
.unwrap();
for i in 0..5 {
fs::write(bridge.join(format!("Bridge{i}.cs")), format!("// {i}\n")).unwrap();
}
fs::create_dir_all(&root).unwrap();
Fixture {
_tmp: tmp,
overlay_dir,
root,
}
}
fn action_of<'a>(planned: &'a [PlannedFile], rel: &str) -> &'a PlannedFile {
planned.iter().find(|f| f.rel == rel).expect(rel)
}
#[test]
fn a_first_install_adds_everything() {
let fx = fixture();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
let summary = summarize(&planned);
assert_eq!(summary.add, 7);
assert_eq!(summary.change + summary.unchanged + summary.kept, 0);
apply(&planned).unwrap();
assert!(fx.root.join("Config").join("Bridge.cfg").is_file());
assert!(fx
.root
.join("Scripts")
.join("Custom")
.join("Bridge")
.join("Bridge0.cs")
.is_file());
}
#[test]
fn a_second_run_with_no_upstream_change_writes_nothing() {
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
let second = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
let summary = summarize(&second);
assert_eq!(summary.unchanged, 7);
assert!(!summary.writes_anything());
assert!(
render(&second).is_empty(),
"an unchanged run prints no file lines"
);
// ...and it must produce a byte-identical record, or install.json would be rewritten on
// every run — "reports unchanged and writes nothing" is the requirement, and a file map
// that recorded `add` the first time and `unchanged` the second would quietly break it.
assert_eq!(records, file_records(&second));
}
#[test]
fn code_files_are_always_overwritten() {
// A hand-edited .cs file or Scripts.csproj is a stale copy that breaks the build, and
// ServUO will not say so — it reloads the previous Scripts.dll and boots clean.
let fx = fixture();
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
let csproj = fx.root.join("Scripts").join("Scripts.csproj");
fs::write(&csproj, b"<Project> hand edited </Project>\n").unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
assert_eq!(
action_of(&planned, "Scripts/Scripts.csproj").action,
Action::Change
);
apply(&planned).unwrap();
assert_eq!(fs::read(&csproj).unwrap(), b"<Project/>\n");
}
#[test]
fn an_edited_bridge_cfg_is_kept_even_when_the_release_moved_on() {
// The deviation from deploy.ps1: overwriting here would silently revert LinkUrl,
// PublicConnectAddress and every sweep interval on an update.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
let deployed = fx.root.join("Config").join("Bridge.cfg");
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
// ...and the release ships a new default too, so this is not merely "no upstream change".
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
let cfg = action_of(&planned, "Config/Bridge.cfg");
assert_eq!(cfg.action, Action::KeptOperatorModified);
apply(&planned).unwrap();
assert_eq!(
fs::read(&deployed).unwrap(),
b"LinkUrl=https://myshard.example/link\n",
"the operator's file must survive"
);
// And the record keeps the operator's hash, not the release's — otherwise the next run
// would conclude the operator had never touched it and overwrite on the run after that.
let records = file_records(&planned);
let record = &records["Config/Bridge.cfg"];
assert_ne!(record.on_disk_sha256, record.overlay_sha256);
assert_eq!(record.state, "kept-operator-modified");
}
#[test]
fn a_kept_bridge_cfg_stays_kept_run_after_run() {
// The rule has to survive its own bookkeeping. Once a file is kept, the record holds the
// operator's hash as what is on disk — so a rule that asked "is the tree still what the
// record last saw?" would answer yes on the next run and overwrite the very file it had
// just protected. Three runs, because the bug only appears from the second one on.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let mut records = file_records(&first);
let deployed = fx.root.join("Config").join("Bridge.cfg");
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
for run in 2..=4 {
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::KeptOperatorModified,
"run {run} must still keep the operator's file"
);
apply(&planned).unwrap();
assert_eq!(
fs::read(&deployed).unwrap(),
b"LinkUrl=https://myshard.example/link\n",
"run {run} overwrote the operator's file"
);
records = file_records(&planned);
}
}
#[test]
fn an_untouched_bridge_cfg_takes_the_upstream_change() {
// The other half of the rule: if what is on disk is exactly what was deployed, the
// operator has not edited it and a new default may land.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::Change
);
}
#[test]
fn a_hand_installed_tree_with_no_record_keeps_its_bridge_cfg() {
// INSTALL.md Appendix A tells operators to deploy by hand today. When the installer later
// arrives on such a host there is no record to compare against, and the safe reading of an
// unknown edit is that it is the operator's.
let fx = fixture();
fs::create_dir_all(fx.root.join("Config")).unwrap();
fs::write(
fx.root.join("Config").join("Bridge.cfg"),
b"LinkUrl=https://myshard.example/link\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::KeptOperatorModified
);
}
#[test]
fn nothing_outside_the_overlay_is_touched() {
let fx = fixture();
let stranger = fx.root.join("Scripts").join("Custom").join("MyShard.cs");
fs::create_dir_all(stranger.parent().unwrap()).unwrap();
fs::write(&stranger, b"// mine\n").unwrap();
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
assert_eq!(fs::read(&stranger).unwrap(), b"// mine\n");
}
#[test]
fn a_directory_of_identical_actions_collapses_to_one_line() {
let fx = fixture();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
let lines = render(&planned);
assert!(
lines
.iter()
.any(|l| l.contains("Scripts/Custom/Bridge/*.cs") && l.contains("(5 files)")),
"{lines:#?}"
);
// The single-file entries stay individually visible — Scripts.csproj overwriting a stock
// file is exactly what must not get folded away.
assert!(
lines.iter().any(|l| l.contains("Scripts/Scripts.csproj")),
"{lines:#?}"
);
}
#[test]
fn the_manifest_check_catches_a_tampered_payload() {
let fx = fixture();
let cfg_rel = "overlay/Config/Bridge.cfg";
let manifest = Manifest {
component: "servuo-plugins-overlay".into(),
version: "0.1.1".into(),
commit: "3a52abb".into(),
repo: "RunicGateway/servuo-plugins".into(),
protocol: 3,
servuo: ServUoCompat {
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(),
)]),
};
verify_payload(&fx.overlay_dir, &manifest).unwrap();
fs::write(fx.overlay_dir.join(cfg_rel), b"tampered\n").unwrap();
let err = verify_payload(&fx.overlay_dir, &manifest)
.unwrap_err()
.to_string();
assert!(err.contains("modified: overlay/Config/Bridge.cfg"), "{err}");
fs::remove_file(fx.overlay_dir.join(cfg_rel)).unwrap();
let err = verify_payload(&fx.overlay_dir, &manifest)
.unwrap_err()
.to_string();
assert!(err.contains("missing: overlay/Config/Bridge.cfg"), "{err}");
}
}