//! Where the installer's own files live. //! //! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the //! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its //! working directory, and a service manager's working directory is not somewhere to put a database. //! The installer therefore owns the layout and pins the config path into the service definition. //! //! **How the database path is pinned differs by platform, and that is not an inconsistency.** The //! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml` //! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the //! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the //! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries //! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which //! every process on the host inherits and which outlives an uninstall. use std::env; use std::path::PathBuf; /// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than /// hidden: an undocumented environment variable that moves where a tool writes is worse than a /// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote. pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Layout { /// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`. pub state_dir: PathBuf, /// `/var/lib/runicgateway` — the sidecar's SQLite store. pub data_dir: PathBuf, /// `/usr/bin/runicgateway-link` — the installed sidecar binary. pub sidecar_bin: PathBuf, /// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust /// instance on the host (docs/modules/rust/PLAN.md §34.2.3). pub rust_sidecar_bin: PathBuf, /// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real /// deployment. Service registration is skipped when it is set — see [`layout`]. pub relocated: bool, } impl Layout { pub fn install_record(&self) -> PathBuf { self.state_dir.join("install.json") } pub fn sidecar_config(&self) -> PathBuf { self.state_dir.join("sidecar.toml") } pub fn sidecar_db(&self) -> PathBuf { self.data_dir.join("uo-link.db") } /// `/etc/runicgateway/patches` — the cached patch set (INSTALL.md §3). /// /// Every patch the tier *evaluated* is cached here, not only the ones that applied. `uninstall` /// needs the applied ones to print the exact hunks to revert long after the release tarball is /// gone (PLAN.md §5), and a refused one is the file the run just told the operator to apply by /// hand — pointing them at a path that only exists on success would be the less useful half. pub fn patches_dir(&self) -> PathBuf { self.state_dir.join("patches") } /// `/etc/runicgateway/patches/originals` — each patched file exactly as it was before the tier /// first touched it, mirroring its path in the ServUO tree. /// /// The tier edits files the operator owns, so the pre-image is what turns "here are the hunks /// we added" into a revert anyone can verify. It lives here rather than beside the file it /// copies, because an installer-owned file inside the ServUO tree is one `uninstall` has /// promised never to clean up. pub fn patch_originals_dir(&self) -> PathBuf { self.patches_dir().join("originals") } /// `/etc/runicgateway/backups` — one dated directory per run that overwrote something /// (PLAN.md §5.3). /// /// Beside the cached patch set rather than inside it: both survive an uninstall and both go /// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy /// of what the *release* shipped. pub fn backups_dir(&self) -> PathBuf { self.state_dir.join("backups") } /// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated /// layout, where no service is registered at all. pub fn systemd_unit(&self) -> PathBuf { PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT) } // ── Rust ───────────────────────────────────────────────────────────────── // // Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a // host running both games has two records that cannot corrupt each other (§34.4) and an // uninstall of one never reaches the other. /// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance. pub fn rust_record(&self) -> PathBuf { self.state_dir.join("rust").join("install.json") } /// Where instance configs live on Linux — the directory the template unit names with `%i`. pub fn rust_config_dir(&self) -> PathBuf { self.state_dir.join("rust") } /// One instance's `sidecar.toml`. /// /// **Linux:** `/etc/runicgateway/rust/.toml`, beside its siblings, because the template /// unit derives the path from the instance name. **Windows:** inside the instance's own /// directory, because a Windows service logs beside its config — two instances sharing a /// directory would share, and fight over, one log file. pub fn rust_config(&self, server_id: &str) -> PathBuf { if cfg!(windows) { self.rust_data_dir(server_id).join("sidecar.toml") } else { self.rust_config_dir().join(format!("{server_id}.toml")) } } /// `/var/lib/runicgateway/rust/` — one instance's store (and, on Windows, its config and /// logs). pub fn rust_data_dir(&self, server_id: &str) -> PathBuf { self.data_dir.join("rust").join(server_id) } /// One instance's SQLite store. Written into its config as an absolute path, so the store never /// depends on anybody's working directory or on a shared default name. pub fn rust_db(&self, server_id: &str) -> PathBuf { self.rust_data_dir(server_id).join("rust-link.db") } /// The shared systemd template for every Rust instance. pub fn rust_template_unit(&self) -> PathBuf { PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT) } } /// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`]. /// /// The override moves **everything the installer would write**: state, data, and the sidecar /// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that /// relocated its config while still dropping a binary into `/usr/bin` would be exactly the /// half-in-the-real-system accident this variable exists to avoid. /// /// A relocated layout also **suppresses service registration** (see `service::ensure`). There is /// no such thing as a relocated systemd unit or a relocated Windows service — both are /// system-global — so the honest behaviour is to install the files, say plainly that no service was /// registered, and print what a real run would have done. pub fn layout() -> Layout { let mut layout = platform_layout(); if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) { let root = PathBuf::from(dir); // The file name is kept so a relocated run installs the same binary name a real one would, // which is what makes `--print-config` and `--version` output comparable between the two. let bin_name = layout .sidecar_bin .file_name() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("uo-link-sidecar")); let rust_bin_name = layout .rust_sidecar_bin .file_name() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("rust-link-sidecar")); layout.data_dir = root.join("data"); layout.sidecar_bin = root.join("bin").join(bin_name); layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name); layout.state_dir = root; layout.relocated = true; } layout } #[cfg(windows)] fn platform_layout() -> Layout { // %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to // C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic. let program_data = env::var_os("ProgramData") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); let program_files = env::var_os("ProgramFiles") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")); // Data lives under ProgramData, never under ProgramFiles: a service writing beneath // C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3). Layout { state_dir: program_data.join("RunicGateway"), data_dir: program_data.join("RunicGateway"), sidecar_bin: program_files .join("RunicGateway") .join("uo-link-sidecar.exe"), rust_sidecar_bin: program_files .join("RunicGateway") .join("rust-link-sidecar.exe"), relocated: false, } } #[cfg(not(windows))] fn platform_layout() -> Layout { Layout { state_dir: PathBuf::from("/etc/runicgateway"), data_dir: PathBuf::from("/var/lib/runicgateway"), sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"), relocated: false, } } #[cfg(test)] mod tests { use super::*; #[test] fn the_installers_own_files_sit_in_the_state_dir() { // Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to // clean and `doctor` has one place to read. The cached patch set and the pre-image // copies of every patched file live there too. let l = platform_layout(); assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path())); assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path())); } #[test] fn the_default_layout_is_absolute() { // A relative state directory would reintroduce exactly the working-directory trap this // layout exists to close. let l = platform_layout(); assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir); assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir); assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin); assert!(!l.relocated); } #[test] fn the_windows_database_lands_beside_its_config_by_default() { // The Windows service pins only the config path; the database follows because the sidecar // anchors a relative [store].path to the config's directory. That only holds while these // two directories are the same one, so it is asserted rather than assumed. #[cfg(windows)] { let l = platform_layout(); assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent()); } } #[test] fn two_rust_instances_share_nothing_but_the_binary() { // The failure this prevents: two instances on one database, one token file, or (on // Windows) one log file. let l = platform_layout(); assert_ne!(l.rust_config("alpha"), l.rust_config("beta")); assert_ne!(l.rust_db("alpha"), l.rust_db("beta")); // A Windows service logs beside its config, so there each instance needs its own directory. if cfg!(windows) { assert_ne!( l.rust_config("alpha").parent(), l.rust_config("beta").parent() ); } assert_ne!(l.rust_record(), l.install_record()); assert_ne!(l.rust_sidecar_bin, l.sidecar_bin); assert!(l.rust_db("alpha").is_absolute()); } #[test] fn a_relocated_layout_moves_the_binary_too() { // The failure this prevents: a test run that writes its config and database under the // override while still dropping a binary into /usr/bin or %ProgramFiles%. let mut l = platform_layout(); let real_bin = l.sidecar_bin.clone(); let root = std::env::temp_dir().join("rg-layout-test"); let bin_name = l.sidecar_bin.file_name().map(PathBuf::from).unwrap(); l.data_dir = root.join("data"); l.sidecar_bin = root.join("bin").join(&bin_name); l.state_dir = root.clone(); l.relocated = true; assert!(l.install_record().starts_with(&root)); assert!(l.sidecar_config().starts_with(&root)); assert!(l.sidecar_db().starts_with(&root)); assert!(l.sidecar_bin.starts_with(&root)); assert_ne!(l.sidecar_bin, real_bin); assert_eq!(l.sidecar_bin.file_name(), real_bin.file_name()); } }