All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m8s
Five comments described the patch tier as work a later phase would do. It is this phase, so they read as stale the moment the code landed. Co-Authored-By: Claude <noreply@anthropic.com>
403 lines
15 KiB
Rust
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 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 is what gates the patch tier.
|
|
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());
|
|
}
|
|
}
|