//! 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, } 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 { 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 { let mut starts: Vec = 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 { 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 { 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 { 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 = 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 { 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()); } }