//! The `install` command — and, in [`Mode::Update`], the deployment half of `update`. //! //! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync //! the overlay, run the optional patch tier, install the sidecar and register its service, record //! what was deployed, and print the values the website needs. //! //! **`update` is this same pipeline, not a second one.** PLAN.md §5 Phase 4 describes it as //! "re-resolve the bundle, then move both components to it" — which is what an `install` over an //! existing deployment already does, down to keeping a modified `Bridge.cfg` and restarting the //! service after replacing its binary. Writing it twice would mean two places for the sync rules, //! the protocol cross-checks and the record-carrying logic to disagree. What actually differs is //! decided by [`Mode`] and is small: where the ServUO root comes from, whether a prior record is //! required, how much of the patch tier is in scope, and what is printed at the end. The //! update-only parts live in [`crate::update`]. //! //! The order of the run is not incidental: //! //! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this //! platform, the ServUO root, and whether this process can write where it must. A run that //! cannot finish should end before a single file enters the ServUO tree. //! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree //! happens together, on the near side of the running-shard check that guards it — and the tier //! comes second because a feature's companion `.cs` lands in a directory the overlay creates. //! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes //! the file the service definition points at, so the service is never started against a config //! that does not exist yet. //! 4. **Record last, print the handoff after that.** The token block is the final thing on screen //! because it is the only thing the operator still has to act on. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use crate::cli::Cli; use crate::record::{ now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord, ServUoRef, ServiceRecord, SCHEMA, }; use crate::servuo::ServUoRoot; use crate::util::TempDir; use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; /// Which verb is driving the pipeline. /// /// The two runs are the same deployment; what differs is what may be assumed. An `install` may be /// the first thing that ever ran on this host, so it detects or asks for a ServUO root and offers /// the patch tier. An `update` is by definition a second run, so it already knows the tree, and its /// tier scope is what a previous run recorded rather than a fresh offer (PLAN.md §5 Phase 4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Install, Update, } impl Mode { pub fn is_update(self) -> bool { matches!(self, Self::Update) } } pub fn run(cli: &Cli) -> Result<()> { deploy(cli, Mode::Install) } pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let layout = paths::layout(); let record_path = layout.install_record(); // Loaded before anything else because `update` is defined by it: without a record there is // nothing to update, and the honest answer is to say so before touching the network. let prior = InstallRecord::load(&record_path)?; if mode.is_update() { crate::update::require_prior(prior.as_ref(), &record_path)?; } // ── What to install ────────────────────────────────────────────────────── // The bundle is resolved first, and its sidecar asset looked up immediately, so a run that // cannot be completed fails here — before a single file has entered the ServUO tree. let (bundle, bundle_url) = bundle::fetch(cli.bundle.as_deref())?; let sidecar_asset = bundle.sidecar_asset()?.clone(); println!( "\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}", env!("CARGO_PKG_VERSION"), if mode.is_update() { "update" } else { "install" }, bundle.bundle, bundle.protocol, if cli.verify { " [--verify: nothing will be written]" } else { "" } ); println!(); // ── Where to install it ────────────────────────────────────────────────── let root = resolve_root(cli, mode, prior.as_ref())?; ui::row( "ServUO", &format!("{} ({})", root.path.display(), root.version_display()), ); // Reaching this line means `servuo::open_stopped` found no shard running out of this tree; a // running one has already ended the run. ui::row("Shard process", "not running"); ui::row( "Overlay", &format!( "{:<24} protocol {}", format!("servuo-plugins {}", bundle.overlay.tag), bundle.overlay.protocol ), ); ui::row( "Sidecar", &format!( "{:<24} protocol {}", format!("uo-link {}", bundle.link.tag), bundle.link.protocol ), ); if !root.is_supported_version() { println!(); ui::warn(&format!( "This tree reports ServUO {}. {} is the only supported version.\n \ The base overlay only adds files and is expected to work broadly, so the install \ continues.\n \ The patch tier is the part that is version-sensitive — see INSTALL.md §4.", root.version_display(), servuo::SUPPORTED_VERSION )); } // Everything below writes into system directories. Finding out here beats finding out after // the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot // simply re-run their way out of. if !cli.verify { preflight_writable(&layout)?; } // ── Fetch and unpack the overlay ───────────────────────────────────────── let scratch = TempDir::new("runicgateway-installer")?; let tarball = scratch.path().join(&bundle.overlay.asset.name); println!(); net::download_verified( &bundle.overlay.asset.url, &tarball, &bundle.overlay.asset.sha256, )?; ui::ok(&format!( "overlay tarball verified sha256 {}…", &bundle.overlay.asset.sha256[..8.min(bundle.overlay.asset.sha256.len())] )); let unpacked = overlay::extract(&tarball, &scratch.path().join("unpacked"))?; let manifest = overlay::read_manifest(&unpacked)?; overlay::verify_payload(&unpacked, &manifest)?; // The bundle and the artifact must agree. They are produced by different repos at different // times, and gate 1 of the compose job (PLAN.md §7.1) is what normally keeps them in step — // this is the same check applied to the artifact actually on disk. if manifest.protocol != bundle.overlay.protocol { bail!( "the overlay release declares protocol {} but bundle {} recorded {}. \ Refusing to deploy a pair that was never checked together.", manifest.protocol, bundle.bundle, bundle.overlay.protocol ); } if manifest.version != bundle.overlay.version { bail!( "bundle {} names overlay {} but the downloaded tarball contains {}", bundle.bundle, bundle.overlay.version, manifest.version ); } // ── Plan the sync ──────────────────────────────────────────────────────── let prior_files = prior_overlay_files(prior.as_ref(), &root); let planned = overlay::plan(&unpacked, &root.path, prior_files)?; let summary = overlay::summarize(&planned); ui::heading("Overlay sync"); let lines = overlay::render(&planned); if lines.is_empty() { println!(" (no changes)"); } for line in lines { println!("{line}"); } if cli.verify { println!( "\n VERIFY only. add={} change={} unchanged={} kept={} (nothing written)", summary.add, summary.change, summary.unchanged, summary.kept ); } else { overlay::apply(&planned)?; // "deployed" is claimed only when something actually moved. A run that copied nothing // reporting "deployed" would read as a fresh install to anyone skimming the output. println!( "\n {} add={} change={} unchanged={} kept={}", if summary.writes_anything() { "deployed." } else { "unchanged." }, summary.add, summary.change, summary.unchanged, summary.kept ); } for file in planned .iter() .filter(|f| f.action == overlay::Action::KeptOperatorModified) { println!(); ui::warn(&format!( "{} has local edits — left exactly as it is.\n \ The release ships its own copy of this file; if you want the new defaults, compare \ yours against\n the one in {}\n and merge by hand. \ Every other overlay file is code and is overwritten unconditionally.", file.rel, bundle.overlay.asset.url )); } // ── The patch tier ─────────────────────────────────────────────────────── // After the overlay, because a feature's companion `.cs` lands in the directory the overlay // creates, and before the sidecar, because everything that edits the ServUO tree belongs on the // near side of the running-shard check that guards it. let tier = tier::run( cli, mode, &root, &unpacked, manifest.patch_tier.as_ref(), &layout, &prior .as_ref() .map(|p| p.patch_records()) .unwrap_or_default(), )?; // ── The sidecar and its service ────────────────────────────────────────── let sidecar = install_sidecar( cli, &bundle, &sidecar_asset, &layout, scratch.path(), prior.as_ref(), )?; // ── Record ─────────────────────────────────────────────────────────────── let record = build_record( prior.as_ref(), &Deployment { bundle: &bundle, bundle_url: &bundle_url, root: &root, manifest: &manifest, planned: &planned, sidecar: sidecar.as_ref(), tier: &tier, verify: cli.verify, }, ); if cli.verify { println!("\n {} not written (--verify)", record_path.display()); } else { match prior.as_ref() { Some(previous) if previous.same_deployment_as(&record) => { println!("\n {} unchanged", record_path.display()); } _ => { record.save(&record_path).with_context(|| { format!( "cannot write {} — run as root/Administrator, or set {} to a writable \ directory for a test run", record_path.display(), paths::STATE_DIR_ENV ) })?; println!("\n Recorded {}", record_path.display()); } } } // ── Closing notes ──────────────────────────────────────────────────────── println!(); if cli.verify { // Worded for the verb that was typed, and said exactly once: `update`'s own closing block // deliberately does not repeat it. println!( "Nothing was written. Re-run without --verify to {}.", if mode.is_update() { "update" } else { "deploy" } ); } else if summary.writes_anything() || tier.core_rebuild { if tier.core_rebuild { // Said again here, after everything else, because it is the one step whose omission // produces a shard that boots perfectly and never emits the events it was patched for. println!( "A CORE ServUO file was patched — rebuild the solution before starting:\n \ dotnet build ServUO.sln\n" ); } println!( "Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\ Start your shard when ready; the installer does not start it for you.\n\ Note that ServUO ignores the script build's exit code, so a clean boot is not proof \ the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \ run `[bridge status` in game (INSTALL.md §6)." ); } else { println!("The ServUO tree already has this overlay — nothing was changed there."); } // ── The one manual step ────────────────────────────────────────────────── // Last, and after the record, because it is the only thing left for the operator to do. The // token goes to the terminal and nowhere else (PLAN.md §6). // // An `update` prints none of it. The token has not changed, the website already holds it, and // reprinting a secret that nobody has to act on puts it in one more scrollback for no reason. // What an update *can* change is the protocol number the website is configured with, and // `update::closing` says so when it moved. match (mode, &sidecar) { (Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify), (Mode::Install, Some(sidecar)) => { let host = resolve_host(cli); println!( "{}", sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref()) ); if !sidecar.service.registered() { ui::warn( "No service was registered, so nothing is listening yet — the values above \ describe the sidecar\n once you start it. See the steps printed above.", ); } } (Mode::Install, None) => {} } Ok(()) } /// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection /// (confirmed), else a prompt. /// /// An update never prompts and never guesses. The tree it is updating is the one `install.json` /// names — detection could plausibly find a *different* shard on a host that has two, and moving a /// deployment to another tree is not something an `update` should be able to do by accident. fn resolve_root(cli: &Cli, mode: Mode, prior: Option<&InstallRecord>) -> Result { if let Some(path) = &cli.servuo { return servuo::open_stopped(&PathBuf::from(path)); } if mode.is_update() { if let Some(prior) = prior { return servuo::open_stopped(&PathBuf::from(&prior.servuo.path)); } } if let Some(detected) = servuo::detect() { let question = format!("Use the ServUO installation at {}?", detected.display()); if ui::confirm(&question, true, cli.assume_yes)? { return servuo::open_stopped(&detected); } } else if cli.assume_yes { // --yes cannot invent a path, and picking one would be the worst possible guess. bail!( "no ServUO installation was found near this binary or the working directory. \ Pass --servuo ." ); } let answer = ui::prompt("Path to your ServUO root", None) .context("a ServUO root is required; pass --servuo for an unattended run")?; servuo::open_stopped(&PathBuf::from(answer.trim().trim_matches('"'))) } /// The previous run's file map, but only when it describes *this* tree. /// /// The map is what distinguishes an operator-edited `Bridge.cfg` from an upstream change, and that /// judgement is only meaningful about the tree it was recorded for. A host whose record points at a /// different root — a shard moved or rebuilt beside the old one — is treated as having no prior /// deployment here, which errs toward keeping the operator's file. fn prior_overlay_files<'a>( prior: Option<&'a InstallRecord>, root: &ServUoRoot, ) -> Option<&'a std::collections::BTreeMap> { let prior = prior?; if Path::new(&prior.servuo.path) != root.path { return None; } prior.overlay_files() } /// The sidecar half of a run: binary, config, service. `None` under `--verify`. struct SidecarOutcome { record: LinkRecord, doc: sidecar::ConfigDoc, service: service::Outcome, } /// Installs the sidecar, provisions its config, and registers its service. /// /// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it, /// **then** `--print-config` (which writes the config the service will be pointed at), then /// register. Doing the last two the other way round registers a service against a file that does /// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing /// mistake. fn install_sidecar( cli: &Cli, bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout, scratch: &Path, prior: Option<&InstallRecord>, ) -> Result> { ui::heading("uo-link sidecar"); let action = sidecar::decide(asset, &layout.sidecar_bin)?; ui::row( "binary", &format!("{} {}", layout.sidecar_bin.display(), action.label()), ); if cli.verify { ui::row( "config", &format!( "{} (would be provisioned)", layout.sidecar_config().display() ), ); ui::row("database", &layout.sidecar_db().display().to_string()); println!( "\n VERIFY only. Nothing installed, no service registered, no token read.\n \ The token handoff needs a provisioned config, so it is only printed by a real run." ); return Ok(None); } // Detect the service manager and make sure the account exists *before* a config file is // written that then has to be owned by it. let prepared = service::prepare(layout.relocated); let binary_sha256 = if action.writes() { // The binary is locked on Windows while its service runs, and on Linux replacing it under a // live process leaves the old code serving until something restarts it. service::stop_for_replacement(&prepared.manager)?; let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?; ui::ok(&format!( "sidecar binary verified sha256 {}…", &asset.sha256[..8.min(asset.sha256.len())] )); sha } else { asset.sha256.trim().to_ascii_lowercase() }; std::fs::create_dir_all(&layout.data_dir) .with_context(|| format!("cannot create {}", layout.data_dir.display()))?; let config_path = layout.sidecar_config(); let db_path = layout.sidecar_db(); // The database is pinned by environment only where it does not already land beside the config. // See `crate::paths` for why that is a platform difference rather than an inconsistency. let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path()); let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?; // The installed binary is the only thing that will actually speak to the website, so its // protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number // from source at the release tag; a disagreement means the pair in front of us is not the pair // CI checked, and the sidecar would answer the website with 409 rather than mis-parsing. if doc.protocol != bundle.protocol { bail!( "the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \ Refusing to register a service for a pair that was never checked together — the \ binary is installed but no service has been created.", doc.protocol, bundle.bundle, bundle.protocol ); } if doc.version != bundle.link.version { ui::warn(&format!( "the installed binary reports version {} but bundle {} names {}. The checksum matched, \ so this is a labelling mismatch in the release rather than a wrong download.", doc.version, bundle.bundle, bundle.link.version )); } ui::row( "config", &format!( "{} {}", doc.config_path, if doc.config_created { "created" } else { "already present" } ), ); ui::row("database", &doc.store.path); ui::row( "listening on", &format!("shard {} website {}", doc.shard.bind, doc.web.bind), ); // The config holds the auth token, and neither default location protects it on its own. This // runs before registration so the file is never left readable while the rest of the run // happens; on Windows the service account does not exist until `sc create` creates it, so the // grant for it comes after. service::protect_config( &config_path, &layout.data_dir, prepared.user.as_deref(), layout.relocated, )?; let outcome = service::register(&prepared, layout, action.writes())?; service::grant_service_access(&config_path, &layout.data_dir, &outcome)?; let service_record = match &outcome { service::Outcome::Registered { kind, name, unit_path, user, user_created, state, } => { ui::row("service", &format!("{name} {state}")); if let Some(user) = user { ui::row("running as", user); } Some(ServiceRecord { kind: (*kind).to_string(), name: name.clone(), unit_path: unit_path.as_ref().map(|p| p.display().to_string()), user: user.clone(), user_created: *user_created || created_by_an_earlier_run(prior, user.as_deref()), }) } service::Outcome::Skipped { reason, manual } => { println!(); ui::warn(&format!( "service NOT REGISTERED — {reason}.\n \ The binary and its config are in place; nothing is running them. Do this by hand:" )); print!("{manual}"); None } }; Ok(Some(SidecarOutcome { record: LinkRecord { repo: bundle.link.repo.clone(), tag: bundle.link.tag.clone(), version: doc.version.clone(), protocol: doc.protocol, binary: BinaryRef { path: layout.sidecar_bin.display().to_string(), sha256: binary_sha256, }, config_path: doc.config_path.clone(), db_path: doc.store.path.clone(), service: service_record, }, doc, service: outcome, })) } /// Whether an earlier run of this installer created the service account. /// /// **`user_created` has to be sticky, and this is why.** `service::prepare` answers "did *this run* /// create the account", which is `false` on every run after the first — the account exists by then. /// Recording that verbatim makes the field describe the run instead of the state, with two /// consequences: `install.json` changes on an otherwise-identical second run (breaking Phase 1's /// "a second run writes nothing"), and `uninstall` — which removes only an account it created — /// silently leaves behind the very account this tool added. Both were caught on the first real /// systemd host the installer ever ran on, and neither is visible on Windows, where the SCM's /// virtual account is never "created" by us at all. /// /// Matched on the account *name*: a record naming a different user describes a different account, /// and inheriting `true` from it would authorize deleting one this installer never made. fn created_by_an_earlier_run(prior: Option<&InstallRecord>, user: Option<&str>) -> bool { let (Some(prior), Some(user)) = (prior, user) else { return false; }; prior .link_record() .and_then(|link| link.service) .is_some_and(|service| service.user.as_deref() == Some(user) && service.user_created) } /// Fails the run before it writes anything if this process cannot write where it must. /// /// `create_dir_all` succeeding is not the same question as "can this process write here" — it /// returns `Ok` for a directory that already exists and is read-only to us — so each location is /// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree /// that has already been deployed into. fn preflight_writable(layout: &paths::Layout) -> Result<()> { let bin_dir = layout .sidecar_bin .parent() .unwrap_or(&layout.sidecar_bin) .to_path_buf(); for dir in [&layout.state_dir, &layout.data_dir, &bin_dir] { std::fs::create_dir_all(dir) .and_then(|_| { let probe = dir.join(".runicgateway-write-test"); std::fs::write(&probe, b"")?; std::fs::remove_file(&probe) }) .with_context(|| { format!( "cannot write to {}.\n \ Run this installer as {}, or set {} to a writable directory for a test run \ (no service is registered then).", dir.display(), if cfg!(windows) { "Administrator, from an elevated PowerShell" } else { "root (sudo)" }, paths::STATE_DIR_ENV ) })?; } Ok(()) } /// The hostname the website should use to reach this machine. /// /// Only ever printed, never connected to — which is why a non-interactive run falls back to the /// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard; /// aborting a completed install over an unanswerable prompt costs them the whole run. fn resolve_host(cli: &Cli) -> String { if let Some(host) = &cli.host { return host.clone(); } let detected = sysinfo::System::host_name() .filter(|h| !h.trim().is_empty()) .unwrap_or_else(|| "this-host".to_string()); if cli.assume_yes { return detected; } let answer = ui::prompt( "Hostname your website should use to reach this machine", Some(&detected), ); answer.unwrap_or(detected) } /// Everything one run produced, gathered so the record can be built from a single value. /// /// The three halves are assembled at different points and the record needs all of them, which is /// how this grew a parameter per step. A struct keeps the call site readable and, more usefully, /// makes it obvious at a glance that nothing else feeds `install.json`. struct Deployment<'a> { bundle: &'a bundle::Bundle, bundle_url: &'a str, root: &'a ServUoRoot, manifest: &'a overlay::Manifest, planned: &'a [overlay::PlannedFile], sidecar: Option<&'a SidecarOutcome>, tier: &'a tier::Outcome, /// A dry run reports everything and records nothing, so every "carry the previous value /// through" branch below turns on it. verify: bool, } fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord { let Deployment { bundle, bundle_url, root, manifest, planned, sidecar, tier, verify, } = *run; InstallRecord { schema: SCHEMA, installer: InstallerInfo { version: env!("CARGO_PKG_VERSION").to_string(), }, updated: now_rfc3339(), bundle: BundleRef { tag: bundle.bundle.clone(), protocol: bundle.protocol, url: bundle_url.to_string(), }, servuo: ServUoRef { path: root.path.to_string_lossy().to_string(), version: root.version.clone(), }, overlay: Some(OverlayRecord { repo: manifest.repo.clone(), tag: bundle.overlay.tag.clone(), version: manifest.version.clone(), commit: manifest.commit.clone(), protocol: manifest.protocol, files: overlay::file_records(planned), }), // A `--verify` run installs no sidecar and must not erase the record of one that is // already there; the same reasoning keeps the patch section and any field a // newer installer wrote intact. A record that forgot a running service would make // `doctor` and `uninstall` forget it too. link: match sidecar { Some(outcome) => serde_json::to_value(&outcome.record).ok(), None => prior.and_then(|p| p.link.clone()), }, // The tier's own records replace the section only when it actually ran and wrote. A // declined tier, a refused one, and a `--verify` dry run all leave the previous record // exactly as it was — an install where the operator said "not this time" must not erase // the evidence of patches applied on an earlier one. patches: match (tier.ran && !verify, prior) { (true, _) => tier .records .iter() .filter_map(|r| serde_json::to_value(r).ok()) .collect(), (false, Some(previous)) => previous.patches.clone(), (false, None) => Vec::new(), }, extra: prior.map(|p| p.extra.clone()).unwrap_or_default(), } } #[cfg(test)] mod tests { use super::*; use crate::record::{FileRecord, ServUoRef}; use std::collections::BTreeMap; fn record_for(path: &str) -> InstallRecord { InstallRecord { schema: SCHEMA, installer: InstallerInfo { version: "0.1.0".into(), }, updated: now_rfc3339(), bundle: BundleRef { tag: "2026.08.04".into(), protocol: 3, url: "https://example/current.json".into(), }, servuo: ServUoRef { path: path.into(), version: Some("57.4".into()), }, overlay: Some(OverlayRecord { repo: "RunicGateway/servuo-plugins".into(), tag: "v0.1.1".into(), version: "0.1.1".into(), commit: "3a52abb".into(), protocol: 3, files: BTreeMap::from([( "Config/Bridge.cfg".to_string(), FileRecord { overlay_sha256: "aa".into(), on_disk_sha256: "bb".into(), state: "kept-operator-modified".into(), }, )]), }), link: None, patches: Vec::new(), extra: BTreeMap::new(), } } fn root_at(path: &str) -> ServUoRoot { ServUoRoot { path: PathBuf::from(path), version: Some("57.4".into()), } } #[test] fn an_account_this_installer_created_stays_recorded_as_created() { // Caught on the first real systemd host: `prepare` reports "did THIS run create it", which // is false from the second run on. Recording that verbatim rewrote install.json on an // identical re-run and, worse, left `uninstall` believing the account was somebody else's. let mut record = record_for("/opt/ServUO"); let service = |user: &str, created: bool| ServiceRecord { kind: "systemd".into(), name: "runicgateway-link.service".into(), unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()), user: Some(user.into()), user_created: created, }; let with_service = |record: &mut InstallRecord, service: ServiceRecord| { record.link = serde_json::to_value(LinkRecord { repo: "RunicGateway/link".into(), tag: "v1.1.0".into(), version: "1.1.0".into(), protocol: 3, binary: BinaryRef { path: "/usr/bin/runicgateway-link".into(), sha256: "aa".into(), }, config_path: "/etc/runicgateway/sidecar.toml".into(), db_path: "/var/lib/runicgateway/uo-link.db".into(), service: Some(service), }) .ok(); }; with_service(&mut record, service("runicgateway", true)); assert!(created_by_an_earlier_run( Some(&record), Some("runicgateway") )); // An account this installer found already there stays somebody else's, forever. with_service(&mut record, service("runicgateway", false)); assert!(!created_by_an_earlier_run( Some(&record), Some("runicgateway") )); // A record naming a different account says nothing about this one — inheriting `true` // there would authorize deleting a user this installer never made. with_service(&mut record, service("someone-else", true)); assert!(!created_by_an_earlier_run( Some(&record), Some("runicgateway") )); assert!(!created_by_an_earlier_run(None, Some("runicgateway"))); assert!(!created_by_an_earlier_run(Some(&record), None)); } #[test] fn a_record_for_this_tree_is_used() { let record = record_for("/opt/ServUO"); let files = prior_overlay_files(Some(&record), &root_at("/opt/ServUO")); assert!(files.is_some_and(|f| f.contains_key("Config/Bridge.cfg"))); } #[test] fn a_record_for_a_different_tree_is_ignored() { // Otherwise a second shard on the same host would inherit the first's hashes and could // have its Bridge.cfg overwritten on the strength of a comparison that never applied to it. let record = record_for("/opt/ServUO-old"); assert!(prior_overlay_files(Some(&record), &root_at("/opt/ServUO")).is_none()); assert!(prior_overlay_files(None, &root_at("/opt/ServUO")).is_none()); } }