Files
installer/src/uninstall.rs
wtclaude 7027a78a23
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s
feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:40:11 -05:00

709 lines
28 KiB
Rust

//! The `uninstall` command — remove what the installer exclusively owns, print the rest.
//!
//! PLAN.md §5 is unusually specific about the shape of this command, and the reason is worth
//! keeping in front of whoever edits it: **the installer cannot know what the operator has changed
//! in their own ServUO tree since deployment.** A clever automatic revert — deleting the overlay's
//! files, reversing the patch hunks — would silently eat work that is not ours to judge. So this
//! command draws a hard line:
//!
//! | | |
//! |---|---|
//! | Removed | the sidecar binary, its service entry, `install.json` |
//! | Kept | `sidecar.toml`, `uo-link.db`, the cached patch set and the pre-patch originals (`--purge` drops them) |
//! | Printed, not done | every overlay file in the ServUO tree, and the exact hunks each applied patch added |
//!
//! ## Why the patch cache outlives the uninstall
//!
//! PLAN.md's table put the cached patch set under "removed", but the report this command prints
//! tells the operator to diff their stock files against the pre-patch copies under
//! `patches/originals/` — advice that the same command would have made impossible to follow. The
//! cache and the originals are the only offline record of what the tier changed once the release
//! tarball is gone, so they survive by default and `--purge` is what removes them, alongside the
//! config and the database. The report names every path it left behind.
//!
//! ## Why the report is a file as well as output
//!
//! It is the only thing the operator still needs after this command exits, and it arrives at the
//! end of the longest output the installer ever produces. A terminal's scrollback is not a place to
//! keep the list of files somebody has to go and delete by hand.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::cli::Cli;
use crate::diff::HunkLine;
use crate::record::{now_rfc3339, InstallRecord, LinkRecord};
use crate::{patch, paths, service, ui, util};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.install_record();
println!(
"\nRunic Gateway installer {} — uninstall",
env!("CARGO_PKG_VERSION")
);
let Some(record) = InstallRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no deployment is recorded on this host.\n \
Looked for {}\n \
If this host is installed, this run cannot see its record: run as \
root/Administrator, and set {} to the same value the install used (if any).",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(0);
};
let link = record.link_record();
print_intent(&record, link.as_ref(), &layout, cli.purge);
// Default **no**, because this is the one command that removes a running service and the
// listing above is what the operator is being asked about — a defaulted-yes prompt on a
// destructive action is answered by reflex rather than read.
//
// `--yes` is nevertheless a **yes** here, not "take the default". Everywhere else that flag
// answers an offer the run made (the patch tier, a detected ServUO root), so taking the safe
// default is right. Here the operator typed the destructive verb themselves; reading `--yes` as
// "no" would leave an unattended uninstall with no way to express itself at all, and a script
// that appeared to succeed while removing nothing is the worse of the two failures.
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove what is exclusively ours ──────────────────────────────────────
println!();
ui::heading("Removing");
let mut done: Vec<String> = Vec::new();
let mut problems: Vec<String> = Vec::new();
if let Some(link) = &link {
if let Some(service_record) = &link.service {
let removal = service::remove(service_record);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&link.binary.path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&link.config_path), &mut done, &mut problems);
// The database is removed with its journal and WAL siblings; SQLite writes those beside
// it, and leaving them behind would confuse the next install rather than protect
// anything.
for suffix in ["", "-journal", "-wal", "-shm"] {
let path = PathBuf::from(format!("{}{suffix}", link.db_path));
if path.exists() {
remove_file(&path, &mut done, &mut problems);
}
}
} else {
done.push(format!(
"kept {} and {} (--purge removes them)",
link.config_path, link.db_path
));
}
}
// The report is written before the record is, because it is rendered *from* the record.
let report = render_report(&record, link.as_ref(), &layout, cli.purge);
let report_path = write_report(&report, &layout);
if cli.purge {
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
} else {
if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
}
// Same rule and the same reason as the patch cache: a backup is the only copy of what this
// host had before an upgrade replaced it, and it outlives the deployment that took it.
let backups = crate::backup::list(&layout);
if !backups.is_empty() {
done.push(format!(
"kept {} — {} backup(s) of files earlier runs replaced",
layout.backups_dir().display(),
backups.len()
));
}
}
remove_file(&record_path, &mut done, &mut problems);
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
// ── What only the operator can do ────────────────────────────────────────
print!("{report}");
match &report_path {
Ok(path) => println!("This report is also saved at:\n {}\n", path.display()),
Err(error) => ui::warn(&format!(
"The report above could not be saved to a file ({error}) — copy it out of this \
terminal before you lose it."
)),
}
// A step that could not be carried out is worth an exit code, for the same reason `doctor` has
// one: the run itself succeeded, and only the shell knows whether anybody is reading the
// output. Everything that *could* be removed still was.
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// Says exactly what will happen, before asking. Nothing here touches the disk.
fn print_intent(
record: &InstallRecord,
link: Option<&LinkRecord>,
layout: &paths::Layout,
purge: bool,
) {
println!();
ui::heading("This will remove");
match link {
Some(link) => {
if let Some(service) = &link.service {
println!(" · the {} service", service.name);
if let (Some(user), true) = (service.user.as_deref(), service.user_created) {
println!(" · the {user} account, which the installer created");
}
}
println!(" · {}", link.binary.path);
if purge {
println!(" · {} [--purge]", link.config_path);
println!(" · {} [--purge]", link.db_path);
}
}
None => println!(" · (no sidecar is recorded on this host)"),
}
println!(" · {}", layout.install_record().display());
if purge {
println!(" · {} [--purge]", layout.patches_dir().display());
println!(" · {} [--purge]", layout.backups_dir().display());
}
println!();
ui::heading("This will NOT touch");
println!(" · your ServUO tree — every deployed file is listed for you to delete");
println!(
" · any patched stock file — the hunks to revert are printed with the rung each landed at"
);
println!(" · your shard, which is neither stopped nor started");
if !purge {
if let Some(link) = link {
println!(" · {} (the auth token)", link.config_path);
println!(" · {} (event history)", link.db_path);
}
println!(
" · {} (cached patches and pre-patch originals)",
layout.patches_dir().display()
);
let backups = crate::backup::list(layout);
if !backups.is_empty() {
println!(
" · {} ({} backup(s) of files earlier runs replaced)",
layout.backups_dir().display(),
backups.len()
);
}
}
let _ = record;
println!();
}
/// The report: everything the operator has to finish by hand.
fn render_report(
record: &InstallRecord,
link: Option<&LinkRecord>,
layout: &paths::Layout,
purge: bool,
) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n",
"",
now_rfc3339(),
env!("CARGO_PKG_VERSION"),
""
);
let _ = writeln!(
out,
"ServUO tree {}{}",
record.servuo.path,
record
.servuo
.version
.as_ref()
.map(|v| format!(" ({v})"))
.unwrap_or_default()
);
if let Some(link) = link {
// Stated flatly, with no claim about what this run managed to remove: the report is
// rendered from the record and is about what is *left* to do. A line asserting "(removed)"
// is a line that can be wrong — a binary locked by a still-running process is exactly the
// case where it would be.
let _ = writeln!(out, "uo-link {}", link.version);
}
render_overlay_section(&mut out, record);
render_patch_section(&mut out, record, layout, purge);
render_backup_section(&mut out, layout, purge);
let _ = writeln!(
out,
"The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\
what you have changed in those files since they were deployed. Both lists above are\n\
yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n"
);
out
}
/// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed.
///
/// The flag is the point: an operator deleting this list file by file must not lose their own
/// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those
/// are.
fn render_overlay_section(out: &mut String, record: &InstallRecord) {
let Some(overlay) = &record.overlay else {
return;
};
let root = Path::new(&record.servuo.path);
let _ = writeln!(
out,
"\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\
{} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n",
overlay.files.len(),
overlay.version
);
for (rel, file) in &overlay.files {
let path = patch::join(root, rel);
let note = match util::sha256_file(&path) {
Err(_) => " (already gone)",
Ok(actual) if actual == file.on_disk_sha256 => "",
Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting",
};
let _ = writeln!(out, " {}{note}", path.display());
}
let _ = writeln!(
out,
"\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n"
);
}
/// The exact hunks each applied patch added, rendered from the cached `.patch` files.
///
/// Rendered rather than referenced: the release tarball is long gone by the time somebody reads
/// this, and "apply the reverse of the patch" is not something an operator can do from a filename.
/// The rung each hunk landed at is printed with it, because a `region-match` apply means the
/// surrounding file was already the operator's and deserves a closer look than a stock-hash one.
fn render_patch_section(
out: &mut String,
record: &InstallRecord,
layout: &paths::Layout,
purge: bool,
) {
let features = record.patch_records();
if features.is_empty() {
return;
}
let _ = writeln!(
out,
"\n── Stock ServUO files this installer patched ────────────────────────────────\n"
);
if features.iter().any(|f| f.unsupported_servuo) {
let _ = writeln!(
out,
" ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n",
features
.iter()
.find_map(|f| f.servuo_version.clone())
.unwrap_or_else(|| "unknown".into())
);
}
for feature in &features {
let _ = writeln!(out, " Feature: {}", feature.feature);
for applied in &feature.patches {
let _ = writeln!(
out,
"\n {} → {}\n applied by: {}",
applied.name,
patch::join(Path::new(&record.servuo.path), &applied.target).display(),
applied.rung
);
match render_hunks(layout, &applied.name, &applied.sha256) {
Some(text) => out.push_str(&text),
None => {
let _ = writeln!(
out,
" (the cached copy of this patch could not be read — the hunks it added \
start\n near line {})",
applied
.hunks
.first()
.map(|h| h.matched_line)
.unwrap_or_default()
);
}
}
}
if !feature.companions.is_empty() {
let _ = writeln!(out, "\n Companion files added by this feature:");
for companion in &feature.companions {
let _ = writeln!(
out,
" {}",
patch::join(Path::new(&record.servuo.path), &companion.path).display()
);
}
}
let _ = writeln!(out);
}
let originals = layout.patch_originals_dir();
if purge {
let _ = writeln!(
out,
" The pre-patch copies of these files were removed by --purge, so the lines above are\n\
the only record of what changed.\n"
);
} else if originals.exists() {
let _ = writeln!(
out,
" Each of those files as it was BEFORE the tier first touched it is kept here:\n \
{}\n Diff against it rather than reversing the hunks by eye — after a region-match \
apply the\n rest of the file was already yours.\n",
originals.display()
);
}
}
/// The backups earlier runs took, since this report is the durable record of what was left behind.
///
/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by
/// the time this report is read the run that took it is long out of the scrollback.
fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) {
let backups = crate::backup::list(layout);
if backups.is_empty() {
return;
}
if purge {
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
{} backup(s) of files earlier runs replaced were removed by --purge.
",
backups.len()
);
return;
}
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
Copies of the files earlier runs replaced, newest first. These are kept:
"
);
for dir in &backups {
let count = crate::backup::read_manifest(dir)
.map(|m| m.files.len())
.unwrap_or(0);
let _ = writeln!(out, " {} ({} file(s))", dir.display(), count);
}
let _ = writeln!(
out,
"
Each carries a manifest.json naming where every file came from. Restoring is yours to
do — this tool will not put an old file back over a newer one. `--purge` removes them.
"
);
}
/// Renders one cached patch's added and removed lines, indented for the report.
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
let path = layout.patches_dir().join(format!("{name}.patch"));
let bytes = std::fs::read(&path).ok()?;
if !sha256.is_empty() && util::sha256_bytes(&bytes) != sha256 {
// Not fatal — a re-run with a newer release can legitimately have replaced the cache — but
// the operator should know the text below is not byte-for-byte what was applied.
let parsed = crate::diff::parse(&bytes).ok()?;
let file = parsed.single_file().ok()?;
let mut out = String::from(
" (the cached patch differs from the one recorded; showing the cached copy)\n",
);
out.push_str(&hunk_text(file));
return Some(out);
}
let parsed = crate::diff::parse(&bytes).ok()?;
Some(hunk_text(parsed.single_file().ok()?))
}
fn hunk_text(file: &crate::diff::FilePatch) -> String {
let mut out = String::new();
for hunk in &file.hunks {
let _ = writeln!(
out,
" @@ around line {} @@",
if hunk.old_start > 0 {
hunk.old_start
} else {
1
}
);
for line in &hunk.lines {
let (sign, bytes) = match line {
HunkLine::Context(b) => (' ', b),
HunkLine::Added(b) => ('+', b),
HunkLine::Removed(b) => ('-', b),
};
let _ = writeln!(out, " {sign}{}", String::from_utf8_lossy(bytes));
}
}
out
}
/// Writes the report where the operator ran the command, falling back to the state directory.
///
/// The working directory is the one place they are certainly looking; `/etc/runicgateway` is being
/// emptied by this very command, and a report inside a directory the operator has just been told is
/// gone is a report nobody finds.
fn write_report(report: &str, layout: &paths::Layout) -> Result<PathBuf> {
let name = format!(
"runicgateway-uninstall-{}.txt",
now_rfc3339().replace([':', '-'], "").replace('Z', "")
);
let cwd = std::env::current_dir().unwrap_or_else(|_| layout.state_dir.clone());
let primary = cwd.join(&name);
if util::write_atomic(&primary, report.as_bytes()).is_ok() {
return Ok(primary);
}
let fallback = layout.state_dir.join(&name);
util::write_atomic(&fallback, report.as_bytes()).with_context(|| {
format!(
"cannot write the uninstall report to {}",
fallback.display()
)
})?;
Ok(fallback)
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(error) => {
// A permission error on the sidecar binary is nearly always a running process holding
// it, not an access-control problem: Windows locks a running executable, and a service
// this command knows about was already stopped above. Saying so beats sending the
// operator to look at ACLs.
let hint = if error.kind() == std::io::ErrorKind::PermissionDenied {
"\n If something is still running it — a sidecar started by hand, or a service \
this installer did not register — stop that first and delete the file."
} else {
""
};
problems.push(format!("cannot remove {}: {error}{hint}", path.display()))
}
}
}
fn remove_dir(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
if !path.exists() {
return;
}
match std::fs::remove_dir_all(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(error) => problems.push(format!("cannot remove {}: {error}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch::{AppliedPatch, FeatureRecord, Rebuild};
use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA};
use std::collections::BTreeMap;
const PATCH: &[u8] = b"\
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -10,3 +10,4 @@ public static class CommandLogging
public static void WriteLine()
{
+ BridgeModerationAudit.Raise();
}
";
fn record(root: &Path) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-05T10:00:00Z".into(),
bundle: BundleRef {
tag: "2026.08.04".into(),
protocol: 3,
url: "https://example/current.json".into(),
},
servuo: ServUoRef {
path: root.display().to_string(),
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([
(
"Scripts/Custom/Bridge/BridgeLink.cs".to_string(),
FileRecord {
overlay_sha256: util::sha256_bytes(b"deployed"),
on_disk_sha256: util::sha256_bytes(b"deployed"),
state: "deployed".into(),
},
),
(
"Config/Bridge.cfg".to_string(),
FileRecord {
overlay_sha256: util::sha256_bytes(b"shipped"),
on_disk_sha256: util::sha256_bytes(b"mine"),
state: "kept-operator-modified".into(),
},
),
]),
}),
link: None,
patches: vec![serde_json::to_value(FeatureRecord {
feature: "moderation-audit".into(),
rebuild: Rebuild::Scripts,
servuo_version: Some("57.4".into()),
unsupported_servuo: false,
patches: vec![AppliedPatch {
name: "commandlogging-event".into(),
target: "Scripts/Commands/Logging.cs".into(),
rung: "region-match".into(),
sha256: util::sha256_bytes(PATCH),
hunks: Vec::new(),
}],
companions: Vec::new(),
})
.unwrap()],
extra: BTreeMap::new(),
}
}
fn layout_in(dir: &Path) -> paths::Layout {
paths::Layout {
state_dir: dir.to_path_buf(),
data_dir: dir.join("data"),
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"),
relocated: true,
}
}
#[test]
fn the_report_lists_every_overlay_file_and_flags_the_edited_ones() {
let dir = util::TempDir::new("rg-test-uninstall").unwrap();
let root = dir.path().join("ServUO");
std::fs::create_dir_all(root.join("Scripts/Custom/Bridge")).unwrap();
std::fs::create_dir_all(root.join("Config")).unwrap();
std::fs::write(
root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
b"deployed",
)
.unwrap();
// Edited after deployment: the operator must be warned before deleting this one.
std::fs::write(root.join("Config/Bridge.cfg"), b"changed again").unwrap();
let layout = layout_in(dir.path());
let record = record(&root);
let report = render_report(&record, None, &layout, false);
assert!(report.contains("BridgeLink.cs"), "{report}");
assert!(
report.contains("EDITED SINCE DEPLOYMENT"),
"the edited Bridge.cfg must be flagged:\n{report}"
);
}
#[test]
fn the_report_renders_the_hunks_from_the_cached_patch() {
// The whole reason the tier caches its patches: this text has to be produceable long after
// the release tarball is gone.
let dir = util::TempDir::new("rg-test-uninstall-hunks").unwrap();
let layout = layout_in(dir.path());
std::fs::create_dir_all(layout.patches_dir()).unwrap();
std::fs::write(
layout.patches_dir().join("commandlogging-event.patch"),
PATCH,
)
.unwrap();
let report = render_report(&record(&dir.path().join("ServUO")), None, &layout, false);
assert!(report.contains("commandlogging-event"), "{report}");
assert!(
report.contains("+ BridgeModerationAudit.Raise();"),
"the added line must appear verbatim:\n{report}"
);
assert!(report.contains("region-match"), "{report}");
}
#[test]
fn a_missing_patch_cache_degrades_to_a_line_number() {
// --purge on an earlier run, or a hand-cleaned /etc: the report still has to say something
// useful rather than claim there was nothing to revert.
let dir = util::TempDir::new("rg-test-uninstall-nocache").unwrap();
let report = render_report(
&record(&dir.path().join("ServUO")),
None,
&layout_in(dir.path()),
false,
);
assert!(report.contains("could not be read"), "{report}");
assert!(report.contains("commandlogging-event"), "{report}");
}
#[test]
fn the_report_is_written_where_the_operator_is_standing() {
let dir = util::TempDir::new("rg-test-uninstall-report").unwrap();
let path = write_report("hello", &layout_in(dir.path())).unwrap();
assert!(path.is_file());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with("runicgateway-uninstall-"),
"{path:?}"
);
let _ = std::fs::remove_file(path);
}
}