Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude <noreply@anthropic.com>
704 lines
27 KiB
Rust
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}");
|
|
}
|
|
}
|