//! 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, /// SHA256 per shipped file, keyed `overlay/...` and `patches/...`. pub files: BTreeMap, } /// 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, } #[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 { 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 { 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>, ) -> Result> { 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 { 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 { 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| { 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) -> 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"\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" hand edited \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"\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}"); } }