Files
installer/src/servuo.rs
wtclaude dff4ad41c9
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
feat(installer): implement Phase 1 — the installer core
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>
2026-08-04 14:58:17 -05:00

403 lines
15 KiB
Rust

//! Finding, validating and interrogating a ServUO installation.
//!
//! Three questions, in the order the installer asks them:
//!
//! 1. **Where is it?** `--servuo`, else detection from where the binary was run, else a prompt.
//! 2. **Is it really one?** `ServUO.exe`, `Scripts/` and `Config/` must all be present
//! (INSTALL.md §2). Deploying 24 files into a directory that merely looked plausible is a mess
//! to unpick by hand.
//! 3. **Is it running?** If it is, the run stops. `deploy.ps1` hard-throws here and the installer
//! inherits that (PLAN.md §2.5): ServUO holds `Scripts.dll` open and rewrites `Saves/` on exit,
//! so deploying underneath it corrupts one or both.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// The version everything is designed, built and tested against (PLAN.md §2.2.2).
pub const SUPPORTED_VERSION: &str = "57.4";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServUoRoot {
pub path: PathBuf,
/// `None` when `Server/AssemblyInfo.cs` is absent or unparseable. Reported as "unknown", which
/// is treated exactly like any other non-57.4 answer: the base install proceeds, and the patch
/// tier (Phase 3) takes its unsupported path.
pub version: Option<String>,
}
impl ServUoRoot {
/// Whether this tree is the one supported version. `None` (unknown) is deliberately **not**
/// supported: an unreadable version is not evidence of a good one.
pub fn is_supported_version(&self) -> bool {
self.version
.as_deref()
.map(normalize_version)
.as_deref()
.map(|v| v == SUPPORTED_VERSION)
.unwrap_or(false)
}
pub fn version_display(&self) -> String {
self.version.clone().unwrap_or_else(|| "unknown".into())
}
}
/// Validates a candidate directory and reads its version.
pub fn open(path: &Path) -> Result<ServUoRoot> {
if !path.exists() {
bail!("no such directory: {}", path.display());
}
if !path.is_dir() {
bail!("not a directory: {}", path.display());
}
if !looks_like_root(path) {
bail!(
"{} does not look like a ServUO root — it must contain ServUO.exe, Scripts/ and Config/",
path.display()
);
}
// Canonicalized so the path recorded in install.json is stable across runs started from
// different working directories. Windows' \\?\ prefix is stripped: it is correct but appears
// in every printed line and in the operator's copy-pasted report.
let path = fs::canonicalize(path)
.map(strip_extended_prefix)
.unwrap_or_else(|_| path.to_path_buf());
let version = read_version(&path);
Ok(ServUoRoot { path, version })
}
/// The membership test from INSTALL.md §2 — all three, not any.
pub fn looks_like_root(path: &Path) -> bool {
path.join("ServUO.exe").is_file()
&& path.join("Scripts").is_dir()
&& path.join("Config").is_dir()
}
/// Looks for a ServUO root around where the installer was run.
///
/// "Run from inside it or from an obvious sibling" (INSTALL.md §2) means: the working directory or
/// one of its parents, then the directory holding the binary or one of its parents — an operator
/// who `scp`'d the installer into the server root and ran it there should not be asked where the
/// server root is. Parents are walked because `cd Scripts && ../installer` is a normal thing to do.
/// Nothing outside those two chains is searched: guessing at unrelated directories on the host is
/// how a tool deploys into the wrong shard.
pub fn detect() -> Option<PathBuf> {
let mut starts: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
starts.push(cwd);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
starts.push(dir.to_path_buf());
}
}
for start in starts {
// Four levels is enough for Scripts/Custom/Bridge and nothing like enough to wander into
// an unrelated tree.
let mut candidate: &Path = &start;
for _ in 0..5 {
if looks_like_root(candidate) {
return Some(candidate.to_path_buf());
}
match candidate.parent() {
Some(parent) => candidate = parent,
None => break,
}
}
}
None
}
/// Reads the version from `Server/AssemblyInfo.cs`.
///
/// The source file rather than `ServUO.exe`'s PE metadata: it is the same *source* tree the patch
/// tier diffs against, it works identically on Linux and Windows, and it costs no dependency. The
/// exe describes whenever the core was last built, which on a tree mid-upgrade is a different — and
/// less relevant — answer.
fn read_version(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("Server").join("AssemblyInfo.cs")).ok()?;
parse_assembly_version(&text)
}
/// Extracts `57.4` from `[assembly: AssemblyVersion("57.4")]`.
///
/// Hand-parsed rather than regex'd (no dependency for one pattern), and tolerant of the whitespace
/// and attribute-ordering variations that show up across forks. Commented-out lines are skipped:
/// ServUO's own file has none, but a fork that left an old declaration behind would otherwise hand
/// back a version nobody is running.
pub fn parse_assembly_version(source: &str) -> Option<String> {
for line in source.lines() {
let line = line.trim();
if line.starts_with("//") {
continue;
}
let Some(rest) = line.split_once("AssemblyVersion").map(|(_, r)| r) else {
continue;
};
let Some(open) = rest.find('"') else { continue };
let Some(close) = rest[open + 1..].find('"') else {
continue;
};
let value = &rest[open + 1..open + 1 + close];
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
/// Drops trailing `.0` components so `57.4.0.0` and `57.4` compare equal.
///
/// ServUO declares `57.4` in source while .NET reports `57.4.0.0`; both name the same release, and
/// an operator should not be told their supported tree is unsupported over padding.
pub fn normalize_version(raw: &str) -> String {
let parts: Vec<&str> = raw.trim().split('.').collect();
let mut end = parts.len();
while end > 1 && parts[end - 1] == "0" {
end -= 1;
}
parts[..end].join(".")
}
#[cfg(windows)]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(stripped) => PathBuf::from(stripped),
None => path,
}
}
#[cfg(not(windows))]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
path
}
/// A ServUO process found running out of the tree being deployed into.
#[derive(Debug, Clone)]
pub struct RunningShard {
pub pid: u32,
pub detail: String,
}
/// Refuses to proceed if a shard is running out of `root`.
///
/// Matched by **executable and command-line path**, not by process name. `deploy.ps1` can look for
/// a process called `ServUO` because it only ever runs on Windows; on Linux the same shard appears
/// as `mono` or `dotnet` with `ServUO.exe` as an argument, and a name match would return "not
/// running" for a shard that is very much running — the one wrong answer that corrupts a live
/// `Scripts.dll`. Scoping to processes under *this* root also means a second shard on the same host
/// does not block a deploy into the first.
pub fn find_running(root: &Path) -> Option<RunningShard> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let root_str = normalize_for_match(&root.to_string_lossy());
for (pid, process) in system.processes() {
// Own process first: the installer may well have been copied into the server root, and
// matching itself would make every run refuse to start.
if pid.as_u32() == std::process::id() {
continue;
}
let exe = process
.exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let args: Vec<String> = process
.cmd()
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let hay = normalize_for_match(&format!("{exe} {}", args.join(" ")));
// Two conditions, both required: something in this process names the tree, AND it names
// the ServUO assembly. Either alone over-matches — an editor with the path open, or a
// different shard's ServUO.exe.
if hay.contains(&root_str) && hay.contains("servuo.exe") {
let detail = if exe.is_empty() { args.join(" ") } else { exe };
return Some(RunningShard {
pid: pid.as_u32(),
detail,
});
}
}
None
}
/// Lower-cases and unifies separators so a Windows path compares equal however it was spelled.
fn normalize_for_match(s: &str) -> String {
s.to_lowercase().replace('\\', "/")
}
/// Builds the refusal message. Separate from [`find_running`] so the wording is testable and so
/// callers cannot accidentally soften it.
pub fn running_error(root: &Path, shard: &RunningShard) -> anyhow::Error {
anyhow::anyhow!(
"ServUO is running from {} (pid {} — {}).\n\
Stop the shard before installing. ServUO.exe holds Scripts.dll open and rewrites Saves/ \
on exit, so deploying underneath it corrupts one or both. This is not overridable.",
root.display(),
shard.pid,
shard.detail
)
}
/// Convenience wrapper used by the commands: validate the path, then refuse if it is in use.
pub fn open_stopped(path: &Path) -> Result<ServUoRoot> {
let root =
open(path).with_context(|| format!("cannot use {} as a ServUO root", path.display()))?;
if let Some(shard) = find_running(&root.path) {
return Err(running_error(&root.path, &shard));
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn fake_root(dir: &Path) {
fs::create_dir_all(dir.join("Scripts")).unwrap();
fs::create_dir_all(dir.join("Config")).unwrap();
fs::create_dir_all(dir.join("Server")).unwrap();
fs::write(dir.join("ServUO.exe"), b"MZ").unwrap();
}
#[test]
fn all_three_markers_are_required() {
let tmp = TempDir::new("rg-test-servuo").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
assert!(looks_like_root(&root));
for missing in ["ServUO.exe", "Scripts", "Config"] {
let partial = tmp.path().join(format!("partial-{missing}"));
fake_root(&partial);
let victim = partial.join(missing);
if victim.is_dir() {
fs::remove_dir_all(&victim).unwrap();
} else {
fs::remove_file(&victim).unwrap();
}
assert!(
!looks_like_root(&partial),
"a tree without {missing} must not qualify"
);
assert!(open(&partial).is_err());
}
}
#[test]
fn the_version_comes_from_assembly_info() {
let tmp = TempDir::new("rg-test-version").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
fs::write(
root.join("Server").join("AssemblyInfo.cs"),
"using System.Reflection;\n[assembly: AssemblyTitle(\"ServUO\")]\n[assembly: AssemblyVersion(\"57.4\")]\n",
)
.unwrap();
let opened = open(&root).unwrap();
assert_eq!(opened.version.as_deref(), Some("57.4"));
assert!(opened.is_supported_version());
}
#[test]
fn an_unreadable_version_is_unknown_and_unsupported() {
// "Unknown" must not be optimistically treated as 57.4: an unreadable version is not
// evidence of a good one, and it gates the patch tier in Phase 3.
let tmp = TempDir::new("rg-test-noversion").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
let opened = open(&root).unwrap();
assert_eq!(opened.version, None);
assert!(!opened.is_supported_version());
assert_eq!(opened.version_display(), "unknown");
}
#[test]
fn assembly_version_parsing_handles_real_world_spellings() {
assert_eq!(
parse_assembly_version("[assembly: AssemblyVersion(\"57.4\")]").as_deref(),
Some("57.4")
);
assert_eq!(
parse_assembly_version("[ assembly : AssemblyVersion ( \"57.4.0.0\" ) ]").as_deref(),
Some("57.4.0.0")
);
// A fork that left an old declaration commented out must not win.
assert_eq!(
parse_assembly_version(
"// [assembly: AssemblyVersion(\"56.0\")]\n[assembly: AssemblyVersion(\"57.4\")]"
)
.as_deref(),
Some("57.4")
);
assert_eq!(parse_assembly_version("no version here"), None);
assert_eq!(
parse_assembly_version("[assembly: AssemblyVersion(\"\")]"),
None
);
}
#[test]
fn dotnet_padding_does_not_make_a_supported_tree_unsupported() {
assert_eq!(normalize_version("57.4.0.0"), "57.4");
assert_eq!(normalize_version("57.4"), "57.4");
assert_eq!(normalize_version("0.0.0"), "0");
// Padding is stripped; a genuinely different version still differs.
assert_ne!(normalize_version("57.40"), SUPPORTED_VERSION);
}
#[test]
fn detection_finds_a_root_from_a_subdirectory() {
let tmp = TempDir::new("rg-test-detect").unwrap();
let root = tmp.path().join("ServUO");
fake_root(&root);
let deep = root.join("Scripts").join("Custom");
fs::create_dir_all(&deep).unwrap();
// detect() reads the process's working directory, so exercise the walk directly on the
// same chain it uses rather than mutating global state inside a threaded test runner.
let mut candidate: &Path = &deep;
let mut found = None;
for _ in 0..5 {
if looks_like_root(candidate) {
found = Some(candidate.to_path_buf());
break;
}
candidate = candidate.parent().unwrap();
}
assert_eq!(found.as_deref(), Some(root.as_path()));
}
#[test]
fn the_refusal_says_why_and_offers_no_override() {
let shard = RunningShard {
pid: 4242,
detail: "/opt/ServUO/ServUO.exe".into(),
};
let msg = running_error(Path::new("/opt/ServUO"), &shard).to_string();
assert!(msg.contains("4242"), "{msg}");
assert!(msg.contains("Scripts.dll"), "{msg}");
assert!(msg.contains("not overridable"), "{msg}");
}
#[test]
fn nothing_is_running_out_of_an_empty_tree() {
// Also proves the scan does not match the test binary itself, which is the failure mode
// that would make every install refuse to start.
let tmp = TempDir::new("rg-test-running").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
assert!(find_running(&root).is_none());
}
}