feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s

Adds the Rust crate at the repo root and implements `install` end to end for
the overlay half of a deployment: resolve the published bundle, find and
validate the ServUO root, refuse to deploy under a running shard, sync the
plugin overlay, and record what was deployed in install.json.

`doctor`, `update` and `uninstall` parse and answer with the phase they arrive
in rather than "unrecognized command", and the run states plainly that the
uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed —
`--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet
completion would be read as a patched shard.

Landing on `edge` rather than `main`: release.yml publishes a binary on every
push to main, and an installer that deploys the overlay but cannot install the
sidecar is not something to hand an operator. pr-checks.yml now gates PRs into
edge on the same rules, so the branch the work happens on is not the ungated
one.

Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1:

- The code lives in a library called `rgdeploy` with a thin binary that keeps
  the published name. Windows' UAC installer detection refuses to launch an
  unsigned executable whose file name contains "install" (os error 740), and
  Cargo names test harnesses after their target — so a target under that name
  makes `cargo test` unrunnable on Windows.
- The running-shard check matches processes by path, not by process name:
  on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and
  a name match would report "not running" for a shard that is running.
- install.json records a state (`deployed` / `kept-operator-modified`), not the
  run's verb, so an unchanged re-run produces an identical record and writes
  nothing.
- The Bridge.cfg keep rule compares against the hash the installer last
  deployed, not the last hash it saw — otherwise a kept file is overwritten on
  the very next run.
- Downloads are verified against the bundle's SHA256 while being written, then
  every extracted file is re-hashed against the release's own manifest.json,
  whose protocol and version are cross-checked against the bundle.

Verified against a real ServUO 57.4 tree and end to end into a scratch tree:
24 files deployed, an unchanged re-run that writes nothing, an edited
Bridge.cfg kept across repeated runs while code files are overwritten, bundle
pinning, and a refusal with a shard running out of the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:58:17 -05:00
parent 0e7d5f3bee
commit dff4ad41c9
17 changed files with 4106 additions and 21 deletions

696
src/overlay.rs Normal file
View File

@@ -0,0 +1,696 @@
//! 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,
/// 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(),
},
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}");
}
}