From dff4ad41c9eec1ae93b90c7d314c50de9ad2d1d0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 14:58:17 -0500 Subject: [PATCH 01/13] =?UTF-8?q?feat(installer):=20implement=20Phase=201?= =?UTF-8?q?=20=E2=80=94=20the=20installer=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Rust crate at the repo root and implements `install` end to end for the overlay half of a deployment: resolve the published bundle, find and validate the ServUO root, refuse to deploy under a running shard, sync the plugin overlay, and record what was deployed in install.json. `doctor`, `update` and `uninstall` parse and answer with the phase they arrive in rather than "unrecognized command", and the run states plainly that the uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed — `--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet completion would be read as a patched shard. Landing on `edge` rather than `main`: release.yml publishes a binary on every push to main, and an installer that deploys the overlay but cannot install the sidecar is not something to hand an operator. pr-checks.yml now gates PRs into edge on the same rules, so the branch the work happens on is not the ungated one. Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1: - The code lives in a library called `rgdeploy` with a thin binary that keeps the published name. Windows' UAC installer detection refuses to launch an unsigned executable whose file name contains "install" (os error 740), and Cargo names test harnesses after their target — so a target under that name makes `cargo test` unrunnable on Windows. - The running-shard check matches processes by path, not by process name: on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and a name match would report "not running" for a shard that is running. - install.json records a state (`deployed` / `kept-operator-modified`), not the run's verb, so an unchanged re-run produces an identical record and writes nothing. - The Bridge.cfg keep rule compares against the hash the installer last deployed, not the last hash it saw — otherwise a kept file is overwritten on the very next run. - Downloads are verified against the bundle's SHA256 while being written, then every extracted file is re-hashed against the release's own manifest.json, whose protocol and version are cross-checked against the bundle. Verified against a real ServUO 57.4 tree and end to end into a scratch tree: 24 files deployed, an unchanged re-run that writes nothing, an edited Bridge.cfg kept across repeated runs while code files are overwritten, bundle pinning, and a refusal with a shard running out of the tree. Co-Authored-By: Claude --- .gitea/workflows/pr-checks.yml | 19 +- .gitea/workflows/release.yml | 12 +- Cargo.lock | 941 +++++++++++++++++++++++++++++++++ Cargo.toml | 59 +++ README.md | 48 +- src/bundle.rs | 228 ++++++++ src/cli.rs | 336 ++++++++++++ src/install.rs | 430 +++++++++++++++ src/lib.rs | 127 +++++ src/main.rs | 6 + src/net.rs | 117 ++++ src/overlay.rs | 696 ++++++++++++++++++++++++ src/paths.rs | 113 ++++ src/record.rs | 265 ++++++++++ src/servuo.rs | 402 ++++++++++++++ src/ui.rs | 123 +++++ src/util.rs | 205 +++++++ 17 files changed, 4106 insertions(+), 21 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/bundle.rs create mode 100644 src/cli.rs create mode 100644 src/install.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/net.rs create mode 100644 src/overlay.rs create mode 100644 src/paths.rs create mode 100644 src/record.rs create mode 100644 src/servuo.rs create mode 100644 src/ui.rs create mode 100644 src/util.rs diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index ad34fa1..dcd9678 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -7,12 +7,12 @@ # The one structural difference is the crate guard below. # # ── Crate guard ────────────────────────────────────────────────────────────── -# This repo is in the planning phase and has no Cargo project yet (the design of -# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather -# than leave the repo ungated until then — or land a workflow that red-Xes every -# governance/docs PR — the gates are conditional on a root Cargo.toml existing. -# Before the crate lands, the job reports green with a notice. The moment -# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change. +# The gates are conditional on a root Cargo.toml existing: before the crate +# landed, this job reported green with a notice so governance/docs PRs were not +# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on +# `edge`, so the gates arm themselves there automatically — and stay dormant on +# a `main` PR until the cutover merges the crate into it. Nothing here changes at +# that point either. # # The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar): # this repo's sole product is the one installer binary, so there is nothing to @@ -35,7 +35,12 @@ name: PR Checks on: pull_request: - branches: [main] + # `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that + # release.yml — which fires on every push to `main` — does not publish an installer binary that + # can deploy the overlay but not yet install the sidecar. Ungating the branch where all the + # work actually happens would leave the gates running only at the cutover, which is the one + # moment a red build is most expensive. + branches: [main, edge] # A newer push to the same PR cancels the in-flight run. concurrency: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bd85c6a..fdb3457 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,10 +21,14 @@ # • Artifact names follow docs/installer/PLAN.md §3. # # ── Crate guard ────────────────────────────────────────────────────────────── -# The repo is in the planning phase. With no Cargo.toml there is nothing to -# build, so the plan step forces RELEASE=false and the job exits green having -# done nothing. It starts cutting real releases the moment Phase 1 lands the -# crate — no edit required here. +# With no Cargo.toml at the repo root there is nothing to build, so the plan step +# forces RELEASE=false and the job exits green having done nothing. +# +# That guard is what makes the `edge` branch work. Phase 1 (installer core) and +# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this +# workflow keeps standing down — an installer binary that syncs the overlay but +# cannot install the sidecar is not something to publish to operators. The first +# release is cut by the `edge → main` cutover, with no edit required here. # # ── Unsigned releases ──────────────────────────────────────────────────────── # Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d6b5329 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,941 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "runicgateway-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "flate2", + "serde", + "serde_json", + "sha2", + "sysinfo", + "tar", + "ureq", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..25a186a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "runicgateway-installer" +version = "0.1.0" +edition = "2021" +description = "Deployment tool for Runic Gateway: syncs the ServUO plugin overlay, installs the uo-link sidecar, and records what it deployed." +license = "GPL-3.0-or-later" +repository = "https://gitea.whitlocktech.com/RunicGateway/installer" + +# The published binary keeps the name PLAN.md §3 and INSTALL.md give it. The library it is built +# from does not share that name on purpose: Windows' UAC installer detection refuses to launch an +# unsigned executable whose file name contains "install" (`os error 740`), and Cargo names test +# harnesses after their target — so a target called `runicgateway_installer` makes `cargo test` +# unrunnable on Windows. `test = false` keeps Cargo from building a harness under the binary's +# name; all the code, and all the tests, live in the library. See src/lib.rs. +[lib] +name = "rgdeploy" +path = "src/lib.rs" + +[[bin]] +name = "runicgateway-installer" +path = "src/main.rs" +test = false + +[dependencies] +# Blocking HTTP over a pure-Rust TLS stack (rustls + ring + webpki-roots). The +# release cross-compiles to x86_64-pc-windows-gnu through MinGW, where anything +# linking OpenSSL turns a one-line build into a toolchain project — and this tool +# makes a handful of sequential requests, so an async runtime would be overhead +# with nothing to overlap. +ureq = "3.3" + +# Overlay releases ship as gzipped tar. flate2's default backend is miniz_oxide +# (pure Rust), so it cross-compiles with no C dependency of its own. +flate2 = "1" +tar = "0.4" + +# SHA256 is the entire trust anchor for these deliberately unsigned artifacts +# (PLAN.md §3), which makes this load-bearing rather than a nicety. +sha2 = "0.11" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# RFC 3339 timestamps for install.json. Same feature set link's sidecar uses. +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } + +# Refusing to deploy under a running shard is a correctness requirement, not a +# courtesy: ServUO holds Scripts.dll open and rewrites Saves/ on exit. Only the +# `system` feature is wanted — disks, networks and users are not our business. +sysinfo = { version = "0.38", default-features = false, features = ["system"] } + +# Error plumbing, as in link's sidecar. Every failure here is read by an operator +# rather than matched on, so a chain of `.context()` strings is the whole +# requirement — the value is that "failed to write install.json" arrives with the +# path and the OS error attached instead of alone. +anyhow = "1" + +[profile.release] +opt-level = 2 diff --git a/README.md b/README.md index b7a7aa3..89c280c 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,34 @@ never restarts the shard. ## Status -**Planning — no installer code exists yet.** +**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.** The design of record is [`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other repos (a `servuo-plugins` release workflow, a non-interactive config read-back in -`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful. +`link`, and the bundle-manifest CI here), all of which have landed — +[`bundles/current.json`](bundles/current.json) names the current protocol-checked +sidecar + overlay combination, recomposed on every component release and nightly +(see [`bundles/README.md`](bundles/README.md)). -All three Phase 0 prerequisites have now landed, so **what the installer will -install already exists and is published**, ahead of the binary that installs it: -[`bundles/current.json`](bundles/current.json) names the current -protocol-checked sidecar + overlay combination, recomposed on every component -release and nightly. See [`bundles/README.md`](bundles/README.md). +| Phase | State | +|---|---| +| 0 — prerequisites in the other repos | ✅ merged | +| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` | +| 2 — uo-link install + service registration | next | +| 3 — the opt-in stock-file patch tier | | +| 4 — `doctor`, `update`, `uninstall` | | -Besides that, this repo currently holds its governance documents and issue/PR -templates. +**Why `edge`:** `release.yml` publishes an installer binary on every push to +`main`, and a binary that deploys the overlay but cannot yet install the sidecar +is not something to hand an operator. Phases 1 and 2 land on `edge`; the +`edge → main` cutover cuts the first release. PRs into `edge` run the same gates +as PRs into `main`. + +Until then, the way to install is by hand — +[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand) +is the same deployment done with `curl`, `tar` and `systemctl`. ## Related repos @@ -82,13 +94,29 @@ templates. ## Build & run -Once the crate exists it will be a standard cargo project: +A standard cargo project, with the crate at the repo root: ```bash cargo build --release cargo run -- --help +cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing +cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test ``` +`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway` +or `%ProgramData%\RunicGateway`), which is how a run is tested without root. + +Two layout notes that look odd until you know why: + +- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC + installer detection refuses to launch an unsigned executable whose file name + contains `install` (`os error 740`), and Cargo names test harnesses after their + target — so a target under that name makes `cargo test` unrunnable on Windows. + The published binary keeps its documented name; `[[bin]] test = false` keeps + Cargo from building a harness under it. Expect a UAC prompt when running the + built binary on Windows; it needs Administrator anyway. +- **`Cargo.lock` is committed**, and CI builds `--locked`. + See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local checks CI will run, and the branch/PR workflow. diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..59ff818 --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,228 @@ +//! The bundle manifest — "what to install", resolved at run time. +//! +//! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of +//! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches +//! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's +//! newest release and hoping the two agree. +//! +//! Two consequences show up directly in this module: +//! +//! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and +//! cross-checked against the overlay's own `manifest.json` at deploy time. +//! - **`schema` is not `protocol`.** It versions the shape of this document and moves +//! independently of both components' versions; a bundle from a newer CI is refused rather than +//! half-understood. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS — +/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1). +const BUNDLE_BASE: &str = + "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles"; + +/// The only `schema` this build understands. +const SUPPORTED_SCHEMA: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Bundle { + pub schema: u32, + /// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two. + pub bundle: String, + pub generated: String, + /// The wire protocol both halves were checked to agree on. + pub protocol: u32, + pub link: LinkComponent, + pub overlay: OverlayComponent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LinkComponent { + pub repo: String, + pub tag: String, + pub version: String, + pub protocol: u32, + /// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and + /// the installer runs on both, so a single hash could only ever describe one of them. + pub assets: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OverlayComponent { + pub repo: String, + pub tag: String, + pub version: String, + pub commit: String, + pub protocol: u32, + pub servuo: ServUoCompat, + /// One tarball, platform-independent: the overlay is C# source that ServUO compiles at boot. + pub asset: Asset, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServUoCompat { + /// The oldest ServUO the *base* overlay is known good on. It only adds files. + pub min_version: String, + /// The single ServUO version the *patch tier* was written and verified against (§2.2). + pub patches_verified_against: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Asset { + pub name: String, + pub url: String, + pub sha256: String, +} + +impl Bundle { + /// The bundle's own asset for the platform this binary is running on. + /// + /// Installing the sidecar is Phase 2, but the lookup lives here so that a run on a platform the + /// bundle has no binary for fails while resolving — before anything has been written into a + /// ServUO tree — rather than after the overlay is already deployed. + pub fn sidecar_asset(&self) -> Result<&Asset> { + let key = platform_key()?; + self.link.assets.get(key).ok_or_else(|| { + anyhow::anyhow!( + "bundle {} has no uo-link binary for {key} (it has: {})", + self.bundle, + self.link + .assets + .keys() + .cloned() + .collect::>() + .join(", ") + ) + }) + } +} + +/// The platform key used by `link.assets`, matching the names the bundle CI assigns. +pub fn platform_key() -> Result<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok("linux-x86_64"), + ("windows", "x86_64") => Ok("windows-x86_64"), + // arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats + // failing later with a missing-key error that reads like a corrupt bundle. + (os, arch) => bail!( + "no Runic Gateway build exists for {os}/{arch}. \ + The released components target linux-x86_64 and windows-x86_64." + ), + } +} + +/// URL of the current bundle, or of a specific one when `--bundle ` pins it. +pub fn url_for(tag: Option<&str>) -> String { + match tag { + Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"), + None => format!("{BUNDLE_BASE}/current.json"), + } +} + +/// Fetches and validates a bundle. +pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> { + let url = url_for(tag); + let body = crate::net::get_text(&url).with_context(|| match tag { + Some(tag) => format!( + "cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \ + against {BUNDLE_BASE}/" + ), + None => "cannot read the current bundle manifest".to_string(), + })?; + let bundle = parse(&body)?; + Ok((bundle, url)) +} + +/// Parses a bundle document and applies the checks that must hold before anything is downloaded. +pub fn parse(body: &str) -> Result { + let bundle: Bundle = serde_json::from_str(body) + .context("the bundle manifest is not in the shape this installer understands")?; + + if bundle.schema != SUPPORTED_SCHEMA { + bail!( + "bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \ + Update the installer — the bundle format changed.", + bundle.bundle, + bundle.schema + ); + } + + // Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all. + // Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated + // manifest that never went through the compose job. + if bundle.link.protocol != bundle.overlay.protocol || bundle.protocol != bundle.link.protocol { + bail!( + "bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, overlay {}. \ + A mismatched pair is rejected by the sidecar with 409 rather than mis-parsed, so this \ + is refused here.", + bundle.bundle, + bundle.protocol, + bundle.link.protocol, + bundle.overlay.protocol + ); + } + + if bundle.overlay.asset.url.is_empty() || bundle.overlay.asset.sha256.is_empty() { + bail!( + "bundle {} names an overlay asset with no URL or checksum", + bundle.bundle + ); + } + + Ok(bundle) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The first published bundle, verbatim from `bundles/current.json`. Using the real document + /// rather than a hand-written stand-in is the point: it is what CI actually emits. + const CURRENT: &str = include_str!("../bundles/current.json"); + + #[test] + fn the_published_bundle_parses() { + let bundle = parse(CURRENT).unwrap(); + assert_eq!(bundle.schema, 1); + assert_eq!(bundle.bundle, "2026.08.04"); + assert_eq!(bundle.protocol, 3); + assert_eq!(bundle.link.version, "1.1.0"); + assert_eq!(bundle.overlay.version, "0.1.1"); + assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4"); + assert_eq!(bundle.link.assets.len(), 2); + assert!(bundle.overlay.asset.name.ends_with(".tar.gz")); + } + + #[test] + fn both_platforms_have_a_sidecar_binary() { + // Whichever of the two this test runs on, the lookup must resolve — a bundle missing the + // host's binary would fail an install after the overlay had already been deployed. + let bundle = parse(CURRENT).unwrap(); + let asset = bundle.sidecar_asset().unwrap(); + assert_eq!(asset.sha256.len(), 64); + assert!(asset.url.contains(&bundle.link.tag)); + } + + #[test] + fn a_newer_schema_is_refused_rather_than_guessed_at() { + let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2"); + let err = parse(&body).unwrap_err().to_string(); + assert!(err.contains("schema 2"), "{err}"); + } + + #[test] + fn a_protocol_disagreement_inside_one_bundle_is_refused() { + // Exactly what CI's gate 1 exists to prevent; re-checked here for documents that never + // went through it. + let body = CURRENT.replacen("\"protocol\": 3", "\"protocol\": 4", 2); + let err = parse(&body).unwrap_err().to_string(); + assert!(err.contains("internally inconsistent"), "{err}"); + } + + #[test] + fn the_pinned_and_current_urls_differ() { + assert!(url_for(None).ends_with("/current.json")); + assert!(url_for(Some("2026.08.04")).ends_with("/bundle-2026.08.04.json")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..29121b1 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,336 @@ +//! Command-line surface. +//! +//! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and +//! fixes every command and flag an operator can type. This module parses that surface *whole*, even +//! though Phase 1 implements only part of it — a parser written once against the published contract +//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this +//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it +//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO +//! files were touched when nothing was. +//! +//! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand +//! trees. A parsing crate would be larger than the code it replaced. + +use std::fmt; + +/// The verb. `Install` is the only one Phase 1 implements; the rest parse so that running them +/// reports which phase they arrive in rather than "unrecognized argument", which would read as a +/// typo rather than as an unfinished tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + Install, + Doctor, + Update, + Uninstall, +} + +impl Command { + fn parse(token: &str) -> Option { + match token { + "install" => Some(Self::Install), + "doctor" => Some(Self::Doctor), + "update" => Some(Self::Update), + "uninstall" => Some(Self::Uninstall), + _ => None, + } + } +} + +impl fmt::Display for Command { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Install => "install", + Self::Doctor => "doctor", + Self::Update => "update", + Self::Uninstall => "uninstall", + }) + } +} + +/// What the patch tier was told to do. Tri-state on purpose: "not mentioned" is a different input +/// from "explicitly declined", because only the first one may prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchChoice { + Ask, + Yes, + No, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Run(Command), + Help, + Version, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cli { + pub mode: Mode, + /// `--verify`: report every change that would be made, write nothing. + pub verify: bool, + /// `--servuo `: name the ServUO root instead of detecting or prompting. + pub servuo: Option, + /// `--bundle `: pin a published bundle instead of resolving the current one. + pub bundle: Option, + pub patches: PatchChoice, + /// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree. + pub patches_unsupported_servuo: bool, + /// `--host `: the hostname to print in the website URLs. + pub host: Option, + /// `--site-url `: the site's base URL, for the Admin → Shard link. + pub site_url: Option, + /// `--yes`: assume the default answer to every prompt. + pub assume_yes: bool, + /// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`. + pub purge: bool, +} + +impl Default for Cli { + fn default() -> Self { + Self { + mode: Mode::Help, + verify: false, + servuo: None, + bundle: None, + patches: PatchChoice::Ask, + patches_unsupported_servuo: false, + host: None, + site_url: None, + assume_yes: false, + purge: false, + } + } +} + +pub const USAGE: &str = "\ +Runic Gateway installer — connects a ServUO shard to a Runic Gateway website. + +Usage: runicgateway-installer [OPTIONS] + +Commands: + install Deploy the plugin overlay, install the uo-link sidecar and its + service, record what was deployed, and print the values the + website needs. + doctor Diagnose an existing deployment end to end. + update Re-resolve the bundle and move both components to it. + uninstall Remove what the installer exclusively owns. Never edits the + ServUO tree — it prints what to remove there. + +Options: + --verify install, update. Dry run: report every + change that would be made, write nothing. + --servuo install, doctor, update. The ServUO root, + instead of detecting or prompting for it. + --bundle install, update. Pin an exact published + bundle (e.g. 2026.08.04) instead of the + current one. + --patches / --no-patches install. Decide the patch tier without + being prompted. --patches never loosens + the region check. + --patches-unsupported-servuo install. Required IN ADDITION TO --patches + to run the patch tier on a ServUO that is + not 57.4. Unsupported and untested. + --host install. The hostname to print in the + website URLs. + --site-url install. Your site's base URL, for the + Admin → Shard link. + --yes Assume the default answer to every prompt. + --purge uninstall. Also delete sidecar.toml and + uo-link.db, which are otherwise kept. + -V, --version Print the installer version and exit. + -h, --help Print this help and exit. + +Environment: + RUNICGATEWAY_STATE_DIR Relocates the installer's own state (install.json, + the cached patch set) away from /etc/runicgateway or + %ProgramData%\\RunicGateway. For testing a run + without root; an installed deployment should not + set it. + +The installer never contacts your website, never deletes anything from your +ServUO tree, and never starts or stops your shard. +"; + +/// Parses arguments **without** the program name. +/// +/// The error string is what the caller prints on stderr before exiting `2`. +pub fn parse>(args: I) -> Result { + let mut cli = Cli::default(); + let mut command: Option = None; + let mut it = args.into_iter().peekable(); + + while let Some(arg) = it.next() { + // `--flag=value` is normalized here so each flag below is written once. Splitting on the + // first '=' only: a URL or a Windows path may legitimately contain more. + let (name, inline) = match arg.split_once('=') { + Some((n, v)) if n.starts_with("--") => (n.to_string(), Some(v.to_string())), + _ => (arg.clone(), None), + }; + + match name.as_str() { + "-h" | "--help" => { + cli.mode = Mode::Help; + return Ok(cli); + } + "-V" | "--version" => { + cli.mode = Mode::Version; + return Ok(cli); + } + "--verify" => cli.verify = true, + "--yes" | "-y" => cli.assume_yes = true, + "--purge" => cli.purge = true, + "--patches" => cli.patches = PatchChoice::Yes, + "--no-patches" => cli.patches = PatchChoice::No, + "--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true, + "--servuo" => cli.servuo = Some(take_value(&name, inline, &mut it)?), + "--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?), + "--host" => cli.host = Some(take_value(&name, inline, &mut it)?), + "--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?), + other if other.starts_with('-') => { + return Err(format!("unrecognized argument: {other}")) + } + other => match Command::parse(other) { + Some(c) if command.is_none() => command = Some(c), + // Two verbs is ambiguous, and picking the first would run something the operator + // did not ask for while looking like it worked. + Some(c) => return Err(format!("only one command may be given (saw {c} as well)")), + None => return Err(format!("unrecognized command: {other}")), + }, + } + } + + match command { + Some(c) => cli.mode = Mode::Run(c), + // No verb is not an error worth an exit code — it is someone typing the binary's name to + // see what it does. + None => cli.mode = Mode::Help, + } + Ok(cli) +} + +/// Pulls a flag's value, from `--flag=value` or from the next token. +/// +/// A missing value is an error rather than a default: `--servuo` with nothing after it would +/// otherwise fall through to auto-detection and deploy into a directory nobody named. +fn take_value>( + flag: &str, + inline: Option, + rest: &mut I, +) -> Result { + match inline { + Some(v) if v.is_empty() => Err(format!("{flag} requires a value")), + Some(v) => Ok(v), + None => rest + .next() + .ok_or_else(|| format!("{flag} requires a value")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &[&str]) -> Result { + parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn no_arguments_prints_help() { + assert_eq!(parse_str(&[]).unwrap().mode, Mode::Help); + } + + #[test] + fn every_documented_command_parses() { + for (token, want) in [ + ("install", Command::Install), + ("doctor", Command::Doctor), + ("update", Command::Update), + ("uninstall", Command::Uninstall), + ] { + assert_eq!(parse_str(&[token]).unwrap().mode, Mode::Run(want)); + } + } + + #[test] + fn flags_accept_both_spellings() { + let spaced = parse_str(&["install", "--servuo", "/opt/ServUO"]).unwrap(); + let equals = parse_str(&["install", "--servuo=/opt/ServUO"]).unwrap(); + assert_eq!(spaced.servuo.as_deref(), Some("/opt/ServUO")); + assert_eq!(spaced, equals); + } + + #[test] + fn a_value_containing_equals_survives() { + // Site URLs carry query strings and Windows paths carry drive colons; splitting on every + // '=' would truncate both. + let cli = parse_str(&["install", "--site-url=https://s.example/x?a=b=c"]).unwrap(); + assert_eq!(cli.site_url.as_deref(), Some("https://s.example/x?a=b=c")); + } + + #[test] + fn a_flag_without_its_value_is_an_error() { + for args in [ + vec!["install", "--servuo"], + vec!["install", "--servuo="], + vec!["install", "--bundle"], + vec!["install", "--host"], + vec!["install", "--site-url"], + ] { + assert!(parse_str(&args).is_err(), "{args:?} should be rejected"); + } + } + + #[test] + fn the_patch_tier_is_tri_state() { + // "Not mentioned" must stay distinguishable from "declined": only the first may prompt, + // and only an explicit --patches is consent. + assert_eq!(parse_str(&["install"]).unwrap().patches, PatchChoice::Ask); + assert_eq!( + parse_str(&["install", "--patches"]).unwrap().patches, + PatchChoice::Yes + ); + assert_eq!( + parse_str(&["install", "--no-patches"]).unwrap().patches, + PatchChoice::No + ); + } + + #[test] + fn unsupported_servuo_consent_is_its_own_flag() { + // --patches alone is deliberately not enough on a non-57.4 tree (PLAN.md §2.2.2), so the + // two must not collapse into one another. + let cli = parse_str(&["install", "--patches", "--patches-unsupported-servuo"]).unwrap(); + assert_eq!(cli.patches, PatchChoice::Yes); + assert!(cli.patches_unsupported_servuo); + assert!( + !parse_str(&["install", "--patches"]) + .unwrap() + .patches_unsupported_servuo + ); + } + + #[test] + fn help_and_version_win_immediately() { + assert_eq!( + parse_str(&["install", "--help", "--bogus"]).unwrap().mode, + Mode::Help + ); + assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version); + } + + #[test] + fn unknown_tokens_are_rejected() { + // A typo'd flag must not start a run that is not the one that was asked for. + assert!(parse_str(&["install", "--verfiy"]).is_err()); + assert!(parse_str(&["instal"]).is_err()); + assert!(parse_str(&["install", "update"]).is_err()); + } + + #[test] + fn order_does_not_matter() { + let a = parse_str(&["--verify", "install", "--yes"]).unwrap(); + let b = parse_str(&["install", "--yes", "--verify"]).unwrap(); + assert_eq!(a, b); + assert!(a.verify && a.assume_yes); + } +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..fe06903 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,430 @@ +//! The `install` command. +//! +//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the +//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2) +//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather +//! than ending on a success line that would read as a finished install. An operator who cannot tell +//! which half ran is the failure this whole tool exists to remove. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use crate::cli::{Cli, PatchChoice}; +use crate::record::{ + now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA, +}; +use crate::servuo::ServUoRoot; +use crate::util::TempDir; +use crate::{bundle, net, overlay, paths, servuo, ui}; + +pub fn run(cli: &Cli) -> Result<()> { + let layout = paths::layout(); + + // ── 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 {} — bundle {} (protocol {}){}", + env!("CARGO_PKG_VERSION"), + bundle.bundle, + bundle.protocol, + if cli.verify { + " [--verify: nothing will be written]" + } else { + "" + } + ); + println!(); + + // ── Where to install it ────────────────────────────────────────────────── + let root = resolve_root(cli)?; + 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 {} (Phase 2 — not installed by this build)", + 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 + )); + } + + // ── 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 record_path = layout.install_record(); + let prior = InstallRecord::load(&record_path)?; + 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 + )); + } + + // ── What this build does not do ────────────────────────────────────────── + report_patch_tier(cli); + report_sidecar(&bundle, &sidecar_asset, &layout); + + // ── Record ─────────────────────────────────────────────────────────────── + let record = build_record( + prior.as_ref(), + &bundle, + &bundle_url, + &root, + &manifest, + &planned, + ); + + 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 summary.writes_anything() && !cli.verify { + 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 if cli.verify { + println!("Nothing was written. Re-run without --verify to deploy."); + } else { + println!("Nothing to do — this tree already has this overlay."); + } + + if cli.host.is_some() || cli.site_url.is_some() { + println!( + "\nNote: --host/--site-url are used by the token handoff, which arrives with the \ + sidecar in Phase 2. They had no effect on this run." + ); + } + Ok(()) +} + +/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt. +fn resolve_root(cli: &Cli) -> Result { + if let Some(path) = &cli.servuo { + return servuo::open_stopped(&PathBuf::from(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() +} + +fn report_patch_tier(cli: &Cli) { + println!(); + match cli.patches { + // --patches must never pass silently: an operator who asked for the tier and got a clean + // run would reasonably conclude that EventSink.cs had been patched. + PatchChoice::Yes => { + ui::warn( + "Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \ + build (Phase 3).\n \ + No stock ServUO file has been touched. Apply the patches by hand if you need \ + them: INSTALL.md Appendix A2.", + ); + } + PatchChoice::No => { + ui::row("Patch tier", "skipped (--no-patches)"); + } + PatchChoice::Ask => { + ui::row( + "Patch tier", + "skipped (not implemented in this build — Phase 3)", + ); + } + } + println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding."); +} + +fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) { + println!(); + ui::warn(&format!( + "uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \ + Without it the shard has nothing to dial out to and your website stays offline.\n \ + Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \ + binary {}\n \ + config {}\n \ + database {}\n \ + download {}\n \ + sha256 {}\n \ + Then provision and read the token back with:\n \ + --print-config --config {}\n \ + The bundle pairs it with overlay {} at protocol {}; keep the two in step.", + layout.sidecar_bin.display(), + layout.sidecar_config().display(), + layout.sidecar_db().display(), + asset.url, + asset.sha256, + layout.sidecar_config().display(), + bundle.overlay.tag, + bundle.protocol, + )); +} + +fn build_record( + prior: Option<&InstallRecord>, + bundle: &bundle::Bundle, + bundle_url: &str, + root: &ServUoRoot, + manifest: &overlay::Manifest, + planned: &[overlay::PlannedFile], +) -> InstallRecord { + 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), + }), + // Sections this build does not own are carried through verbatim, so a Phase 1 binary + // re-running on a fully installed host cannot make a service or a set of applied patch + // hunks disappear from the record that documents them. + link: prior.and_then(|p| p.link.clone()), + patches: prior.map(|p| p.patches.clone()).unwrap_or_default(), + 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 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3e641dc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,127 @@ +//! Runic Gateway installer. +//! +//! Takes a working ServUO installation and connects it to a Runic Gateway website. The design of +//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary +//! existed, is `docs/installer/INSTALL.md`. +//! +//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and +//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2), +//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and +//! every one of them says so when reached rather than failing as though it were a typo. +//! +//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same +//! convention as the sidecar's CLI. +//! +//! ## Why the library target is called `rgdeploy` +//! +//! Windows applies **UAC installer detection** to unsigned executables whose file name contains +//! `install`, `setup`, `update` or `patch`: it decides the program is a legacy installer and +//! demands elevation before the process starts. That is tolerable for the shipped binary, which +//! needs Administrator anyway and is documented as being run from an elevated shell — but Cargo +//! names test harnesses after their target, so a target called `runicgateway_installer` produces +//! `runicgateway_installer-.exe`, which Windows refuses to launch (`os error 740`) and +//! `cargo test` cannot run at all on a developer's machine. +//! +//! So the code lives in a neutrally-named library, the binary target keeps the published name from +//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering +//! name. Nothing an operator sees changes. + +pub mod bundle; +pub mod cli; +pub mod install; +pub mod net; +pub mod overlay; +pub mod paths; +pub mod record; +pub mod servuo; +pub mod ui; +pub mod util; + +use cli::{Command, Mode}; + +/// The whole program. Returns the process exit code rather than calling `exit` itself, so the +/// entry point stays a one-liner and this stays callable from a test. +pub fn run() -> i32 { + ui::init_console(); + + let parsed = match cli::parse(std::env::args().skip(1)) { + Ok(parsed) => parsed, + Err(message) => { + eprintln!("error: {message}\n"); + eprint!("{}", cli::USAGE); + return 2; + } + }; + + let result = match parsed.mode { + Mode::Help => { + print!("{}", cli::USAGE); + Ok(()) + } + Mode::Version => { + println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Mode::Run(Command::Install) => install::run(&parsed), + Mode::Run(command) => Err(not_implemented(command)), + }; + + if let Err(error) = result { + // The chain is printed, not just the outermost message: "cannot write + // /etc/runicgateway/install.json" is only actionable with the OS error still attached. + eprintln!("\nerror: {error}"); + for cause in error.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + return 1; + } + 0 +} + +/// A command the contract documents but this phase has not built. +/// +/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished. +fn not_implemented(command: Command) -> anyhow::Error { + let (phase, workaround) = match command { + Command::Doctor => ( + "Phase 4", + "Check the deployment by hand: `[bridge status` in game, and \ + `curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).", + ), + Command::Update => ( + "Phase 4", + "Re-run `install` to move the overlay to the current bundle; replace the sidecar \ + binary by hand (INSTALL.md Appendix A6).", + ), + Command::Uninstall => ( + "Phase 4", + "Remove the sidecar service and binary by hand; the overlay files this installer \ + deployed are listed in install.json.", + ), + Command::Install => unreachable!("install is implemented"), + }; + anyhow::anyhow!( + "`{command}` is not implemented in this build — it arrives in {phase} \ + (see docs/installer/PLAN.md §5).\n{workaround}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unfinished_commands_name_their_phase_and_a_way_through() { + // An operator who runs `doctor` today must not be left thinking they typed it wrong, and + // must not be left with nothing to do either. + for command in [Command::Doctor, Command::Update, Command::Uninstall] { + let message = not_implemented(command).to_string(); + assert!(message.contains(&command.to_string()), "{message}"); + assert!(message.contains("Phase 4"), "{message}"); + assert!( + message.contains("INSTALL.md") || message.contains("install.json"), + "{message}" + ); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..29259fa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,6 @@ +//! Entry point. Everything lives in the library — see `src/lib.rs`, including why the library +//! target is not named after this binary. + +fn main() { + std::process::exit(rgdeploy::run()); +} diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..c407c3d --- /dev/null +++ b/src/net.rs @@ -0,0 +1,117 @@ +//! HTTP fetches and verified downloads. +//! +//! Everything this module pulls comes from a public Gitea repo over anonymous HTTPS — the shard +//! host has no Gitea credentials and needs none (PLAN.md §1). The one rule that matters: +//! **nothing downloaded is used before its SHA256 has been checked against the bundle.** The +//! artifacts are deliberately unsigned (§3), so the checksum is the entire trust anchor, and a +//! download that "mostly worked" is exactly the case that must not proceed. + +use std::fs::File; +use std::io::{self, BufWriter}; +use std::path::Path; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; + +use crate::util::HashingWriter; + +/// Identifies the tool and its version in Gitea's logs — worth having when an operator reports that +/// a fetch failed and nobody can tell which build made the request. +fn user_agent() -> String { + format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION")) +} + +/// One agent per call is fine at this volume, and it keeps the timeouts in one place. +/// +/// The global timeout is generous because the overlay tarball travels over whatever link the shard +/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run +/// with a message instead of hanging an operator's terminal indefinitely. +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .user_agent(user_agent()) + .timeout_global(Some(Duration::from_secs(300))) + .build() + .into() +} + +/// Fetches a small text document (the bundle manifest). +pub fn get_text(url: &str) -> Result { + let mut response = agent() + .get(url) + .call() + .with_context(|| format!("cannot reach {url}"))?; + let status = response.status(); + if !status.is_success() { + bail!("{url} returned HTTP {}", status.as_u16()); + } + response + .body_mut() + .read_to_string() + .with_context(|| format!("cannot read the response from {url}")) +} + +/// Downloads `url` to `dest`, verifying SHA256 **while writing**. +/// +/// On mismatch the partial file is removed before returning: leaving a wrong-hash artifact on disk +/// invites a later step — or a puzzled operator — to use it anyway. +pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Result<()> { + let expected = expected_sha256.trim().to_ascii_lowercase(); + if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})"); + } + + let mut response = agent() + .get(url) + .call() + .with_context(|| format!("cannot reach {url}"))?; + let status = response.status(); + if !status.is_success() { + bail!("{url} returned HTTP {}", status.as_u16()); + } + + let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?; + let mut writer = HashingWriter::new(BufWriter::new(file)); + io::copy(&mut response.body_mut().as_reader(), &mut writer) + .with_context(|| format!("download of {url} failed"))?; + let actual = writer.finish(); + + if actual != expected { + let _ = std::fs::remove_file(dest); + return Err(anyhow!( + "checksum mismatch for {url}\n expected {expected}\n got {actual}\n\ + These artifacts are unsigned, so the checksum is the only thing vouching for them. \ + Refusing to use this download." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + #[test] + fn the_user_agent_names_the_build() { + let ua = user_agent(); + assert!(ua.starts_with("runicgateway-installer/"), "{ua}"); + assert!(ua.len() > "runicgateway-installer/".len(), "{ua}"); + } + + #[test] + fn a_malformed_expected_hash_is_refused_before_any_request() { + // A bundle whose sha256 field is truncated, uppercase-garbled or empty must stop the run + // rather than download something that can then only be compared against nonsense. The URL + // is unroutable on purpose: reaching the network at all would be the bug. + let dir = TempDir::new("rg-test-net").unwrap(); + let dest = dir.path().join("artifact"); + for bad in ["", "abc", &"z".repeat(64)] { + let err = download_verified("http://127.0.0.1:1/artifact", &dest, bad).unwrap_err(); + assert!( + err.to_string().contains("unusable SHA256"), + "expected a pre-flight refusal, got: {err}" + ); + } + assert!(!dest.exists()); + } +} diff --git a/src/overlay.rs b/src/overlay.rs new file mode 100644 index 0000000..375706a --- /dev/null +++ b/src/overlay.rs @@ -0,0 +1,696 @@ +//! The plugin overlay: unpack the release, then sync it into the ServUO tree. +//! +//! The plugin ships as **C# source that ServUO compiles at boot** (PLAN.md §2.1), so deployment is +//! a hash-compare file copy rather than a DLL drop. Three rules govern it: +//! +//! - **Nothing is ever deleted.** `overlay/` mirrors the server root and only adds or overwrites. +//! That is `deploy.ps1`'s behaviour and the installer inherits it: the ServUO tree belongs to the +//! operator, and a deployment tool that removes files from it is a deployment tool that +//! eventually removes the wrong one. +//! - **`Config/Bridge.cfg` is reported, not overwritten, once it has been edited** — the single +//! deviation from `deploy.ps1` (PLAN.md §5, Phase 1). It is the only file in the overlay that is +//! *meant* to be edited in place, and it carries no code, so a stale copy cannot break the build. +//! Silently reverting it would throw away `LinkUrl`, `PublicConnectAddress` and every sweep +//! interval on an `update`. +//! - **A successful copy is not a working bridge.** ServUO ignores the script build's exit code +//! and reloads the previous `Scripts.dll` (§2.1), so nothing here may report success in terms +//! stronger than "the files are in place". + +use std::collections::BTreeMap; +use std::fmt; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::bundle::ServUoCompat; +use crate::record::FileRecord; +use crate::util::sha256_file; + +/// The tarball's fixed top-level directory. Fixed rather than versioned on purpose: the installer +/// looks for `overlay/`, `patches/` and `manifest.json` at known paths instead of parsing the very +/// version it is trying to read (PLAN.md §5, Phase 0 item 1). +const TOP_LEVEL_DIR: &str = "runicgateway-overlay"; + +/// Files the operator owns once deployed. Everything else — every `.cs` file and `Scripts.csproj` — +/// is overwritten unconditionally, because it is code and a stale copy breaks the build. +const OPERATOR_OWNED: &[&str] = &["Config/Bridge.cfg"]; + +/// `manifest.json`, generated by the `servuo-plugins` release workflow (PLAN.md §7.0). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Manifest { + pub component: String, + pub version: String, + pub commit: String, + pub repo: String, + /// The plugin half of the compatibility contract, declared in `overlay.toml`. Nothing can + /// derive it — the plugin announces no version on the wire and none is queryable before ServUO + /// boots — which is why it is checked against the bundle before anything is written. + pub protocol: u32, + pub servuo: ServUoCompat, + /// SHA256 per shipped file, keyed `overlay/...` and `patches/...`. + pub files: BTreeMap, +} + +/// What the sync will do to one file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Add, + Change, + Unchanged, + /// The operator has edited this file since it was deployed (or it was already there before the + /// installer ever ran). Reported, left alone. + KeptOperatorModified, +} + +impl Action { + /// The token written into `install.json` — the resulting *state*, not the verb. + /// + /// Add, change and unchanged all leave the release's copy in the tree, so all three record + /// `deployed`. Collapsing them is what lets an unchanged re-run compare equal to the previous + /// record and write nothing (see [`crate::record::FileRecord::state`]). + pub fn state(self) -> &'static str { + match self { + Self::Add | Self::Change | Self::Unchanged => "deployed", + Self::KeptOperatorModified => "kept-operator-modified", + } + } + + fn label(self) -> &'static str { + match self { + Self::Add => "ADD", + Self::Change => "CHANGE", + Self::Unchanged => "same", + Self::KeptOperatorModified => "KEEP", + } + } + + fn writes(self) -> bool { + matches!(self, Self::Add | Self::Change) + } +} + +impl fmt::Display for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone)] +pub struct PlannedFile { + /// ServUO-tree-relative, always `/`-separated so the record is portable between platforms. + pub rel: String, + pub src: PathBuf, + pub dst: PathBuf, + pub action: Action, + pub overlay_sha256: String, + /// What is on disk now — `None` when the file does not exist yet. + pub on_disk_sha256: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Summary { + pub add: usize, + pub change: usize, + pub unchanged: usize, + pub kept: usize, +} + +impl Summary { + pub fn writes_anything(&self) -> bool { + self.add + self.change > 0 + } +} + +/// Unpacks the release tarball and returns the directory holding `overlay/`, `patches/` and +/// `manifest.json`. +/// +/// `tar`'s unpack refuses entries that escape the destination, so a malicious or malformed archive +/// cannot write outside the scratch directory — worth stating explicitly, since this is the one +/// place the installer expands untrusted-shaped data. The archive itself has already been checked +/// against the bundle's SHA256 by the time this runs. +pub fn extract(tarball: &Path, into: &Path) -> Result { + let file = File::open(tarball).with_context(|| format!("cannot open {}", tarball.display()))?; + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(into) + .with_context(|| format!("cannot unpack {}", tarball.display()))?; + + // The fixed prefix is what the release workflow writes; falling back to the extraction root + // covers a tarball repackaged without it, which is a plausible operator mistake and a + // pointless thing to fail on when the three known paths are right there. + let with_prefix = into.join(TOP_LEVEL_DIR); + for candidate in [with_prefix, into.to_path_buf()] { + if candidate.join("manifest.json").is_file() && candidate.join("overlay").is_dir() { + return Ok(candidate); + } + } + bail!( + "{} does not contain {TOP_LEVEL_DIR}/manifest.json and {TOP_LEVEL_DIR}/overlay/ — \ + this is not a Runic Gateway overlay release", + tarball.display() + ); +} + +pub fn read_manifest(dir: &Path) -> Result { + let path = dir.join("manifest.json"); + let body = + fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?; + serde_json::from_str(&body).with_context(|| { + format!( + "{} is not a manifest this installer understands", + path.display() + ) + }) +} + +/// Re-hashes every file the manifest names. +/// +/// The tarball's own checksum has already been verified against the bundle, so this is not the +/// trust boundary — it is a guard against a truncated extraction, a disk error, or an archive +/// repacked by hand between download and deploy. It is also what makes the hashes recorded in +/// `install.json` trustworthy, since those come from this manifest rather than from re-reading the +/// tree later. +pub fn verify_payload(dir: &Path, manifest: &Manifest) -> Result<()> { + let mut problems = Vec::new(); + for (rel, expected) in &manifest.files { + let path = dir.join(rel); + if !path.is_file() { + problems.push(format!(" missing: {rel}")); + continue; + } + let actual = sha256_file(&path)?; + if &actual != expected { + problems.push(format!(" modified: {rel}")); + } + } + if !problems.is_empty() { + bail!( + "the extracted overlay does not match its own manifest:\n{}", + problems.join("\n") + ); + } + Ok(()) +} + +/// Decides what to do with every file in `overlay/`, without touching anything. +/// +/// `prior` is the previous `install.json` file map. It is what separates "the operator edited +/// `Bridge.cfg`" from "the overlay shipped a new `Bridge.cfg`" (PLAN.md §7.0): if what is on disk +/// is exactly the copy this installer last *deployed*, the operator has not touched it and an +/// upstream change may land. Anything else — including no record at all, i.e. a tree where the +/// file was put there by hand per INSTALL.md Appendix A — is treated as the operator's. +pub fn plan( + overlay_dir: &Path, + servuo_root: &Path, + prior: Option<&BTreeMap>, +) -> Result> { + let source = overlay_dir.join("overlay"); + let mut files = Vec::new(); + collect(&source, &source, &mut files)?; + files.sort(); + + let mut planned = Vec::with_capacity(files.len()); + for rel in files { + let src = source.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); + let dst = servuo_root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); + let overlay_sha256 = sha256_file(&src)?; + let on_disk_sha256 = if dst.is_file() { + Some(sha256_file(&dst)?) + } else { + None + }; + + let action = match &on_disk_sha256 { + None => Action::Add, + Some(on_disk) if *on_disk == overlay_sha256 => Action::Unchanged, + Some(on_disk) if OPERATOR_OWNED.contains(&rel.as_str()) => { + match prior.and_then(|p| p.get(&rel)) { + // What is on disk is byte-for-byte the copy the installer itself last + // deployed, so the operator has not touched it and the release's new default + // may land. + // + // Compared against `overlay_sha256` — the release copy — and NOT against + // `on_disk_sha256`: after a file has once been kept, `on_disk_sha256` holds + // the *operator's* content, so comparing to it would find a match on the very + // next run and overwrite exactly the file this rule exists to protect. A keep + // has to stay kept for as long as the operator's edit is there. + Some(record) if record.overlay_sha256 == *on_disk => Action::Change, + _ => Action::KeptOperatorModified, + } + } + Some(_) => Action::Change, + }; + + planned.push(PlannedFile { + rel, + src, + dst, + action, + overlay_sha256, + on_disk_sha256, + }); + } + Ok(planned) +} + +/// Copies every file the plan writes. Parent directories are created; nothing is removed. +pub fn apply(planned: &[PlannedFile]) -> Result<()> { + for file in planned.iter().filter(|f| f.action.writes()) { + if let Some(parent) = file.dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + fs::copy(&file.src, &file.dst).with_context(|| { + format!( + "cannot write {} — {}", + file.dst.display(), + "check that the shard is stopped and that you are running as root/Administrator" + ) + })?; + } + Ok(()) +} + +pub fn summarize(planned: &[PlannedFile]) -> Summary { + let mut summary = Summary::default(); + for file in planned { + match file.action { + Action::Add => summary.add += 1, + Action::Change => summary.change += 1, + Action::Unchanged => summary.unchanged += 1, + Action::KeptOperatorModified => summary.kept += 1, + } + } + summary +} + +/// The per-file record for `install.json`. +pub fn file_records(planned: &[PlannedFile]) -> BTreeMap { + planned + .iter() + .map(|f| { + // For everything the installer wrote, what is on disk afterwards *is* the overlay's + // copy. Only a kept file keeps its own hash — which is precisely what makes a later + // run able to tell that the operator, not the release, owns it. + let on_disk = match f.action { + Action::KeptOperatorModified => f + .on_disk_sha256 + .clone() + .unwrap_or_else(|| f.overlay_sha256.clone()), + _ => f.overlay_sha256.clone(), + }; + ( + f.rel.clone(), + FileRecord { + overlay_sha256: f.overlay_sha256.clone(), + on_disk_sha256: on_disk, + state: f.action.state().to_string(), + }, + ) + }) + .collect() +} + +/// Renders the changed files, collapsing a directory full of identically-treated files into one +/// line — 22 `ADD` lines for `Scripts/Custom/Bridge/*.cs` push everything else off the screen, and +/// what an operator needs to see is that `Scripts.csproj` was overwritten. +pub fn render(planned: &[PlannedFile]) -> Vec { + const GROUP_AT: usize = 4; + let mut lines = Vec::new(); + let mut group: Vec<&PlannedFile> = Vec::new(); + + let interesting: Vec<&PlannedFile> = planned + .iter() + .filter(|f| f.action != Action::Unchanged) + .collect(); + + let key = |f: &PlannedFile| -> (Action, String, String) { + let (dir, name) = match f.rel.rsplit_once('/') { + Some((d, n)) => (d.to_string(), n.to_string()), + None => (String::new(), f.rel.clone()), + }; + let ext = name + .rsplit_once('.') + .map(|(_, e)| e.to_string()) + .unwrap_or_default(); + (f.action, dir, ext) + }; + + let flush = |group: &mut Vec<&PlannedFile>, lines: &mut Vec| { + if group.is_empty() { + return; + } + if group.len() >= GROUP_AT { + let (action, dir, ext) = key(group[0]); + let glob = if ext.is_empty() { + format!("{dir}/*") + } else { + format!("{dir}/*.{ext}") + }; + lines.push(format!( + " {:<7} {:<38} ({} files)", + action.label(), + glob, + group.len() + )); + } else { + for f in group.iter() { + lines.push(format!(" {:<7} {}", f.action.label(), f.rel)); + } + } + group.clear(); + }; + + for file in interesting { + if group.first().map(|g| key(g)) != Some(key(file)) { + flush(&mut group, &mut lines); + } + group.push(file); + } + flush(&mut group, &mut lines); + lines +} + +/// Recursively lists files under `dir` as `/`-separated paths relative to `base`. +fn collect(base: &Path, dir: &Path, out: &mut Vec) -> Result<()> { + let entries = fs::read_dir(dir).with_context(|| format!("cannot list {}", dir.display()))?; + for entry in entries { + let entry = entry.with_context(|| format!("cannot list {}", dir.display()))?; + let path = entry.path(); + if path.is_dir() { + collect(base, &path, out)?; + } else if path.is_file() { + let rel = path + .strip_prefix(base) + .with_context(|| format!("{} is not under {}", path.display(), base.display()))?; + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + struct Fixture { + _tmp: TempDir, + overlay_dir: PathBuf, + root: PathBuf, + } + + /// An overlay release laid out the way the tarball is, and an empty ServUO tree. + fn fixture() -> Fixture { + let tmp = TempDir::new("rg-test-overlay").unwrap(); + let overlay_dir = tmp.path().join("runicgateway-overlay"); + let root = tmp.path().join("ServUO"); + + let bridge = overlay_dir + .join("overlay") + .join("Scripts") + .join("Custom") + .join("Bridge"); + fs::create_dir_all(&bridge).unwrap(); + fs::create_dir_all(overlay_dir.join("overlay").join("Config")).unwrap(); + fs::write( + overlay_dir + .join("overlay") + .join("Config") + .join("Bridge.cfg"), + b"LinkUrl=https://yoursite/link\n", + ) + .unwrap(); + fs::write( + overlay_dir + .join("overlay") + .join("Scripts") + .join("Scripts.csproj"), + b"\n", + ) + .unwrap(); + for i in 0..5 { + fs::write(bridge.join(format!("Bridge{i}.cs")), format!("// {i}\n")).unwrap(); + } + fs::create_dir_all(&root).unwrap(); + + Fixture { + _tmp: tmp, + overlay_dir, + root, + } + } + + fn action_of<'a>(planned: &'a [PlannedFile], rel: &str) -> &'a PlannedFile { + planned.iter().find(|f| f.rel == rel).expect(rel) + } + + #[test] + fn a_first_install_adds_everything() { + let fx = fixture(); + let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + let summary = summarize(&planned); + assert_eq!(summary.add, 7); + assert_eq!(summary.change + summary.unchanged + summary.kept, 0); + + apply(&planned).unwrap(); + assert!(fx.root.join("Config").join("Bridge.cfg").is_file()); + assert!(fx + .root + .join("Scripts") + .join("Custom") + .join("Bridge") + .join("Bridge0.cs") + .is_file()); + } + + #[test] + fn a_second_run_with_no_upstream_change_writes_nothing() { + let fx = fixture(); + let first = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + apply(&first).unwrap(); + let records = file_records(&first); + + let second = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap(); + let summary = summarize(&second); + assert_eq!(summary.unchanged, 7); + assert!(!summary.writes_anything()); + assert!( + render(&second).is_empty(), + "an unchanged run prints no file lines" + ); + + // ...and it must produce a byte-identical record, or install.json would be rewritten on + // every run — "reports unchanged and writes nothing" is the requirement, and a file map + // that recorded `add` the first time and `unchanged` the second would quietly break it. + assert_eq!(records, file_records(&second)); + } + + #[test] + fn code_files_are_always_overwritten() { + // A hand-edited .cs file or Scripts.csproj is a stale copy that breaks the build, and + // ServUO will not say so — it reloads the previous Scripts.dll and boots clean. + let fx = fixture(); + apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap(); + let csproj = fx.root.join("Scripts").join("Scripts.csproj"); + fs::write(&csproj, b" hand edited \n").unwrap(); + + let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + assert_eq!( + action_of(&planned, "Scripts/Scripts.csproj").action, + Action::Change + ); + apply(&planned).unwrap(); + assert_eq!(fs::read(&csproj).unwrap(), b"\n"); + } + + #[test] + fn an_edited_bridge_cfg_is_kept_even_when_the_release_moved_on() { + // The deviation from deploy.ps1: overwriting here would silently revert LinkUrl, + // PublicConnectAddress and every sweep interval on an update. + let fx = fixture(); + let first = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + apply(&first).unwrap(); + let records = file_records(&first); + + let deployed = fx.root.join("Config").join("Bridge.cfg"); + fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap(); + // ...and the release ships a new default too, so this is not merely "no upstream change". + fs::write( + fx.overlay_dir + .join("overlay") + .join("Config") + .join("Bridge.cfg"), + b"LinkUrl=https://yoursite/link\nNewSetting=1\n", + ) + .unwrap(); + + let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap(); + let cfg = action_of(&planned, "Config/Bridge.cfg"); + assert_eq!(cfg.action, Action::KeptOperatorModified); + apply(&planned).unwrap(); + assert_eq!( + fs::read(&deployed).unwrap(), + b"LinkUrl=https://myshard.example/link\n", + "the operator's file must survive" + ); + + // And the record keeps the operator's hash, not the release's — otherwise the next run + // would conclude the operator had never touched it and overwrite on the run after that. + let records = file_records(&planned); + let record = &records["Config/Bridge.cfg"]; + assert_ne!(record.on_disk_sha256, record.overlay_sha256); + assert_eq!(record.state, "kept-operator-modified"); + } + + #[test] + fn a_kept_bridge_cfg_stays_kept_run_after_run() { + // The rule has to survive its own bookkeeping. Once a file is kept, the record holds the + // operator's hash as what is on disk — so a rule that asked "is the tree still what the + // record last saw?" would answer yes on the next run and overwrite the very file it had + // just protected. Three runs, because the bug only appears from the second one on. + let fx = fixture(); + let first = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + apply(&first).unwrap(); + let mut records = file_records(&first); + + let deployed = fx.root.join("Config").join("Bridge.cfg"); + fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap(); + fs::write( + fx.overlay_dir + .join("overlay") + .join("Config") + .join("Bridge.cfg"), + b"LinkUrl=https://yoursite/link\nNewSetting=1\n", + ) + .unwrap(); + + for run in 2..=4 { + let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap(); + assert_eq!( + action_of(&planned, "Config/Bridge.cfg").action, + Action::KeptOperatorModified, + "run {run} must still keep the operator's file" + ); + apply(&planned).unwrap(); + assert_eq!( + fs::read(&deployed).unwrap(), + b"LinkUrl=https://myshard.example/link\n", + "run {run} overwrote the operator's file" + ); + records = file_records(&planned); + } + } + + #[test] + fn an_untouched_bridge_cfg_takes_the_upstream_change() { + // The other half of the rule: if what is on disk is exactly what was deployed, the + // operator has not edited it and a new default may land. + let fx = fixture(); + let first = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + apply(&first).unwrap(); + let records = file_records(&first); + + fs::write( + fx.overlay_dir + .join("overlay") + .join("Config") + .join("Bridge.cfg"), + b"LinkUrl=https://yoursite/link\nNewSetting=1\n", + ) + .unwrap(); + + let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap(); + assert_eq!( + action_of(&planned, "Config/Bridge.cfg").action, + Action::Change + ); + } + + #[test] + fn a_hand_installed_tree_with_no_record_keeps_its_bridge_cfg() { + // INSTALL.md Appendix A tells operators to deploy by hand today. When the installer later + // arrives on such a host there is no record to compare against, and the safe reading of an + // unknown edit is that it is the operator's. + let fx = fixture(); + fs::create_dir_all(fx.root.join("Config")).unwrap(); + fs::write( + fx.root.join("Config").join("Bridge.cfg"), + b"LinkUrl=https://myshard.example/link\n", + ) + .unwrap(); + + let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + assert_eq!( + action_of(&planned, "Config/Bridge.cfg").action, + Action::KeptOperatorModified + ); + } + + #[test] + fn nothing_outside_the_overlay_is_touched() { + let fx = fixture(); + let stranger = fx.root.join("Scripts").join("Custom").join("MyShard.cs"); + fs::create_dir_all(stranger.parent().unwrap()).unwrap(); + fs::write(&stranger, b"// mine\n").unwrap(); + + apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap(); + assert_eq!(fs::read(&stranger).unwrap(), b"// mine\n"); + } + + #[test] + fn a_directory_of_identical_actions_collapses_to_one_line() { + let fx = fixture(); + let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap(); + let lines = render(&planned); + assert!( + lines + .iter() + .any(|l| l.contains("Scripts/Custom/Bridge/*.cs") && l.contains("(5 files)")), + "{lines:#?}" + ); + // The single-file entries stay individually visible — Scripts.csproj overwriting a stock + // file is exactly what must not get folded away. + assert!( + lines.iter().any(|l| l.contains("Scripts/Scripts.csproj")), + "{lines:#?}" + ); + } + + #[test] + fn the_manifest_check_catches_a_tampered_payload() { + let fx = fixture(); + let cfg_rel = "overlay/Config/Bridge.cfg"; + let manifest = Manifest { + component: "servuo-plugins-overlay".into(), + version: "0.1.1".into(), + commit: "3a52abb".into(), + repo: "RunicGateway/servuo-plugins".into(), + protocol: 3, + servuo: ServUoCompat { + min_version: "57.4".into(), + patches_verified_against: "57.4".into(), + }, + files: BTreeMap::from([( + cfg_rel.to_string(), + sha256_file(&fx.overlay_dir.join(cfg_rel)).unwrap(), + )]), + }; + verify_payload(&fx.overlay_dir, &manifest).unwrap(); + + fs::write(fx.overlay_dir.join(cfg_rel), b"tampered\n").unwrap(); + let err = verify_payload(&fx.overlay_dir, &manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("modified: overlay/Config/Bridge.cfg"), "{err}"); + + fs::remove_file(fx.overlay_dir.join(cfg_rel)).unwrap(); + let err = verify_payload(&fx.overlay_dir, &manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("missing: overlay/Config/Bridge.cfg"), "{err}"); + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..5105983 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,113 @@ +//! 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 (from Phase 2) pins `UOLINK_CONFIG` and +//! `UOLINK_DB_PATH` into the service definition. +//! +//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole +//! layout is declared here so Phase 2 and 3 add nothing new to argue about. + +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. Phase 2. + pub data_dir: PathBuf, + /// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2. + pub sidecar_bin: PathBuf, +} + +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") + } +} + +/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`]. +/// +/// The override moves the *state* and *data* directories together. Splitting them under an override +/// would make a test run write half its files into the real system location, which is exactly the +/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes +/// it, and a relocated binary would not be what the service definition names. +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); + layout.data_dir = root.join("data"); + layout.state_dir = root; + } + 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"), + } +} + +#[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"), + } +} + +#[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 joins them in Phase 3. + 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); + } +} diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..61ce773 --- /dev/null +++ b/src/record.rs @@ -0,0 +1,265 @@ +//! `install.json` — what this host has deployed. +//! +//! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later +//! command reasons from. Two of its properties are load-bearing rather than informational: +//! +//! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the +//! record and the release manifest means the overlay moved on; differing from the record alone +//! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and +//! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on. +//! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on +//! a host where Phase 2 and 3 have written sidecar and patch records must give them back +//! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved +//! verbatim. A future field that silently vanished on a re-run would be worse than one that was +//! never written. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::util::write_atomic; + +/// The shape of this document. Independent of the bundle's `schema` and of any protocol version. +pub const SCHEMA: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstallRecord { + pub schema: u32, + pub installer: InstallerInfo, + /// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one + /// excluded when deciding whether a re-run has anything to record. + pub updated: String, + pub bundle: BundleRef, + pub servuo: ServUoRef, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay: Option, + /// Phase 2 (uo-link binary, config and service). Carried through untouched by this build. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link: Option, + /// Phase 3 (applied patches, with the rung that applied each). Carried through untouched. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub patches: Vec, + /// Anything a newer installer wrote that this one has no name for. + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstallerInfo { + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BundleRef { + pub tag: String, + pub protocol: u32, + /// The exact document this install resolved, so a re-install can be reproduced and a support + /// question about "which bundle?" is answered by the file rather than by memory. + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServUoRef { + pub path: String, + /// The detected version, or `null`. Recorded even when unknown: the patch tier's support story + /// follows the install (§2.2.2), and a later `doctor` must be able to show it without + /// re-deriving it. + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OverlayRecord { + pub repo: String, + pub tag: String, + pub version: String, + pub commit: String, + pub protocol: u32, + /// Keyed by ServUO-tree-relative path, always with `/` separators so a record written on + /// Windows is readable on Linux and vice versa. + pub files: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileRecord { + /// What the release shipped for this path. + pub overlay_sha256: String, + /// What is on disk in the ServUO tree after this run. Equal to `overlay_sha256` except for a + /// file the installer deliberately left alone (`Config/Bridge.cfg`, once edited). + pub on_disk_sha256: String, + /// `deployed` (the tree holds the release's copy) or `kept-operator-modified` (it holds the + /// operator's). + /// + /// Deliberately a **state, not a verb**: recording `add` on the first run and `unchanged` on + /// the next would make every re-run rewrite this file, which is exactly the "a second run + /// writes nothing" promise in PLAN.md Phase 1. What later commands need to know is whose copy + /// is in the tree, and that does not change just because time passed. + pub state: String, +} + +impl InstallRecord { + /// Whether a re-run would record anything new. + /// + /// Everything except `updated` is compared: PLAN.md Phase 1 requires that a second run with no + /// upstream change reports "unchanged" and **writes nothing**, and rewriting the file purely to + /// move a timestamp would break that promise in the least visible way possible — by touching a + /// file whose mtime an operator may be watching. + pub fn same_deployment_as(&self, other: &Self) -> bool { + let mut a = self.clone(); + let mut b = other.clone(); + a.updated.clear(); + b.updated.clear(); + a == b + } + + pub fn load(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let body = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + let record: Self = serde_json::from_str(&body).with_context(|| { + format!( + "{} exists but is not a record this installer understands. \ + Move it aside to start over, or install a newer installer.", + path.display() + ) + })?; + Ok(Some(record)) + } + + pub fn save(&self, path: &Path) -> Result<()> { + // Pretty-printed with a trailing newline: this file is read by humans during support, and + // diffed by anyone who keeps /etc under version control. + let mut body = + serde_json::to_string_pretty(self).context("cannot serialize install.json")?; + body.push('\n'); + write_atomic(path, body.as_bytes()) + } + + /// Files this installer previously deployed, for the `Bridge.cfg` comparison in `overlay::plan`. + pub fn overlay_files(&self) -> Option<&BTreeMap> { + self.overlay.as_ref().map(|o| &o.files) + } +} + +pub fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + fn sample() -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: "2026-08-04T18:00:00Z".into(), + bundle: BundleRef { + tag: "2026.08.04".into(), + protocol: 3, + url: "https://example/bundles/current.json".into(), + }, + servuo: ServUoRef { + path: "/opt/ServUO".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: "aa".into(), + state: "deployed".into(), + }, + )]), + }), + link: None, + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + #[test] + fn a_record_round_trips() { + let dir = TempDir::new("rg-test-record").unwrap(); + let path = dir.path().join("install.json"); + let record = sample(); + record.save(&path).unwrap(); + assert_eq!(InstallRecord::load(&path).unwrap().unwrap(), record); + } + + #[test] + fn a_missing_record_is_not_an_error() { + let dir = TempDir::new("rg-test-record-missing").unwrap(); + assert!(InstallRecord::load(&dir.path().join("nope.json")) + .unwrap() + .is_none()); + } + + #[test] + fn later_phases_survive_a_phase_one_rewrite() { + // The scenario: Phase 2 and 3 have written sidecar and patch sections (and some future + // field this build has never heard of), then an older installer re-runs. Dropping any of + // it would make `doctor` and `uninstall` forget a service and a set of applied hunks. + let dir = TempDir::new("rg-test-record-forward").unwrap(); + let path = dir.path().join("install.json"); + let body = r#"{ + "schema": 1, + "installer": { "version": "0.9.0" }, + "updated": "2026-09-01T00:00:00Z", + "bundle": { "tag": "2026.09.01", "protocol": 3, "url": "https://example/current.json" }, + "servuo": { "path": "/opt/ServUO", "version": "57.4" }, + "overlay": null, + "link": { "version": "1.1.0", "service": "runicgateway-link.service" }, + "patches": [ { "name": "commandlogging-event", "rung": "region-match" } ], + "future_section": { "kept": true } + }"#; + std::fs::write(&path, body).unwrap(); + + let loaded = InstallRecord::load(&path).unwrap().unwrap(); + loaded.save(&path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + + assert!(text.contains("runicgateway-link.service"), "{text}"); + assert!(text.contains("region-match"), "{text}"); + assert!(text.contains("future_section"), "{text}"); + } + + #[test] + fn only_the_timestamp_is_ignored_when_deciding_to_rewrite() { + let a = sample(); + let mut b = a.clone(); + b.updated = "2027-01-01T00:00:00Z".into(); + assert!(a.same_deployment_as(&b)); + + // Anything that actually describes the deployment must count as a change. + let mut c = a.clone(); + c.bundle.tag = "2026.09.01".into(); + assert!(!a.same_deployment_as(&c)); + + let mut d = a.clone(); + if let Some(overlay) = d.overlay.as_mut() { + overlay.files.get_mut("Config/Bridge.cfg").unwrap().state = + "kept-operator-modified".into(); + } + assert!(!a.same_deployment_as(&d)); + } + + #[test] + fn timestamps_are_utc_rfc3339() { + let now = now_rfc3339(); + assert!(now.ends_with('Z'), "{now}"); + assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}"); + } +} diff --git a/src/servuo.rs b/src/servuo.rs new file mode 100644 index 0000000..ba5e2a8 --- /dev/null +++ b/src/servuo.rs @@ -0,0 +1,402 @@ +//! 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()); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b548b57 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,123 @@ +//! Terminal output and prompts. +//! +//! Two rules shape this module: +//! +//! 1. **A run's output is a support artifact.** `docs/installer/INSTALL.md` shows operators what a +//! run looks like, and the first thing anyone asks for in a bug report is a pasted log — so the +//! marks and the column layout here match the guide rather than being decided per call site. +//! 2. **Nothing here is a secret.** The auth token is printed by the handoff (Phase 2) straight to +//! the operator's terminal and never routed through a log file (PLAN.md §6). + +use std::io::{self, IsTerminal, Write}; + +/// Enables UTF-8 on the Windows console so the status marks below are not mojibake. +/// +/// The guide's illustrated output uses `✓ ⚠ ✗`, and PowerShell 5.1 on a machine whose console code +/// page is still 437/1252 renders those as garbage. `SetConsoleOutputCP` is the one-call fix; it is +/// declared inline rather than pulling in a Windows binding crate for a single symbol, and a +/// failure is ignored because a wrongly-encoded tick is a cosmetic problem, not a reason to refuse +/// to install. +#[cfg(windows)] +pub fn init_console() { + extern "system" { + fn SetConsoleOutputCP(code_page: u32) -> i32; + } + const CP_UTF8: u32 = 65001; + unsafe { + SetConsoleOutputCP(CP_UTF8); + } +} + +#[cfg(not(windows))] +pub fn init_console() {} + +pub fn ok(msg: &str) { + println!("✓ {msg}"); +} + +pub fn warn(msg: &str) { + println!("⚠ {msg}"); +} + +pub fn heading(msg: &str) { + println!("\n{msg}"); +} + +/// A two-column row: ` label value`. +pub fn row(label: &str, value: &str) { + println!(" {label:<16} {value}"); +} + +/// Asks a yes/no question. +/// +/// `assume_yes` (`--yes`) takes the default without asking, which is what makes an unattended run +/// expressible. A non-interactive run *without* `--yes` is an error rather than a silent default: +/// the questions this asks decide whether stock ServUO files get edited, and a pipe with no +/// terminal on the other end cannot consent to that. +pub fn confirm(question: &str, default: bool, assume_yes: bool) -> io::Result { + if assume_yes { + println!( + "{question} [{}] (--yes)", + if default { "Y/n" } else { "y/N" } + ); + return Ok(default); + } + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "cannot ask \"{question}\" — stdin is not a terminal. \ + Pass --yes to take the default, or the matching flag to answer it explicitly." + ))); + } + + loop { + print!("{question} [{}] ", if default { "Y/n" } else { "y/N" }); + io::stdout().flush()?; + let mut line = String::new(); + // EOF (0 bytes) is not "yes". It means the operator is gone; take the default and move on. + if io::stdin().read_line(&mut line)? == 0 { + println!(); + return Ok(default); + } + match line.trim().to_ascii_lowercase().as_str() { + "" => return Ok(default), + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => println!(" please answer y or n"), + } + } +} + +/// Asks for a line of text. An empty answer keeps `default` when one is offered. +pub fn prompt(question: &str, default: Option<&str>) -> io::Result { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "cannot ask \"{question}\" — stdin is not a terminal. Pass the matching flag." + ))); + } + loop { + match default { + Some(d) => print!("{question} [{d}]: "), + None => print!("{question}: "), + } + io::stdout().flush()?; + let mut line = String::new(); + if io::stdin().read_line(&mut line)? == 0 { + println!(); + return match default { + Some(d) => Ok(d.to_string()), + None => Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("no answer for \"{question}\""), + )), + }; + } + let answer = line.trim(); + if !answer.is_empty() { + return Ok(answer.to_string()); + } + if let Some(d) = default { + return Ok(d.to_string()); + } + println!(" an answer is required"); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..8fde480 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,205 @@ +//! Hashing and scratch-directory helpers. + +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +/// Lower-case hex, written out rather than taken from a crate. +/// +/// Every hash this tool handles is compared against one produced by `sha256sum` or by `jq` in CI, +/// both of which emit lower-case hex — so the formatting is part of the contract, not a display +/// choice. +pub fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a +/// straight-line hash of the same bytes; the run itself only ever hashes files and streams. +#[cfg(test)] +pub fn sha256_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex(&hasher.finalize()) +} + +/// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's +/// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs +/// once per deployed file. +pub fn sha256_file(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("cannot read {} to hash it", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .with_context(|| format!("cannot read {}", path.display()))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex(&hasher.finalize())) +} + +/// A [`Write`] that hashes everything passing through it. +/// +/// Downloads are verified *while* being written rather than by re-reading the finished file: it +/// halves the I/O and, more importantly, means the bytes that were hashed are provably the bytes +/// that were written. +pub struct HashingWriter { + inner: W, + hasher: Sha256, +} + +impl HashingWriter { + pub fn new(inner: W) -> Self { + Self { + inner, + hasher: Sha256::new(), + } + } + + pub fn finish(self) -> String { + hex(&self.hasher.finalize()) + } +} + +impl Write for HashingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let n = self.inner.write(buf)?; + self.hasher.update(&buf[..n]); + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +/// A scratch directory that deletes itself. +/// +/// Downloads and the extracted overlay land here. Hand-rolled rather than pulled from a crate +/// because the requirement is one directory with a unique name and a `Drop` — and because a failed +/// cleanup must never fail the run: by the time it matters the install has already succeeded or +/// failed on its own merits. +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub fn new(prefix: &str) -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&path) + .with_context(|| format!("cannot create scratch directory {}", path.display()))?; + Ok(Self { path }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +/// Writes a file by writing a sibling `.tmp` and renaming over the target. +/// +/// `install.json` is the record every later command reasons from; a half-written one after a +/// crash or a full disk would be worse than none at all, because `doctor` and `update` would +/// believe it. +pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + let tmp = path.with_extension("tmp"); + { + let mut file = + File::create(&tmp).with_context(|| format!("cannot create {}", tmp.display()))?; + file.write_all(contents) + .with_context(|| format!("cannot write {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("cannot flush {}", tmp.display()))?; + } + // Windows will not rename onto an existing file, so the old one goes first. The window this + // opens is the reason for the .tmp file existing at all: its content is already durable. + if path.exists() { + fs::remove_file(path).with_context(|| format!("cannot replace {}", path.display()))?; + } + fs::rename(&tmp, path).with_context(|| format!("cannot move {} into place", tmp.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The canonical empty-input SHA256. If this ever changes, everything else in the trust chain + // is meaningless, so it is worth one line. + const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + #[test] + fn hashing_matches_sha256sum() { + assert_eq!(sha256_bytes(b""), EMPTY); + assert_eq!( + sha256_bytes(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn file_and_byte_hashing_agree() { + let dir = TempDir::new("rg-test-hash").unwrap(); + let path = dir.path().join("f.bin"); + // Larger than the 64 KiB read buffer, so the streaming path is actually exercised. + let blob: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); + fs::write(&path, &blob).unwrap(); + assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob)); + } + + #[test] + fn the_hashing_writer_sees_what_was_written() { + let mut w = HashingWriter::new(Vec::new()); + w.write_all(b"abc").unwrap(); + assert_eq!( + w.finish(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn a_temp_dir_removes_itself() { + let path = { + let dir = TempDir::new("rg-test-drop").unwrap(); + fs::write(dir.path().join("x"), b"x").unwrap(); + dir.path().to_path_buf() + }; + assert!(!path.exists()); + } + + #[test] + fn atomic_write_replaces_an_existing_file() { + let dir = TempDir::new("rg-test-atomic").unwrap(); + let path = dir.path().join("nested").join("install.json"); + write_atomic(&path, b"first").unwrap(); + write_atomic(&path, b"second").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"second"); + assert!(!path.with_extension("tmp").exists()); + } +} From 2228e0848b90709e49e02a54c784db026b477f9f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 15:40:56 -0500 Subject: [PATCH 02/13] =?UTF-8?q?feat(installer):=20implement=20Phase=202?= =?UTF-8?q?=20=E2=80=94=20uo-link=20install=20and=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the sidecar half of a deployment to the same `install` run: download and verify the bundle's binary, provision its config, register and start a service, and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the binary and the config document; `src/service.rs` owns systemd and the Windows SCM. The order is fixed by PLAN.md §5 and matters: stop anything running the old binary, replace it, then `--print-config` (which writes the config the service will be pointed at), then register. Registering first points a service at a file that does not exist yet. Decisions worth a reviewer's attention: - Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets the `runicgateway` system user the plan already specified; Windows gets a virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`, which the SCM creates itself and which has no password. Plain `sc create` runs as LocalSystem — the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root. - `sidecar.toml` holds the auth token and neither default location protects it: /etc is world-readable and %ProgramData% grants Users read by inheritance, so a stock install would leave the shard's token readable by any local account. The lockdown straddles registration because it has to — on Windows the service account does not exist until `sc create` creates it, so the file is first cut down to SYSTEM + Administrators, and the account's read grant comes after. - Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative [store] path to its config's directory, so the pin is redundant — and `sc.exe` has no per-service environment, only a machine-wide one that every process inherits and that outlives an uninstall. The config path rides in the service's own binPath instead. - `--verify` runs no part of the sidecar half. `--print-config` provisions: it writes the config and mints a token, so a dry run that called it would create the state it claims not to. It also carries an existing `link` section of install.json through untouched, so a dry run cannot make a service disappear from the record. - The installed binary's protocol version is checked against the bundle before the service is registered. Gate 1 read that number from source at the release tag; this is the same check applied to the binary that will actually answer the website. - RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses service registration and the file-permission hardening. There is no such thing as a relocated systemd unit, and hardening a scratch config against the only account that will ever read it just breaks the next test run. - A host with no systemd, or where the service user cannot be created, still gets a working binary and config plus the exact unit and commands. There is no fallback to User=root or LocalSystem: a service quietly running with more privilege than its documentation promises is worse than one that was not registered. - install.json never records the token. The `link` section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account. Docs half: docs#91. Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end on Windows against a relocated layout — bundle sidecar downloaded and verified, config provisioned, handoff printed with URLs composed from the host rather than the bind address, second run reporting unchanged with install.json byte-identical, --verify over an installed host writing nothing and preserving the link section, and a tampered binary detected by hash and replaced with no staging file left. Co-Authored-By: Claude --- src/install.rs | 331 ++++++++++++++++--- src/lib.rs | 10 +- src/paths.rs | 83 ++++- src/record.rs | 105 +++++- src/service.rs | 858 +++++++++++++++++++++++++++++++++++++++++++++++++ src/sidecar.rs | 465 +++++++++++++++++++++++++++ src/util.rs | 124 ++++++- 7 files changed, 1919 insertions(+), 57 deletions(-) create mode 100644 src/service.rs create mode 100644 src/sidecar.rs diff --git a/src/install.rs b/src/install.rs index fe06903..b2a8d88 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,10 +1,24 @@ //! The `install` command. //! -//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the -//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2) -//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather -//! than ending on a success line that would read as a finished install. An operator who cannot tell -//! which half ran is the failure this whole tool exists to remove. +//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync +//! the overlay, install the sidecar and register its service, record what was deployed, and print +//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so +//! in as many words rather than ending on a success line that would read as a finished install. An +//! operator who cannot tell which half ran is the failure this whole tool exists to remove. +//! +//! 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 sidecar.** The sidecar is what the shard dials out to, but the shard is +//! stopped throughout; deploying code the shard will compile is the step with a running-process +//! hazard attached, so it happens while the check that guards it is freshest. +//! 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}; @@ -12,11 +26,12 @@ use anyhow::{bail, Context, Result}; use crate::cli::{Cli, PatchChoice}; use crate::record::{ - now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA, + 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, servuo, ui}; +use crate::{bundle, net, overlay, paths, service, servuo, sidecar, ui}; pub fn run(cli: &Cli) -> Result<()> { let layout = paths::layout(); @@ -60,7 +75,7 @@ pub fn run(cli: &Cli) -> Result<()> { ui::row( "Sidecar", &format!( - "{:<24} protocol {} (Phase 2 — not installed by this build)", + "{:<24} protocol {}", format!("uo-link {}", bundle.link.tag), bundle.link.protocol ), @@ -77,6 +92,13 @@ pub fn run(cli: &Cli) -> Result<()> { )); } + // 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); @@ -172,7 +194,9 @@ pub fn run(cli: &Cli) -> Result<()> { // ── What this build does not do ────────────────────────────────────────── report_patch_tier(cli); - report_sidecar(&bundle, &sidecar_asset, &layout); + + // ── The sidecar and its service ────────────────────────────────────────── + let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?; // ── Record ─────────────────────────────────────────────────────────────── let record = build_record( @@ -182,6 +206,7 @@ pub fn run(cli: &Cli) -> Result<()> { &root, &manifest, &planned, + sidecar.as_ref(), ); if cli.verify { @@ -218,14 +243,24 @@ pub fn run(cli: &Cli) -> Result<()> { } else if cli.verify { println!("Nothing was written. Re-run without --verify to deploy."); } else { - println!("Nothing to do — this tree already has this overlay."); + println!("The ServUO tree already has this overlay — nothing was changed there."); } - if cli.host.is_some() || cli.site_url.is_some() { + // ── 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). + if let Some(sidecar) = &sidecar { + let host = resolve_host(cli); println!( - "\nNote: --host/--site-url are used by the token handoff, which arrives with the \ - sidecar in Phase 2. They had no effect on this run." + "{}", + 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.", + ); + } } Ok(()) } @@ -297,29 +332,240 @@ fn report_patch_tier(cli: &Cli) { println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding."); } -fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) { - println!(); - ui::warn(&format!( - "uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \ - Without it the shard has nothing to dial out to and your website stays offline.\n \ - Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \ - binary {}\n \ - config {}\n \ - database {}\n \ - download {}\n \ - sha256 {}\n \ - Then provision and read the token back with:\n \ - --print-config --config {}\n \ - The bundle pairs it with overlay {} at protocol {}; keep the two in step.", - layout.sidecar_bin.display(), - layout.sidecar_config().display(), - layout.sidecar_db().display(), - asset.url, - asset.sha256, - layout.sidecar_config().display(), - bundle.overlay.tag, - bundle.protocol, - )); +/// 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, +) -> 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, + }) + } + 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, + })) +} + +/// 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) } fn build_record( @@ -329,6 +575,7 @@ fn build_record( root: &ServUoRoot, manifest: &overlay::Manifest, planned: &[overlay::PlannedFile], + sidecar: Option<&SidecarOutcome>, ) -> InstallRecord { InstallRecord { schema: SCHEMA, @@ -353,10 +600,14 @@ fn build_record( protocol: manifest.protocol, files: overlay::file_records(planned), }), - // Sections this build does not own are carried through verbatim, so a Phase 1 binary - // re-running on a fully installed host cannot make a service or a set of applied patch - // hunks disappear from the record that documents them. - link: prior.and_then(|p| p.link.clone()), + // 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 (Phase 3) 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()), + }, patches: prior.map(|p| p.patches.clone()).unwrap_or_default(), extra: prior.map(|p| p.extra.clone()).unwrap_or_default(), } diff --git a/src/lib.rs b/src/lib.rs index 3e641dc..e3e4fcf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,10 +4,10 @@ //! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary //! existed, is `docs/installer/INSTALL.md`. //! -//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and -//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2), -//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and -//! every one of them says so when reached rather than failing as though it were a typo. +//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation, +//! the overlay sync, `install.json`, the uo-link sidecar and its service, and the token handoff. +//! The patch tier (Phase 3) and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and +//! each of them says so when reached rather than failing as though it were a typo. //! //! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same //! convention as the sidecar's CLI. @@ -33,7 +33,9 @@ pub mod net; pub mod overlay; pub mod paths; pub mod record; +pub mod service; pub mod servuo; +pub mod sidecar; pub mod ui; pub mod util; diff --git a/src/paths.rs b/src/paths.rs index 5105983..f3415a2 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -3,11 +3,15 @@ //! 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 (from Phase 2) pins `UOLINK_CONFIG` and -//! `UOLINK_DB_PATH` into the service definition. +//! The installer therefore owns the layout and pins the config path into the service definition. //! -//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole -//! layout is declared here so Phase 2 and 3 add nothing new to argue about. +//! **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; @@ -21,10 +25,13 @@ pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR"; pub struct Layout { /// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`. pub state_dir: PathBuf, - /// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2. + /// `/var/lib/runicgateway` — the sidecar's SQLite store. pub data_dir: PathBuf, - /// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2. + /// `/usr/bin/runicgateway-link` — the installed sidecar binary. pub 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 { @@ -39,20 +46,40 @@ impl Layout { pub fn sidecar_db(&self) -> PathBuf { self.data_dir.join("uo-link.db") } + + /// 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) + } } /// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`]. /// -/// The override moves the *state* and *data* directories together. Splitting them under an override -/// would make a test run write half its files into the real system location, which is exactly the -/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes -/// it, and a relocated binary would not be what the service definition names. +/// 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")); layout.data_dir = root.join("data"); + layout.sidecar_bin = root.join("bin").join(bin_name); layout.state_dir = root; + layout.relocated = true; } layout } @@ -76,6 +103,7 @@ fn platform_layout() -> Layout { sidecar_bin: program_files .join("RunicGateway") .join("uo-link-sidecar.exe"), + relocated: false, } } @@ -85,6 +113,7 @@ fn platform_layout() -> Layout { state_dir: PathBuf::from("/etc/runicgateway"), data_dir: PathBuf::from("/var/lib/runicgateway"), sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), + relocated: false, } } @@ -109,5 +138,39 @@ mod tests { 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 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()); } } diff --git a/src/record.rs b/src/record.rs index 61ce773..588a04a 100644 --- a/src/record.rs +++ b/src/record.rs @@ -35,7 +35,12 @@ pub struct InstallRecord { pub servuo: ServUoRef, #[serde(skip_serializing_if = "Option::is_none")] pub overlay: Option, - /// Phase 2 (uo-link binary, config and service). Carried through untouched by this build. + /// The sidecar: binary, config, database, service (Phase 2). + /// + /// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer* + /// installer — with fields this build has no name for — survives a re-run here intact. Phase 1 + /// carried this section through without understanding it at all; the same tolerance now applies + /// in the other direction. Read it with [`InstallRecord::link_record`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// Phase 3 (applied patches, with the rung that applied each). Carried through untouched. @@ -98,6 +103,53 @@ pub struct FileRecord { pub state: String, } +/// The sidecar half of a deployment, as `install.json` records it. +/// +/// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once +/// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted +/// into bug reports. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LinkRecord { + pub repo: String, + pub tag: String, + /// What the installed binary reports, not what the bundle claimed — the two agree, and if they + /// ever did not, the binary is the one that will actually run. + pub version: String, + pub protocol: u32, + pub binary: BinaryRef, + pub config_path: String, + /// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's + /// directory rather than pinned by the service, which is why it is recorded rather than derived. + pub db_path: String, + /// `None` when no service was registered — a relocated test run, or a host with no service + /// manager the installer can drive. `doctor` reports that as an unfinished install rather than + /// as a healthy one. + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BinaryRef { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServiceRecord { + /// `systemd` or `windows-scm`. + pub kind: String, + /// `runicgateway-link.service` or `RunicGatewayLink`. + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit_path: Option, + /// The account the service runs as. + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// The installer created that account. `uninstall` (Phase 4) removes only what it created — + /// deleting a user that was already on the host is not this tool's business. + pub user_created: bool, +} + impl InstallRecord { /// Whether a re-run would record anything new. /// @@ -142,6 +194,15 @@ impl InstallRecord { pub fn overlay_files(&self) -> Option<&BTreeMap> { self.overlay.as_ref().map(|o| &o.files) } + + /// The sidecar section, when it is one this build understands. + /// + /// A section it cannot parse yields `None` rather than an error: the raw value is still carried + /// through on save, so the worst case is that this run re-derives what it needs instead of + /// reading it — never that an older installer refuses to run on a newer host. + pub fn link_record(&self) -> Option { + serde_json::from_value(self.link.clone()?).ok() + } } pub fn now_rfc3339() -> String { @@ -256,6 +317,48 @@ mod tests { assert!(!a.same_deployment_as(&d)); } + #[test] + fn the_link_section_round_trips_and_holds_no_secret() { + let link = 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: "27d491ef".repeat(8), + }, + config_path: "/etc/runicgateway/sidecar.toml".into(), + db_path: "/var/lib/runicgateway/uo-link.db".into(), + service: Some(ServiceRecord { + kind: "systemd".into(), + name: "runicgateway-link.service".into(), + unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()), + user: Some("runicgateway".into()), + user_created: true, + }), + }; + + let mut record = sample(); + record.link = Some(serde_json::to_value(&link).unwrap()); + assert_eq!(record.link_record().unwrap(), link); + + // install.json is pasted into bug reports. The token lives in sidecar.toml and on the + // operator's terminal; there is no field here for it to arrive in. + let text = serde_json::to_string(&record).unwrap(); + assert!(!text.contains("auth_token"), "{text}"); + assert!(!text.contains("token"), "{text}"); + } + + #[test] + fn an_unreadable_link_section_is_ignored_rather_than_fatal() { + // A record written by a future installer must not stop this one from running. + let mut record = sample(); + record.link = Some(serde_json::json!({ "shape": "from a newer installer" })); + assert!(record.link_record().is_none()); + assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok()); + } + #[test] fn timestamps_are_utc_rfc3339() { let now = now_rfc3339(); diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..0b31e8b --- /dev/null +++ b/src/service.rs @@ -0,0 +1,858 @@ +//! Registering the sidecar as a service — systemd on Linux, the SCM on Windows. +//! +//! PLAN.md §5 Phase 2 and §8 question 1. The mechanism is the plainest one that works on a stock +//! host: a written unit file and `systemctl`, or `sc.exe`. No WinSW/NSSM shim to ship and keep +//! current, and no `--service` mode added to the sidecar — a change to `link` for something the +//! platform already does. +//! +//! ## The two definitions are not symmetrical, on purpose +//! +//! - **Both run as a dedicated, unprivileged identity.** Linux gets a system user +//! (`runicgateway`); Windows gets a *virtual service account* (`NT SERVICE\RunicGatewayLink`), +//! which the SCM creates itself, has no password, and exists only for this service. A sidecar +//! that listens on two TCP ports has no business running as `LocalSystem` when its Linux twin +//! does not run as root. +//! - **Only Linux carries `UOLINK_DB_PATH`.** On Windows the config and the database live in the +//! same directory and the sidecar already anchors a relative `[store].path` there, so the pin is +//! redundant — and the only way to give a Windows service an environment variable through +//! `sc.exe` is to set a *machine-wide* one, which every process on the host would inherit and +//! which would outlive an uninstall. The config path is passed as `--config` in the service's own +//! command line instead, which is scoped to this service by construction. +//! +//! ## Failure is degradation, not an aborted install +//! +//! A host with no systemd (openrc, a container, a distro that never had it) or one where the +//! service user cannot be created still gets a working binary and a provisioned config. What it +//! does not get is a silently weaker service — there is no fallback to `User=root` or to +//! `LocalSystem`. It gets the exact unit text and the exact commands, printed, and `install.json` +//! records that no service was registered so `doctor` keeps saying so. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::util::{command_line, run, run_ok}; + +/// The unit file name, and the systemd service name with its suffix. +pub const SYSTEMD_UNIT: &str = "runicgateway-link.service"; +/// The Windows service key name (INSTALL.md §3). +pub const WINDOWS_SERVICE: &str = "RunicGatewayLink"; +/// The dedicated Linux system user. +pub const SERVICE_USER: &str = "runicgateway"; +/// What both platforms show a human. +const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar"; + +/// Which service manager this host has — or why it has none this installer can drive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Manager { + Systemd, + WindowsScm, + /// No service will be registered. The string is the reason, printed to the operator verbatim. + Unavailable(String), +} + +impl Manager { + pub fn kind(&self) -> &'static str { + match self { + Self::Systemd => "systemd", + Self::WindowsScm => "windows-scm", + Self::Unavailable(_) => "none", + } + } +} + +/// Everything decided before the sidecar's config is provisioned: which manager, and which identity +/// the service will run as. Split from [`register`] because the identity has to exist *before* +/// `--print-config` writes a config file that then has to be owned by it. +#[derive(Debug, Clone)] +pub struct Prepared { + pub manager: Manager, + /// The account the service runs as, when this platform names one the installer has to create. + /// `None` on Windows, where the SCM creates the virtual account itself as part of registration. + pub user: Option, + /// This run created that account — recorded so `uninstall` (Phase 4) knows whether removing it + /// is its business or somebody else's. + pub user_created: bool, +} + +/// What registration ended up doing. +#[derive(Debug, Clone)] +pub enum Outcome { + Registered { + kind: &'static str, + name: String, + unit_path: Option, + user: Option, + user_created: bool, + /// `running, enabled` — read back from the manager, not assumed from the exit codes. + state: String, + }, + /// Nothing was registered. `reason` says why in one line; `manual` is the full set of steps. + Skipped { reason: String, manual: String }, +} + +impl Outcome { + pub fn registered(&self) -> bool { + matches!(self, Self::Registered { .. }) + } +} + +/// Detects the service manager and makes sure the service identity exists. +/// +/// Never returns `Err`: everything that can go wrong here is a reason to skip service registration +/// and say so, not a reason to fail an install whose binary and config are already correct. +pub fn prepare(relocated: bool) -> Prepared { + if relocated { + return unavailable(format!( + "{} is set, so this is a test run", + crate::paths::STATE_DIR_ENV + )); + } + prepare_platform() +} + +fn unavailable(reason: String) -> Prepared { + Prepared { + manager: Manager::Unavailable(reason), + user: None, + user_created: false, + } +} + +// ── Linux ──────────────────────────────────────────────────────────────────── + +#[cfg(unix)] +fn prepare_platform() -> Prepared { + // The canonical "was this host booted with systemd" test. `systemctl` being on PATH is not the + // same question — it is installed in plenty of containers where PID 1 is not systemd, and + // `systemctl enable` there fails with a message about the D-Bus socket rather than anything an + // operator can act on. + if !Path::new("/run/systemd/system").is_dir() { + return unavailable( + "this host is not running systemd (/run/systemd/system does not exist)".to_string(), + ); + } + match ensure_user(SERVICE_USER) { + Ok(created) => Prepared { + manager: Manager::Systemd, + user: Some(SERVICE_USER.to_string()), + user_created: created, + }, + // No fallback to User=root. A service that quietly runs with more privilege than its own + // documentation promises is worse than one that was not registered. + Err(error) => unavailable(format!( + "the {SERVICE_USER} service user does not exist and could not be created ({error})" + )), + } +} + +/// Creates the dedicated system user if it is not already there. Returns whether it created it. +#[cfg(unix)] +fn ensure_user(user: &str) -> Result { + if run("id", &["-u", user]).map(|o| o.status.success()) == Ok(true) { + return Ok(false); + } + // `useradd` is the near-universal spelling; `adduser` is the fallback for Debian's wrapper and + // for busybox, whose `adduser` is the only one present on a minimal image. + let useradd = run_ok( + "useradd", + &[ + "--system", + "--no-create-home", + "--shell", + "/usr/sbin/nologin", + user, + ], + ); + if useradd.is_ok() { + return Ok(true); + } + run_ok("adduser", &["--system", "--no-create-home", user]) + .map(|_| true) + .map_err(|adduser_error| { + anyhow::anyhow!( + "{}; and {}", + useradd.unwrap_err().to_string().replace('\n', " "), + adduser_error.to_string().replace('\n', " ") + ) + }) +} + +/// The unit file. Pure, so its content is a test rather than something only a Linux host can check. +/// +/// Deliberately close to the hand-written unit in INSTALL.md Appendix A4 — an operator who set this +/// up by hand and later runs the installer should recognize what replaces their file. +pub fn systemd_unit_text(binary: &Path, config: &Path, db: &Path, user: &str) -> String { + format!( + "# {DISPLAY_NAME}\n\ + #\n\ + # Generated by the Runic Gateway installer {installer}. It is rewritten by `install` and\n\ + # `update` whenever its content changes, so local edits belong in a drop-in instead:\n\ + # systemctl edit {SYSTEMD_UNIT}\n\ + \n\ + [Unit]\n\ + Description={DISPLAY_NAME}\n\ + After=network.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + User={user}\n\ + Environment=UOLINK_CONFIG={config}\n\ + Environment=UOLINK_DB_PATH={db}\n\ + ExecStart={binary}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + \n\ + [Install]\n\ + WantedBy=multi-user.target\n", + installer = env!("CARGO_PKG_VERSION"), + config = config.display(), + db = db.display(), + binary = binary.display(), + ) +} + +#[cfg(unix)] +fn register_systemd( + prepared: &Prepared, + binary: &Path, + config: &Path, + db: &Path, + unit_path: &Path, + restart: bool, +) -> Result { + let user = prepared.user.clone().unwrap_or_else(|| "root".into()); + let text = systemd_unit_text(binary, config, db, &user); + + // An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on + // every run is a change an operator watching /etc has to investigate and then dismiss. + let current = std::fs::read_to_string(unit_path).unwrap_or_default(); + if current != text { + crate::util::write_atomic(unit_path, text.as_bytes()) + .with_context(|| format!("cannot write {}", unit_path.display()))?; + run_ok("systemctl", &["daemon-reload"])?; + } + + run_ok("systemctl", &["enable", SYSTEMD_UNIT])?; + if restart { + // The binary underneath a running service has just been replaced; `start` on an already + // active unit is a no-op and would leave the old code running. + run_ok("systemctl", &["restart", SYSTEMD_UNIT])?; + } else { + run_ok("systemctl", &["start", SYSTEMD_UNIT])?; + } + + Ok(Outcome::Registered { + kind: "systemd", + name: SYSTEMD_UNIT.to_string(), + unit_path: Some(unit_path.to_path_buf()), + user: prepared.user.clone(), + user_created: prepared.user_created, + state: systemd_state(), + }) +} + +/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl +/// start` succeeding and the service still being up a second later are different claims. +#[cfg(unix)] +fn systemd_state() -> String { + let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT])); + let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT])); + format!("{active}, {enabled}") +} + +#[cfg(unix)] +fn one_word(result: Result) -> String { + result + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ── Windows ────────────────────────────────────────────────────────────────── + +#[cfg(windows)] +fn prepare_platform() -> Prepared { + // The SCM creates the virtual account as part of `sc create obj= "NT SERVICE\"`, so + // there is nothing to provision here — and nothing that can fail before the config exists. + Prepared { + manager: Manager::WindowsScm, + user: None, + user_created: false, + } +} + +/// The virtual service account the SCM creates for this service. Locale-independent: the `NT +/// SERVICE\` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name +/// is translated. +pub fn windows_service_account() -> String { + format!("NT SERVICE\\{WINDOWS_SERVICE}") +} + +/// The `binPath=` value: the executable and the `--config` it must always be started with. +/// +/// Pure and tested on both platforms because it is the single string that decides whether an +/// installed service reads the config the installer wrote, or whatever `sidecar.toml` happens to be +/// in the service's working directory — which, for a Windows service, is `%SystemRoot%\System32`. +pub fn windows_bin_path(binary: &Path, config: &Path) -> String { + format!("\"{}\" --config \"{}\"", binary.display(), config.display()) +} + +#[cfg(windows)] +fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result { + let bin_path = windows_bin_path(binary, config); + let account = windows_service_account(); + + if windows_service_exists() { + // `config` rather than delete-and-recreate: recreating would drop the failure actions and, + // more to the point, would briefly leave a host with no service if the create half failed. + run_ok( + "sc.exe", + &[ + "config", + WINDOWS_SERVICE, + "binPath=", + &bin_path, + "start=", + "auto", + "obj=", + &account, + ], + )?; + } else { + run_ok( + "sc.exe", + &[ + "create", + WINDOWS_SERVICE, + "binPath=", + &bin_path, + "start=", + "auto", + "obj=", + &account, + "DisplayName=", + DISPLAY_NAME, + ], + ) + .context( + "could not register the Windows service. The account is a virtual service account \ + (no password); if this host's policy forbids them, register the service by hand — \ + INSTALL.md Appendix A4.", + )?; + let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]); + } + + // Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400` + // means the failure count goes back to zero after a quiet day, so a service that crashes once + // a month keeps being restarted. + run_ok( + "sc.exe", + &[ + "failure", + WINDOWS_SERVICE, + "reset=", + "86400", + "actions=", + "restart/5000", + ], + )?; + + if restart && windows_service_state().contains("RUNNING") { + stop_windows_service()?; + } + // 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure. + let start = run("sc.exe", &["start", WINDOWS_SERVICE])?; + if !start.status.success() && start.status.code() != Some(1056) { + anyhow::bail!( + "`{}` failed with exit code {}. Check the Windows event log; a service that exits \ + immediately usually cannot read its config: {}", + command_line("sc.exe", &["start", WINDOWS_SERVICE]), + start.status.code().unwrap_or(-1), + config.display() + ); + } + + Ok(Outcome::Registered { + kind: "windows-scm", + name: WINDOWS_SERVICE.to_string(), + unit_path: None, + user: Some(account), + user_created: false, + state: format!("{}, automatic start", windows_service_state()), + }) +} + +/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as +/// "it exists", so the caller reconfigures rather than trying to create a service that is there. +#[cfg(windows)] +fn windows_service_exists() -> bool { + match run("sc.exe", &["query", WINDOWS_SERVICE]) { + Ok(output) => output.status.code() != Some(1060), + Err(_) => false, + } +} + +#[cfg(windows)] +fn windows_service_state() -> String { + let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else { + return "unknown".to_string(); + }; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.trim_start().starts_with("STATE") { + // " STATE : 4 RUNNING" + if let Some(word) = line.split_whitespace().last() { + return word.to_string(); + } + } + } + "unknown".to_string() +} + +#[cfg(windows)] +fn stop_windows_service() -> Result<()> { + // 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for. + let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?; + if !stop.status.success() && stop.status.code() != Some(1062) { + anyhow::bail!( + "cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \ + service runs, so the install cannot replace it.", + stop.status.code().unwrap_or(-1) + ); + } + // The SCM returns as soon as the stop is *pending*; the file stays locked until the process + // actually exits. Polling is the only way to know, and a fixed sleep would be either too short + // or a delay on every run. + for _ in 0..30 { + if windows_service_state() == "STOPPED" { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + anyhow::bail!( + "{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \ + sc.exe stop {WINDOWS_SERVICE}" + ) +} + +// ── Shared entry points ────────────────────────────────────────────────────── + +/// Stops a running service so its binary can be replaced. +/// +/// Called only when the installed binary differs from the bundle's. On Windows the file is locked +/// while the service runs; on Linux replacing it under a running process is permitted but leaves +/// the old code serving until something restarts it, which is a worse outcome than a brief gap. +pub fn stop_for_replacement(manager: &Manager) -> Result<()> { + match manager { + #[cfg(unix)] + Manager::Systemd => { + // Not `run_ok`: a unit that is not loaded yet (first install) exits non-zero, and that + // is the normal case rather than an error. + let _ = run("systemctl", &["stop", SYSTEMD_UNIT]); + Ok(()) + } + #[cfg(windows)] + Manager::WindowsScm => { + if windows_service_exists() { + stop_windows_service()?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Registers, enables and starts the service — or explains why it did not. +/// +/// `binary_changed` decides restart versus start: a replaced binary under an already-running +/// service must be restarted, or the host keeps running the code that was just replaced. +pub fn register( + prepared: &Prepared, + layout: &crate::paths::Layout, + binary_changed: bool, +) -> Result { + let config = layout.sidecar_config(); + let db = layout.sidecar_db(); + + match &prepared.manager { + Manager::Unavailable(reason) => Ok(Outcome::Skipped { + reason: reason.clone(), + manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated), + }), + #[cfg(unix)] + Manager::Systemd => register_systemd( + prepared, + &layout.sidecar_bin, + &config, + &db, + &layout.systemd_unit(), + binary_changed, + ), + #[cfg(windows)] + Manager::WindowsScm => register_windows(&layout.sidecar_bin, &config, binary_changed), + // The manager this build cannot drive because it was compiled for the other platform. Only + // reachable if a Manager is constructed by hand; the detector never produces it. + #[allow(unreachable_patterns)] + other => Ok(Outcome::Skipped { + reason: format!("{} is not supported by this build", other.kind()), + manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated), + }), + } +} + +/// What to do by hand when no service was registered. The whole point of degrading rather than +/// failing: the operator ends the run with a working binary and the exact commands. +/// +/// `config_protected` says whether the run already locked the config file down. It must not be +/// guessed: a relocated run deliberately leaves the permissions alone (see [`protect_config`]), and +/// a printed recipe that claims a token file is already protected when it is not is worse than one +/// that simply tells the operator to protect it. +pub fn manual_steps(binary: &Path, config: &Path, db: &Path, config_protected: bool) -> String { + #[cfg(windows)] + { + // `binPath=` is wrapped in *single* quotes, which is what makes this line pasteable into + // PowerShell: the value itself contains the double quotes the SCM needs around a path with + // spaces, and PowerShell would otherwise eat them. The `sc.exe` convention of a space after + // each `=` is not a typo either — the key and the value are separate arguments. + format!( + " From an elevated PowerShell:\n\n \ + sc.exe create {WINDOWS_SERVICE} binPath= '{bin_path}' obj= '{account}' start= auto\n \ + sc.exe failure {WINDOWS_SERVICE} reset= 86400 actions= restart/5000\n \ + icacls '{config}' /grant '{account}:(R)'\n \ + icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \ + sc.exe start {WINDOWS_SERVICE}\n\n{token_note}", + bin_path = windows_bin_path(binary, config), + account = windows_service_account(), + config = config.display(), + data_dir = db.parent().unwrap_or(db).display(), + token_note = if config_protected { + " That config file's own permissions have already been restricted to \ + Administrators\n and SYSTEM, because it holds the auth token. The two grants \ + above are what the\n service account needs once `sc create` has created it.\n" + .to_string() + } else { + format!( + " That config file holds the auth token, and this run did NOT restrict its\n \ + permissions. Lock it down too:\n\n \ + icacls '{config}' /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r \ + '*S-1-5-32-544:(F)'\n", + config = config.display(), + ) + }, + ) + } + #[cfg(not(windows))] + { + // The chown lines are printed whether or not the file has already been chmod'ed: on this + // platform `protect_config` restricts the mode but can only hand the file to a user that + // exists, and reaching here means one does not. + let _ = config_protected; + format!( + " Write this to /etc/systemd/system/{SYSTEMD_UNIT}:\n\n{unit}\n \ + Then:\n\n \ + useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \ + chown {SERVICE_USER} {config}\n \ + chown -R {SERVICE_USER} {data_dir}\n \ + systemctl daemon-reload\n \ + systemctl enable --now {SYSTEMD_UNIT}\n\n \ + On a host without systemd, run the binary under whatever supervisor it does have —\n \ + the only requirements are that it starts {binary} with UOLINK_CONFIG={config}\n \ + and UOLINK_DB_PATH={db}, as an unprivileged user that can write the database.\n", + unit = indent(&systemd_unit_text(binary, config, db, SERVICE_USER)), + config = config.display(), + db = db.display(), + data_dir = db.parent().unwrap_or(db).display(), + binary = binary.display(), + ) + } +} + +#[cfg(not(windows))] +fn indent(text: &str) -> String { + text.lines() + .map(|l| format!(" {l}\n")) + .collect::() +} + +/// Locks down the files holding the auth token, **before** the service is registered. +/// +/// `sidecar.toml` holds the token, and neither default location protects it on its own: `/etc` is +/// world-readable, and `%ProgramData%` grants `Users` read by inheritance. This runs as early as the +/// platform allows so the file is never sitting there readable while the rest of the run happens. +/// +/// On Linux that is the whole job — the service user already exists, so the file can be handed to it +/// here. On Windows the service account does not exist until `sc create` creates it, so this step +/// only shuts everyone else out and [`grant_service_access`] does the rest afterwards. +/// +/// `relocated` is not a courtesy flag. On Windows this replaces the file's ACL with SYSTEM and +/// Administrators, which is right for a real install — the installer runs elevated and a service +/// account is about to be granted read — and wrong for a relocated test run, where there is no +/// service account and the operator is explicitly *not* an administrator. Hardening a scratch file +/// against the only person who will ever read it just makes the next test run fail. +pub fn protect_config( + config: &Path, + data_dir: &Path, + user: Option<&str>, + relocated: bool, +) -> Result<()> { + protect_config_platform(config, data_dir, user, relocated) +} + +/// Grants the registered service account access to what it must read and write. +/// +/// A no-op on Linux, where the account was known before the config existed and `protect_config` +/// already handed both to it. On Windows this is the second half of that job, and it can only run +/// once the SCM has created the virtual account. +pub fn grant_service_access(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> { + grant_service_access_platform(config, data_dir, outcome) +} + +#[cfg(unix)] +fn grant_service_access_platform( + _config: &Path, + _data_dir: &Path, + _outcome: &Outcome, +) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn protect_config_platform( + config: &Path, + data_dir: &Path, + user: Option<&str>, + _relocated: bool, +) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(config, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("cannot restrict permissions on {}", config.display()))?; + + if let Some(user) = user { + // `chown` the command rather than a libc call: this crate has no libc dependency, the + // operation happens once per run, and a failure here has to be reported with the exact + // command anyway. Group is deliberately not set — `useradd --system` creates a matching + // group on most distros but not all, and naming one that does not exist fails the chown. + run_ok("chown", &[user, &config.to_string_lossy()])?; + run_ok("chown", &["-R", user, &data_dir.to_string_lossy()])?; + } + Ok(()) +} + +#[cfg(windows)] +fn protect_config_platform( + config: &Path, + _data_dir: &Path, + _user: Option<&str>, + relocated: bool, +) -> Result<()> { + if relocated { + return Ok(()); + } + // Well-known SIDs, not display names: `Administrators` and `SYSTEM` are localized, and an + // icacls line naming them fails on a non-English Windows. + // *S-1-5-18 NT AUTHORITY\SYSTEM + // *S-1-5-32-544 BUILTIN\Administrators + // Removing inheritance is the entire point: %ProgramData% grants `Users` read by inheritance, + // and the auth token is in this file. + run_ok( + "icacls", + &[ + config.to_string_lossy().into_owned(), + "/inheritance:r".to_string(), + "/grant:r".to_string(), + "*S-1-5-18:(F)".to_string(), + "/grant:r".to_string(), + "*S-1-5-32-544:(F)".to_string(), + ], + ) + .context("cannot restrict access to the sidecar config, which holds the auth token")?; + Ok(()) +} + +#[cfg(windows)] +fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> { + // Nothing to grant when nothing was registered: the account only exists because `sc create` + // made it, and a skipped registration leaves the config locked to Administrators — which is the + // right resting state for a host where no service is going to read it. + if !outcome.registered() { + return Ok(()); + } + let account = windows_service_account(); + + run_ok( + "icacls", + &[ + config.to_string_lossy().into_owned(), + "/grant".to_string(), + format!("{account}:(R)"), + ], + ) + .context("cannot grant the service account read access to its config")?; + + // The service creates the database — and SQLite's journal and WAL files beside it — so the + // grant is on the directory: `(OI)(CI)M` = modify, inherited by files and subdirectories. On + // Windows that directory also holds `install.json`, because §3 puts both under + // `%ProgramData%\RunicGateway`; the config file is unaffected, since removing its inheritance + // above is what stops a directory grant from reaching it. + run_ok( + "icacls", + &[ + data_dir.to_string_lossy().into_owned(), + "/grant".to_string(), + format!("{account}:(OI)(CI)M"), + ], + ) + .context("cannot grant the service account write access to its database directory")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_unit_pins_both_paths_and_an_unprivileged_user() { + // Every line here is load-bearing: an unpinned config path resolves against the service's + // working directory (PLAN.md §2.3), and User= is the whole reason the installer creates an + // account at all. + let unit = systemd_unit_text( + Path::new("/usr/bin/runicgateway-link"), + Path::new("/etc/runicgateway/sidecar.toml"), + Path::new("/var/lib/runicgateway/uo-link.db"), + SERVICE_USER, + ); + assert!(unit.contains("Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml")); + assert!(unit.contains("Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db")); + assert!(unit.contains("ExecStart=/usr/bin/runicgateway-link")); + assert!(unit.contains(&format!("User={SERVICE_USER}"))); + assert!(!unit.contains("User=root"), "{unit}"); + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=multi-user.target")); + // The drop-in pointer, so an operator with local changes is not fighting the installer. + assert!(unit.contains("systemctl edit"), "{unit}"); + } + + #[test] + fn the_windows_bin_path_quotes_both_paths() { + // Both default Windows paths contain a space ("Program Files", and any relocated tree can). + // An unquoted binPath is the classic Windows service bug: the SCM would try to run + // C:\Program.exe with "Files\..." as an argument. + let line = windows_bin_path( + Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"), + Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"), + ); + assert_eq!( + line, + "\"C:\\Program Files\\RunicGateway\\uo-link-sidecar.exe\" \ + --config \"C:\\ProgramData\\RunicGateway\\sidecar.toml\"" + ); + } + + #[test] + fn the_service_account_is_the_virtual_one() { + // Not LocalSystem. The Linux half runs as an unprivileged user and this is its counterpart. + assert_eq!(windows_service_account(), "NT SERVICE\\RunicGatewayLink"); + } + + #[test] + fn a_relocated_run_registers_nothing() { + // There is no such thing as a relocated system service, so a test run must not create one. + let prepared = prepare(true); + assert!(matches!(prepared.manager, Manager::Unavailable(_))); + assert!(prepared.user.is_none()); + match &prepared.manager { + Manager::Unavailable(reason) => { + assert!(reason.contains(crate::paths::STATE_DIR_ENV), "{reason}") + } + other => panic!("{other:?}"), + } + } + + #[test] + fn the_manual_steps_are_a_complete_recipe() { + // This text is all an operator gets on a host the installer cannot drive, so it has to name + // the binary, the config and the service — not merely gesture at the documentation. + let steps = manual_steps( + Path::new("/usr/bin/runicgateway-link"), + Path::new("/etc/runicgateway/sidecar.toml"), + Path::new("/var/lib/runicgateway/uo-link.db"), + true, + ); + assert!(steps.contains("sidecar.toml"), "{steps}"); + #[cfg(windows)] + { + assert!(steps.contains("sc.exe create"), "{steps}"); + assert!(steps.contains("NT SERVICE\\RunicGatewayLink"), "{steps}"); + // The binPath value carries its own double quotes, so the argument around it must be + // single-quoted or PowerShell strips them and the SCM gets an unquoted path. + assert!(steps.contains("binPath= '\""), "{steps}"); + assert!(!steps.contains("binPath= \"\""), "{steps}"); + // The write grant belongs to the database directory, which is not always the config's. + assert!(steps.contains("/var/lib/runicgateway'"), "{steps}"); + } + } + + #[test] + fn the_recipe_never_claims_a_protection_the_run_did_not_apply() { + // A relocated run leaves the token file's permissions alone. Telling the operator it is + // already locked down would be the one sentence here that could cost them the token. + let args = ( + Path::new("/usr/bin/runicgateway-link"), + Path::new("/etc/runicgateway/sidecar.toml"), + Path::new("/var/lib/runicgateway/uo-link.db"), + ); + let protected = manual_steps(args.0, args.1, args.2, true); + let unprotected = manual_steps(args.0, args.1, args.2, false); + #[cfg(windows)] + { + assert_ne!(protected, unprotected); + assert!(protected.contains("already been restricted"), "{protected}"); + assert!(unprotected.contains("did NOT restrict"), "{unprotected}"); + assert!(unprotected.contains("/inheritance:r"), "{unprotected}"); + } + // On Linux the recipe is the same either way, and correct either way: `protect_config` + // restricts the mode but cannot hand the file to a user that does not exist yet, so the + // chown lines are needed regardless. + #[cfg(not(windows))] + { + assert_eq!(protected, unprotected); + assert!(protected.contains("chown runicgateway"), "{protected}"); + } + #[cfg(not(windows))] + { + assert!(steps.contains("systemctl enable --now"), "{steps}"); + assert!( + steps.contains("ExecStart=/usr/bin/runicgateway-link"), + "{steps}" + ); + // Non-systemd hosts get the requirements, not just a unit they cannot use. + assert!(steps.contains("without systemd"), "{steps}"); + } + } + + #[test] + fn a_skipped_registration_still_hands_over_the_recipe() { + let layout = crate::paths::Layout { + state_dir: PathBuf::from("/etc/runicgateway"), + data_dir: PathBuf::from("/var/lib/runicgateway"), + sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), + relocated: true, + }; + let outcome = register(&prepare(true), &layout, false).unwrap(); + match outcome { + Outcome::Skipped { reason, manual } => { + assert!(reason.contains("test run"), "{reason}"); + assert!(!manual.trim().is_empty()); + } + other => panic!("expected a skip, got {other:?}"), + } + } +} diff --git a/src/sidecar.rs b/src/sidecar.rs new file mode 100644 index 0000000..61b5831 --- /dev/null +++ b/src/sidecar.rs @@ -0,0 +1,465 @@ +//! The uo-link sidecar: install the binary, provision its config, read the token back. +//! +//! This is the half of the deployment that makes the website work at all. The overlay puts code in +//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for +//! the shard to dial out to, and until the website has been given its address, protocol version and +//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened" +//! failure mode). +//! +//! Three rules govern this module: +//! +//! - **Every value in the handoff comes from asking the installed binary**, via +//! `--print-config --config `. Not from the log, not from re-reading the TOML, +//! and not from the installer's own idea of what it wrote. That single call also *provisions* — +//! it writes the config file if absent and generates the token if blank — which is why PLAN.md +//! §5 requires it to run **before** the service is registered: the service must never start +//! against a config that does not exist yet. +//! - **The token is printed and never stored.** It goes to the operator's terminal and into +//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not +//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through +//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a +//! command printed. +//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is +//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s +//! question (Phase 4). + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; + +use crate::bundle::Asset; +use crate::util::{run, sha256_file}; + +/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`). +/// +/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an +/// installer that does not know about it, and every field read here has been in the document since +/// the CLI was introduced in link v1.1.0. +#[derive(Debug, Clone, Deserialize)] +pub struct ConfigDoc { + pub component: String, + pub version: String, + pub protocol: u32, + pub config_path: String, + /// This run created the config file. False on every re-run — which is how the installer knows + /// not to report a token as newly minted when it is simply being read back. + pub config_created: bool, + pub token_generated: bool, + pub shard: ShardDoc, + pub web: WebDoc, + pub store: StoreDoc, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ShardDoc { + pub bind: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebDoc { + pub bind: String, + pub ws_path: String, + pub auth_required: bool, + /// **A secret.** Printed in the handoff block and never recorded anywhere else. + pub auth_token: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StoreDoc { + /// Absolute, already resolved by the sidecar against its config file's directory. + pub path: String, +} + +/// What installing the binary would do, decided by hash before anything is downloaded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryAction { + /// Nothing is installed at the target path yet. + Install, + /// Something is, and it is not what the bundle names. + Replace, + /// The bundle's binary is already in place, byte for byte. + Unchanged, +} + +impl BinaryAction { + pub fn writes(self) -> bool { + !matches!(self, Self::Unchanged) + } + + pub fn label(self) -> &'static str { + match self { + Self::Install => "install", + Self::Replace => "replace", + Self::Unchanged => "unchanged", + } + } +} + +/// Compares what is installed against what the bundle names. +/// +/// Hash rather than version string: the version an installed binary reports costs a process launch +/// to obtain and would still not distinguish two builds of the same version. The bundle records the +/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is +/// against the same value the download will be checked against. +pub fn decide(asset: &Asset, dest: &Path) -> Result { + if !dest.exists() { + return Ok(BinaryAction::Install); + } + let installed = sha256_file(dest)?; + if installed.eq_ignore_ascii_case(asset.sha256.trim()) { + Ok(BinaryAction::Unchanged) + } else { + Ok(BinaryAction::Replace) + } +} + +/// Downloads the sidecar binary and puts it at `dest`, executable. +/// +/// The download lands in the scratch directory and is checksum-verified there, then copied to a +/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for +/// different reasons: on Windows the target is locked while the service runs (the caller stops it +/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at +/// exactly the path a service is about to execute. +pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result { + let staged = scratch.join(&asset.name); + crate::net::download_verified(&asset.url, &staged, &asset.sha256)?; + + let parent = dest + .parent() + .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?; + fs::create_dir_all(parent).with_context(|| { + format!( + "cannot create {} — run as root/Administrator", + parent.display() + ) + })?; + + let pending = pending_path(dest); + fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?; + set_executable(&pending)?; + + // Windows will not rename onto an existing file. The old binary goes first; the replacement is + // already complete on disk by this point, so the window is a rename wide. + if dest.exists() { + fs::remove_file(dest).with_context(|| { + format!( + "cannot replace {} — if a service is running it, stop it first", + dest.display() + ) + })?; + } + fs::rename(&pending, dest) + .with_context(|| format!("cannot move {} into place", pending.display()))?; + + sha256_file(dest) +} + +/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target. +/// +/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and +/// `/usr/bin` are routinely different mounts, and a rename between them fails. +fn pending_path(dest: &Path) -> PathBuf { + let mut name = dest.file_name().unwrap_or_default().to_os_string(); + name.push(".new"); + dest.with_file_name(name) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("cannot make {} executable", path.display())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +/// Runs the installed binary's `--print-config`, provisioning the config and returning the token. +/// +/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would +/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`. +/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path` +/// to its config's directory, and passing the variable would buy nothing while implying the service +/// needs a machine-wide environment variable it does not (see [`crate::paths`]). +/// +/// **This function must not print, log or attach the child's stdout to an error.** It is the one +/// place in the installer where a secret crosses a process boundary. +pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result { + let mut command = std::process::Command::new(binary); + command.arg("--print-config").arg("--config").arg(config); + if let Some(db) = db_path { + command.env("UOLINK_DB_PATH", db); + } + + let output = command.output().with_context(|| { + format!( + "cannot run {} --print-config. The binary was just installed, so this usually means it \ + cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.", + binary.display() + ) + })?; + + if !output.status.success() { + // stderr only. stdout is the document, and the document contains the auth token. + let reason = String::from_utf8_lossy(&output.stderr) + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("(nothing on stderr)") + .to_string(); + bail!( + "{} --print-config --config {} failed with {}: {reason}", + binary.display(), + config.display(), + match output.status.code() { + Some(code) => format!("exit code {code}"), + None => "no exit code".to_string(), + } + ); + } + + let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context( + "the sidecar's --print-config output is not the document this installer expects. \ + Its contents are not shown here because they would contain the auth token; run the same \ + command by hand to see it (INSTALL.md Appendix A3).", + )?; + + if doc.component != "uo-link-sidecar" { + bail!( + "the binary at {} identifies itself as {:?}, not uo-link-sidecar", + binary.display(), + doc.component + ); + } + if doc.web.auth_token.trim().is_empty() { + // Authentication is always on in the sidecar, so this cannot happen against a real one — + // and if it ever did, an unauthenticated web surface must not be reported as a success. + bail!( + "the sidecar reported an empty auth token from {}. Authentication is always on; refusing \ + to continue with a config that would leave its web surface unauthenticated.", + doc.config_path + ); + } + Ok(doc) +} + +/// Asks an installed binary for its version line, `uo-link-sidecar (protocol )`. +/// +/// Used to report what is already installed on a run that installs nothing. It is deliberately +/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose +/// real work has already succeeded. +pub fn version_line(binary: &Path) -> Option { + let output = run(&binary.to_string_lossy(), &["--version"]).ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) +} + +/// The two URLs the website needs, composed from the sidecar's own answers plus a host. +/// +/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently +/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken +/// from it; the host is the one the operator named or the installer detected. +pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) { + let port = port_of(&doc.web.bind); + let ws_path = if doc.web.ws_path.starts_with('/') { + doc.web.ws_path.clone() + } else { + format!("/{}", doc.web.ws_path) + }; + ( + format!("http://{host}:{port}"), + format!("ws://{host}:{port}{ws_path}"), + ) +} + +/// The port half of a bind address. +/// +/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment +/// of the address. A bind with no port at all is not something the sidecar produces, so the whole +/// string is handed back rather than guessing a default that would then be wrong everywhere it was +/// printed. +fn port_of(bind: &str) -> &str { + match bind.rsplit_once(':') { + Some((_, port)) if !port.is_empty() => port, + _ => bind, + } +} + +/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do. +/// +/// Returned as a string rather than printed so it can be tested, and so the caller decides where it +/// goes. It goes to stdout. It never goes to a file. +pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String { + let (base_url, ws_url) = website_urls(doc, host); + let site = site_url + .map(|s| s.trim_end_matches('/').to_string()) + .unwrap_or_else(|| "https://".to_string()); + + format!( + "\nRunic Gateway is installed.\n\n\ + One manual step remains — connect the website to this sidecar:\n\n \ + Base URL {base_url}\n \ + WebSocket URL {ws_url}\n \ + Protocol version {protocol}\n \ + Auth token {token}\n \ + (also in {config})\n\n\ + Paste these into Admin → Shard on your Runic Gateway site:\n \ + {site}/admin/shard\n\n\ + The token is write-only once saved — the site will never show it back to you.\n", + protocol = doc.protocol, + token = doc.web.auth_token, + config = doc.config_path, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + /// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3. + const PRINT_CONFIG: &str = r#"{ + "component": "uo-link-sidecar", + "version": "1.1.0", + "protocol": 3, + "config_path": "/etc/runicgateway/sidecar.toml", + "config_created": true, + "token_generated": true, + "shard": { "bind": "127.0.0.1:7788" }, + "web": { + "bind": "127.0.0.1:8080", + "ws_path": "/ws", + "auth_required": true, + "auth_token": "4f9c00112233445566778899aabbccdd" + }, + "store": { "path": "/var/lib/runicgateway/uo-link.db" } + }"#; + + fn doc() -> ConfigDoc { + serde_json::from_str(PRINT_CONFIG).unwrap() + } + + #[test] + fn the_documented_print_config_output_parses() { + let doc = doc(); + assert_eq!(doc.version, "1.1.0"); + assert_eq!(doc.protocol, 3); + assert!(doc.web.auth_required); + assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db"); + } + + #[test] + fn a_newer_sidecar_adding_fields_still_parses() { + // The sidecar and the installer version independently; a key added to the document must not + // strand an installed installer. + let body = PRINT_CONFIG.replace( + "\"protocol\": 3,", + "\"protocol\": 3, \"something_new\": { \"nested\": true },", + ); + assert!(serde_json::from_str::(&body).is_ok()); + } + + #[test] + fn the_website_urls_use_the_host_not_the_bind_address() { + // The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website. + let mut d = doc(); + let (base, ws) = website_urls(&d, "shard.example.com"); + assert_eq!(base, "http://shard.example.com:8080"); + assert_eq!(ws, "ws://shard.example.com:8080/ws"); + + d.web.bind = "0.0.0.0:9001".into(); + let (base, ws) = website_urls(&d, "shard.example.com"); + assert_eq!(base, "http://shard.example.com:9001"); + assert_eq!(ws, "ws://shard.example.com:9001/ws"); + } + + #[test] + fn an_ipv6_bind_yields_its_port() { + // Splitting on the first colon would produce "http://host::" from "[::]:8080". + assert_eq!(port_of("[::]:8080"), "8080"); + assert_eq!(port_of("[::1]:7788"), "7788"); + assert_eq!(port_of("127.0.0.1:8080"), "8080"); + } + + #[test] + fn the_handoff_carries_every_value_the_admin_form_asks_for() { + // INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them. + let doc = doc(); + let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/")); + assert!(block.contains("http://shard.example.com:8080"), "{block}"); + assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}"); + assert!(block.contains("Protocol version 3"), "{block}"); + assert!(block.contains(&doc.web.auth_token), "{block}"); + // The trailing slash on the site URL must not produce a double slash in the link. + assert!( + block.contains("https://my-site.example/admin/shard"), + "{block}" + ); + assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}"); + } + + #[test] + fn the_handoff_still_works_without_a_site_url() { + // An unattended run has nobody to ask, and the token is far too useful to withhold over a + // link the operator does not need. + let block = handoff(&doc(), "shard", None); + assert!(block.contains("https:///admin/shard"), "{block}"); + assert!(block.contains("4f9c"), "{block}"); + } + + #[test] + fn an_installed_binary_matching_the_bundle_is_left_alone() { + let dir = TempDir::new("rg-test-sidecar").unwrap(); + let dest = dir.path().join("uo-link-sidecar"); + assert_eq!( + decide(&asset("0".repeat(64)), &dest).unwrap(), + BinaryAction::Install + ); + + fs::write(&dest, b"pretend binary").unwrap(); + let installed = sha256_file(&dest).unwrap(); + assert_eq!( + decide(&asset(installed.clone()), &dest).unwrap(), + BinaryAction::Unchanged + ); + // Hex case must not decide whether a host reinstalls its sidecar on every run. + assert_eq!( + decide(&asset(installed.to_uppercase()), &dest).unwrap(), + BinaryAction::Unchanged + ); + assert_eq!( + decide(&asset("a".repeat(64)), &dest).unwrap(), + BinaryAction::Replace + ); + } + + #[test] + fn the_staging_file_sits_beside_its_target() { + // /tmp and /usr/bin are routinely different filesystems, and rename across them fails. + let dest = Path::new("/usr/bin/runicgateway-link"); + assert_eq!(pending_path(dest).parent(), dest.parent()); + assert_eq!( + pending_path(Path::new("/usr/bin/x.exe")) + .file_name() + .unwrap(), + "x.exe.new" + ); + } + + fn asset(sha256: String) -> Asset { + Asset { + name: "uo-link-sidecar-linux-x86_64".into(), + url: "https://example/uo-link-sidecar".into(), + sha256, + } + } +} diff --git a/src/util.rs b/src/util.rs index 8fde480..f0e994d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,11 +1,13 @@ -//! Hashing and scratch-directory helpers. +//! Hashing, scratch directories, and running other programs. +use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; /// Lower-case hex, written out rather than taken from a crate. @@ -146,6 +148,78 @@ pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> { Ok(()) } +/// How a command is written back to the operator when it fails. +/// +/// Reproducible by hand is the whole point: every external command this tool runs — `systemctl`, +/// `useradd`, `sc.exe` — is one an operator can run themselves, and a failure they can retype is a +/// failure they can diagnose. +pub fn command_line>(program: &str, args: &[S]) -> String { + let mut line = String::from(program); + for arg in args { + let text = arg.as_ref().to_string_lossy().into_owned(); + line.push(' '); + if text.contains(' ') && !text.starts_with('"') { + line.push('"'); + line.push_str(&text); + line.push('"'); + } else { + line.push_str(&text); + } + } + line +} + +/// Runs a program to completion, capturing its output. A non-zero exit is **not** an error here — +/// several callers ask questions whose answer *is* the exit code (`id -u`, `sc query`). +pub fn run>(program: &str, args: &[S]) -> Result { + Command::new(program).args(args).output().with_context(|| { + format!( + "cannot run `{}` — is it installed and on PATH?", + command_line(program, args) + ) + }) +} + +/// Runs a program and treats a non-zero exit as a failure, quoting what it printed. +/// +/// **Never call this on anything that emits a secret.** The sidecar's `--print-config` writes the +/// auth token to stdout, so it is run through [`run`] and handled where the token can be kept out +/// of the error path (PLAN.md §6). +pub fn run_ok>(program: &str, args: &[S]) -> Result { + let output = run(program, args)?; + if !output.status.success() { + bail!(failure_message( + &command_line(program, args), + output.status.code(), + &output.stderr, + &output.stdout, + )); + } + Ok(output) +} + +/// The message a failed command produces. Split out from [`run_ok`] because it is the part worth +/// testing — spawning a process that fails identically on Linux and Windows is not. +fn failure_message(line: &str, code: Option, stderr: &[u8], stdout: &[u8]) -> String { + let detail = first_useful_line(stderr) + .or_else(|| first_useful_line(stdout)) + .unwrap_or_else(|| "(no output)".to_string()); + let status = match code { + Some(code) => format!("exit code {code}"), + None => "no exit code (killed by a signal)".to_string(), + }; + format!("`{line}` failed with {status}: {detail}") +} + +/// The first non-blank line of a captured stream, for a one-line error message. +fn first_useful_line(bytes: &[u8]) -> Option { + String::from_utf8_lossy(bytes) + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(str::to_string) +} + #[cfg(test)] mod tests { use super::*; @@ -193,6 +267,52 @@ mod tests { assert!(!path.exists()); } + #[test] + fn a_failed_command_is_reported_with_what_it_printed() { + // Both halves matter: the command to retype, and the reason it failed. stderr wins over + // stdout because that is where systemctl and sc.exe put the reason. + let message = failure_message( + "systemctl enable --now runicgateway-link.service", + Some(1), + b"Failed to enable unit: Unit file does not exist.\n", + b"noise\n", + ); + assert!(message.contains("systemctl enable"), "{message}"); + assert!(message.contains("exit code 1"), "{message}"); + assert!(message.contains("Unit file does not exist."), "{message}"); + + // A command that fails silently must still say something usable. + let quiet = failure_message("sc.exe start RunicGatewayLink", Some(1053), b"", b""); + assert!( + quiet.contains("1053") && quiet.contains("(no output)"), + "{quiet}" + ); + } + + #[test] + fn a_missing_program_says_so_rather_than_panicking() { + let err = run("rg-no-such-program-exists", &["x"]) + .unwrap_err() + .to_string(); + assert!(err.contains("rg-no-such-program-exists"), "{err}"); + } + + #[test] + fn command_lines_quote_arguments_containing_spaces() { + // These strings are printed for an operator to paste back; an unquoted Windows path with + // spaces in it would be a command that does not work when they do. + let line = command_line( + "sc.exe", + &[ + "create", + "RunicGatewayLink", + "binPath=", + "C:\\Program Files\\x.exe", + ], + ); + assert!(line.contains("\"C:\\Program Files\\x.exe\""), "{line}"); + } + #[test] fn atomic_write_replaces_an_existing_file() { let dir = TempDir::new("rg-test-atomic").unwrap(); From 7bfb0339577d075e6b6b77a797ca42399168712e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 15:52:02 -0500 Subject: [PATCH 03/13] fix(installer): make the Linux half of Phase 2 compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults in code that only compiles under cfg(unix), none of which the Windows build could see: - `run(...).map(...) == Ok(true)` compared two `Result<_, anyhow::Error>` values, and anyhow::Error is not PartialEq. Replaced with `is_ok_and`. - `command_line` is used only by the Windows registration path, so importing it unconditionally is an unused-import error under `-D warnings`. Qualified at its call site instead. - A cfg(not(windows)) assertion block had ended up in the wrong test, leaving it referencing a binding from its original one. Caught by running the same gates the CI runner does inside a rust:1-slim container against this working tree — fmt, clippy --all-targets -D warnings, and cargo test --locked all pass there now, as they do on Windows. Co-Authored-By: Claude --- src/service.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/service.rs b/src/service.rs index 0b31e8b..3a2a81d 100644 --- a/src/service.rs +++ b/src/service.rs @@ -31,7 +31,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use crate::util::{command_line, run, run_ok}; +// `command_line` is used only by the Windows registration path, so it is qualified at its call +// site rather than imported here — an unconditional import is an unused-import error on Linux. +use crate::util::{run, run_ok}; /// The unit file name, and the systemd service name with its suffix. pub const SYSTEMD_UNIT: &str = "runicgateway-link.service"; @@ -149,7 +151,7 @@ fn prepare_platform() -> Prepared { /// Creates the dedicated system user if it is not already there. Returns whether it created it. #[cfg(unix)] fn ensure_user(user: &str) -> Result { - if run("id", &["-u", user]).map(|o| o.status.success()) == Ok(true) { + if run("id", &["-u", user]).is_ok_and(|o| o.status.success()) { return Ok(false); } // `useradd` is the near-universal spelling; `adduser` is the fallback for Debian's wrapper and @@ -373,7 +375,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result Date: Tue, 4 Aug 2026 19:54:36 -0500 Subject: [PATCH 04/13] =?UTF-8?q?feat(installer):=20implement=20Phase=203?= =?UTF-8?q?=20=E2=80=94=20the=20patch=20tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude --- Cargo.lock | 12 + Cargo.toml | 7 + src/cli.rs | 16 +- src/diff.rs | 591 ++++++++++++ src/install.rs | 144 +-- src/lib.rs | 9 +- src/overlay.rs | 7 + src/patch.rs | 890 +++++++++++++++++ src/paths.rs | 21 + src/record.rs | 21 +- src/tier.rs | 894 ++++++++++++++++++ src/util.rs | 46 +- tests/fixtures/commandlogging-event.patch | 33 + tests/fixtures/patch_tier.json | 47 + .../playervendor-sale-eventsink.patch | 66 ++ tests/fixtures/playervendor-sale-gump.patch | 15 + tests/real_patches.rs | 317 +++++++ 17 files changed, 3064 insertions(+), 72 deletions(-) create mode 100644 src/diff.rs create mode 100644 src/patch.rs create mode 100644 src/tier.rs create mode 100644 tests/fixtures/commandlogging-event.patch create mode 100644 tests/fixtures/patch_tier.json create mode 100644 tests/fixtures/playervendor-sale-eventsink.patch create mode 100644 tests/fixtures/playervendor-sale-gump.patch create mode 100644 tests/real_patches.rs diff --git a/Cargo.lock b/Cargo.lock index d6b5329..0b7b0d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,7 @@ dependencies = [ "flate2", "serde", "serde_json", + "sha1", "sha2", "sysinfo", "tar", @@ -509,6 +510,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 25a186a..183bbe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,13 @@ tar = "0.4" # (PLAN.md §3), which makes this load-bearing rather than a nicety. sha2 = "0.11" +# SHA1 is here for one reason only: a patch's `index ..` line carries +# git blob hashes, and reproducing one is how the patch tier answers rung 1 — +# "is this whole file still stock?" (PLAN.md §2.2.1). It is never used as a +# security primitive. Computing it natively is what keeps `git` off the shard +# host, which is the whole point of shipping the plugin as a release tarball. +sha1 = "0.11" + serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/cli.rs b/src/cli.rs index 29121b1..d88ff08 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,20 +2,20 @@ //! //! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and //! fixes every command and flag an operator can type. This module parses that surface *whole*, even -//! though Phase 1 implements only part of it — a parser written once against the published contract -//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this -//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it -//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO -//! files were touched when nothing was. +//! where a later phase implements it — a parser written once against the published contract cannot +//! drift from it, and a flag belonging to an unbuilt phase gets an explicit notice at the point +//! where it would have taken effect (see `install.rs`). The tri-state on `--patches` is the part +//! that carries weight: "not mentioned" has to stay distinguishable from "explicitly declined", +//! because only the first may prompt and only an explicit yes may edit a stock ServUO file. //! //! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand //! trees. A parsing crate would be larger than the code it replaced. use std::fmt; -/// The verb. `Install` is the only one Phase 1 implements; the rest parse so that running them -/// reports which phase they arrive in rather than "unrecognized argument", which would read as a -/// typo rather than as an unfinished tool. +/// The verb. `Install` is the only one built so far; the rest parse so that running them reports +/// which phase they arrive in rather than "unrecognized argument", which would read as a typo +/// rather than as an unfinished tool. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Command { Install, diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..935b1bc --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,591 @@ +//! Unified-diff parsing. +//! +//! The patch tier resolves a file through the rung ladder of `docs/installer/PLAN.md` §2.2.1, and +//! every rung is answered from the diff itself: a unified diff already carries the stock text of +//! each region it edits — the context lines plus the `-` lines **are** the pre-image, and the +//! context lines plus the `+` lines are the post-image. Nothing else has to be shipped alongside +//! the patch for the installer to know what it is looking for. +//! +//! Three properties of this parser are load-bearing rather than tidiness: +//! +//! - **Everything is bytes, never `String`.** The three ServUO files the tier edits are CRLF and +//! are not guaranteed to be UTF-8; a lossy decode would corrupt bytes on write-back, and a strict +//! one would refuse to patch a shard over a stray `0x92` in a comment. Line content is compared +//! after normalization but written back verbatim. +//! - **The `index` line is optional and its absence is not a defect.** `commandlogging-event.patch` +//! is a plain `---`/`+++` diff with no `diff --git` header at all, so rung 1 (whole-file +//! pre-image hash) is structurally unavailable for it. That is fine: rung 2 matches on content +//! and is the stronger guarantee anyway. A parser that required the header would have rejected a +//! patch we ship. +//! - **Hunk line counts are checked against the lines actually present.** A truncated or +//! hand-edited diff whose `@@` header promises more lines than it carries would otherwise +//! reconstruct a short pre-image, which is exactly the sort of thing that then matches somewhere +//! it should not. + +use anyhow::{bail, Context, Result}; + +/// One line inside a hunk body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HunkLine { + /// ` ` — present on both sides. + Context(Vec), + /// `-` — present in the stock file only. + Removed(Vec), + /// `+` — present in the patched file only. + Added(Vec), +} + +impl HunkLine { + fn content(&self) -> &[u8] { + match self { + Self::Context(b) | Self::Removed(b) | Self::Added(b) => b, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hunk { + /// 1-based line number in the stock file, from the `@@` header. **Advisory only** — the match + /// is made by content, since an insertion anywhere above shifts every number below it. It is + /// used to prefer the nearest candidate when reporting, and nowhere else. + pub old_start: usize, + pub old_count: usize, + pub new_start: usize, + pub new_count: usize, + pub lines: Vec, + /// The stock file's last line has no trailing newline (`\ No newline at end of file` after a + /// `-` or context line). + pub old_no_newline: bool, + /// Likewise for the patched file. + pub new_no_newline: bool, +} + +impl Hunk { + /// The stock text this hunk expects to find: context + removed, in order. + pub fn pre_image(&self) -> Vec<&[u8]> { + self.lines + .iter() + .filter(|l| !matches!(l, HunkLine::Added(_))) + .map(HunkLine::content) + .collect() + } + + /// The text this hunk leaves behind: context + added, in order. + pub fn post_image(&self) -> Vec<&[u8]> { + self.lines + .iter() + .filter(|l| !matches!(l, HunkLine::Removed(_))) + .map(HunkLine::content) + .collect() + } + + /// Whether this hunk changes anything. A hunk of pure context is a no-op and must not be + /// counted as applied work — nor searched for, since its pre- and post-images are identical + /// and rung 0 could never be distinguished from rung 2. + pub fn is_noop(&self) -> bool { + !self + .lines + .iter() + .any(|l| matches!(l, HunkLine::Added(_) | HunkLine::Removed(_))) + } +} + +/// One file section of a patch. Our patches carry one each, but a diff may hold several and +/// silently applying the first would be a quiet way to half-patch a tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilePatch { + /// From `+++ b/`, with the `b/` prefix stripped and separators left as `/`. + pub path: String, + /// The abbreviated blob hash of the stock file, from `index ..`. `None` when the + /// diff has no `index` line, which makes rung 1 unavailable for this file — see the module + /// docs. + pub pre_blob: Option, + pub post_blob: Option, + pub hunks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Patch { + pub files: Vec, +} + +impl Patch { + /// The single file this patch edits. + /// + /// The tier declares one target per patch (`tier.json`), so a diff that turned out to edit two + /// files would mean the declaration and the artifact disagree — and the installer would have + /// checked only one of them against the rung ladder before writing both. + pub fn single_file(&self) -> Result<&FilePatch> { + match self.files.as_slice() { + [only] => Ok(only), + [] => bail!("this patch contains no file sections"), + many => bail!( + "this patch edits {} files ({}); the tier declares one target per patch", + many.len(), + many.iter() + .map(|f| f.path.as_str()) + .collect::>() + .join(", ") + ), + } + } +} + +/// Splits a buffer into lines **without** their terminators, tolerating CRLF, LF and a final line +/// with no terminator at all. +/// +/// A trailing newline does not produce a final empty line: `b"a\n"` is one line, matching how every +/// diff tool counts them. +pub fn split_lines(data: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut start = 0usize; + for (i, b) in data.iter().enumerate() { + if *b == b'\n' { + let mut end = i; + if end > start && data[end - 1] == b'\r' { + end -= 1; + } + out.push(&data[start..end]); + start = i + 1; + } + } + if start < data.len() { + let mut end = data.len(); + if end > start && data[end - 1] == b'\r' { + end -= 1; + } + out.push(&data[start..end]); + } + out +} + +/// The comparison form of a line: trailing whitespace removed. +/// +/// This is the *whole* of the licence PLAN.md §2.2.1 grants — line-ending and trailing-whitespace +/// normalization, nothing else. There is no fuzz and no context reduction: dropping context to +/// force a match is precisely how a hunk lands in the wrong method. Line endings are already gone +/// by the time this runs, since [`split_lines`] strips them. +pub fn normalize(line: &[u8]) -> &[u8] { + let mut end = line.len(); + while end > 0 && (line[end - 1] == b' ' || line[end - 1] == b'\t' || line[end - 1] == b'\r') { + end -= 1; + } + &line[..end] +} + +/// Parses a `.patch` file. +pub fn parse(data: &[u8]) -> Result { + let lines = split_lines(data); + let mut files: Vec = Vec::new(); + let mut pending_blobs: Option<(String, String)> = None; + let mut i = 0usize; + + while i < lines.len() { + let line = lines[i]; + + if line.starts_with(b"index ") { + // `index ..[ ]`. Abbreviated to 7+ hex characters by git, so rung 1 + // compares by prefix rather than for equality. + pending_blobs = parse_index(line); + i += 1; + continue; + } + + if line.starts_with(b"--- ") && i + 1 < lines.len() && lines[i + 1].starts_with(b"+++ ") { + let path = header_path(lines[i + 1], b"+++ ") + .or_else(|| header_path(lines[i], b"--- ")) + .with_context(|| { + format!( + "cannot read the target path from the diff header at line {}", + i + 2 + ) + })?; + let (pre_blob, post_blob) = match pending_blobs.take() { + Some((a, b)) => (Some(a), Some(b)), + None => (None, None), + }; + i += 2; + + let mut hunks = Vec::new(); + while i < lines.len() && lines[i].starts_with(b"@@") { + let (hunk, next) = parse_hunk(&lines, i)?; + hunks.push(hunk); + i = next; + } + if hunks.is_empty() { + bail!("the diff section for {path} contains no hunks"); + } + files.push(FilePatch { + path, + pre_blob, + post_blob, + hunks, + }); + continue; + } + + // `diff --git`, `new file mode`, `similarity index`, a covering-letter preamble — anything + // outside a hunk body is skipped. Only `index` is worth keeping. + i += 1; + } + + if files.is_empty() { + bail!("no unified-diff sections found — this file is not a patch"); + } + Ok(Patch { files }) +} + +/// `--- a/Scripts/Commands/Logging.cs` → `Scripts/Commands/Logging.cs`. +/// +/// The trailing tab-separated timestamp some tools append is dropped, and so is the one-letter +/// prefix directory git uses. `/dev/null` yields `None`, which makes a pure-creation diff fall back +/// to the other header rather than producing a file called `dev/null`. +fn header_path(line: &[u8], marker: &[u8]) -> Option { + let rest = line.strip_prefix(marker)?; + let rest = match rest.iter().position(|b| *b == b'\t') { + Some(tab) => &rest[..tab], + None => rest, + }; + let text = String::from_utf8_lossy(rest).trim().replace('\\', "/"); + if text.is_empty() || text == "/dev/null" { + return None; + } + // git writes `a/` and `b/`; `-p1` semantics. A path with no prefix (`patch -p0` style) is left + // alone rather than having its first directory eaten. + for prefix in ["a/", "b/", "i/", "w/", "c/", "o/"] { + if let Some(stripped) = text.strip_prefix(prefix) { + return Some(stripped.to_string()); + } + } + Some(text) +} + +fn parse_index(line: &[u8]) -> Option<(String, String)> { + let rest = String::from_utf8_lossy(line.strip_prefix(b"index ")?).to_string(); + let head = rest.split_whitespace().next()?; + let (old, new) = head.split_once("..")?; + let hex = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit()); + if !hex(old) || !hex(new) { + return None; + } + Some((old.to_ascii_lowercase(), new.to_ascii_lowercase())) +} + +/// Parses one hunk, starting at the `@@` header. Returns the hunk and the index of the line after +/// it. +fn parse_hunk(lines: &[&[u8]], start: usize) -> Result<(Hunk, usize)> { + let header = String::from_utf8_lossy(lines[start]).to_string(); + let (old_start, old_count, new_start, new_count) = parse_hunk_header(&header) + .with_context(|| format!("cannot parse hunk header at line {}: {header}", start + 1))?; + + let mut body = Vec::new(); + let mut old_no_newline = false; + let mut new_no_newline = false; + let mut seen_old = 0usize; + let mut seen_new = 0usize; + let mut i = start + 1; + + while i < lines.len() { + let line = lines[i]; + // The marker describes the line *above* it, and which side it applies to depends on that + // line's kind: a `-` line means the stock file ended there, a `+` line the patched one, and + // a context line both. + if line.starts_with(b"\\ ") { + match body.last() { + Some(HunkLine::Removed(_)) => old_no_newline = true, + Some(HunkLine::Added(_)) => new_no_newline = true, + Some(HunkLine::Context(_)) => { + old_no_newline = true; + new_no_newline = true; + } + None => {} + } + i += 1; + continue; + } + if seen_old >= old_count && seen_new >= new_count { + break; + } + + let (kind, rest) = match line.first() { + Some(b' ') => (0u8, &line[1..]), + Some(b'-') => (1, &line[1..]), + Some(b'+') => (2, &line[1..]), + // git emits a genuinely empty line for an empty context line rather than a lone space, + // and trailing whitespace is routinely stripped in transit. Treating it as context is + // what every patch tool does. + None => (0, line), + // Anything else ends the hunk — the next `@@`, `diff --git`, or trailing prose. + Some(_) => break, + }; + + match kind { + 0 => { + seen_old += 1; + seen_new += 1; + body.push(HunkLine::Context(rest.to_vec())); + } + 1 => { + seen_old += 1; + body.push(HunkLine::Removed(rest.to_vec())); + } + _ => { + seen_new += 1; + body.push(HunkLine::Added(rest.to_vec())); + } + } + i += 1; + } + + // A header that promises more than the body delivers reconstructs a short pre-image, which is + // then liable to match a place the author never meant. Refusing is the only safe reading. + if seen_old != old_count || seen_new != new_count { + bail!( + "hunk at line {} declares -{old_start},{old_count} +{new_start},{new_count} but \ + carries {seen_old} old and {seen_new} new lines — the patch is truncated or malformed", + start + 1 + ); + } + + Ok(( + Hunk { + old_start, + old_count, + new_start, + new_count, + lines: body, + old_no_newline, + new_no_newline, + }, + i, + )) +} + +/// `@@ -75,16 +75,27 @@ optional section heading` → `(75, 16, 75, 27)`. +/// +/// A count may be omitted, which means 1 (`@@ -75 +75,2 @@`), and a count of 0 is legal for a pure +/// insertion or deletion. +fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> { + let inner = header.strip_prefix("@@")?; + let end = inner.find("@@")?; + let mut parts = inner[..end].split_whitespace(); + let old = parts.next()?.strip_prefix('-')?; + let new = parts.next()?.strip_prefix('+')?; + + let range = |s: &str| -> Option<(usize, usize)> { + match s.split_once(',') { + Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)), + None => Some((s.parse().ok()?, 1)), + } + }; + let (old_start, old_count) = range(old)?; + let (new_start, new_count) = range(new)?; + Some((old_start, old_count, new_start, new_count)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(text: &str) -> Result { + parse(text.as_bytes()) + } + + const SIMPLE: &str = "\ +diff --git a/Scripts/Commands/Logging.cs b/Scripts/Commands/Logging.cs +index 5dd3f54..9ab1c22 100644 +--- a/Scripts/Commands/Logging.cs ++++ b/Scripts/Commands/Logging.cs +@@ -75,4 +75,6 @@ namespace Server.Commands + return o; + } + ++ public static event Action OnWrite; ++ + public static void WriteLine(Mobile from, string text) +"; + + #[test] + fn a_git_format_patch_parses_whole() { + let patch = parse_str(SIMPLE).unwrap(); + let file = patch.single_file().unwrap(); + assert_eq!(file.path, "Scripts/Commands/Logging.cs"); + assert_eq!(file.pre_blob.as_deref(), Some("5dd3f54")); + assert_eq!(file.post_blob.as_deref(), Some("9ab1c22")); + assert_eq!(file.hunks.len(), 1); + + let hunk = &file.hunks[0]; + assert_eq!((hunk.old_start, hunk.old_count), (75, 4)); + assert_eq!((hunk.new_start, hunk.new_count), (75, 6)); + assert_eq!(hunk.pre_image().len(), 4); + assert_eq!(hunk.post_image().len(), 6); + assert!(!hunk.is_noop()); + } + + #[test] + fn a_plain_diff_without_a_git_header_parses_and_offers_no_blob() { + // commandlogging-event.patch is exactly this shape. Rung 1 is unavailable for it, which is + // a fact to report — not a parse error, and certainly not a reason to skip the patch. + let text = SIMPLE + .lines() + .filter(|l| !l.starts_with("diff --git") && !l.starts_with("index ")) + .collect::>() + .join("\n"); + let patch = parse_str(&text).unwrap(); + let file = patch.single_file().unwrap(); + assert_eq!(file.path, "Scripts/Commands/Logging.cs"); + assert_eq!(file.pre_blob, None); + assert_eq!(file.hunks.len(), 1); + } + + #[test] + fn a_crlf_patch_yields_the_same_lines_as_an_lf_one() { + // Every .patch this tier ships is CRLF in a Windows checkout and LF in the tarball CI + // builds. The two must parse identically or a patch would apply on one platform and not + // the other. + let crlf = SIMPLE.replace('\n', "\r\n"); + assert_eq!(parse_str(&crlf).unwrap(), parse_str(SIMPLE).unwrap()); + } + + #[test] + fn pre_and_post_images_are_the_two_sides_of_the_hunk() { + let patch = parse_str( + "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + keep +-old ++new + tail +", + ) + .unwrap(); + let hunk = &patch.single_file().unwrap().hunks[0]; + assert_eq!(hunk.pre_image(), vec![&b"keep"[..], b"old", b"tail"]); + assert_eq!(hunk.post_image(), vec![&b"keep"[..], b"new", b"tail"]); + } + + #[test] + fn the_no_newline_marker_attaches_to_the_side_it_describes() { + // Three cases, because the marker follows the line it describes and the side depends on + // that line's kind. Getting this wrong writes a spurious trailing newline into a file that + // never had one — a one-byte change that shows up in every future hash comparison. + let old_only = parse_str( + "--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n", + ) + .unwrap(); + let hunk = &old_only.single_file().unwrap().hunks[0]; + assert!(hunk.old_no_newline && !hunk.new_no_newline); + + let new_only = parse_str( + "--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n+new\n\\ No newline at end of file\n", + ) + .unwrap(); + let hunk = &new_only.single_file().unwrap().hunks[0]; + assert!(!hunk.old_no_newline && hunk.new_no_newline); + + let both = parse_str( + "--- a/x\n+++ b/x\n@@ -1,1 +1,2 @@\n+added\n same\n\\ No newline at end of file\n", + ) + .unwrap(); + let hunk = &both.single_file().unwrap().hunks[0]; + assert!(hunk.old_no_newline && hunk.new_no_newline); + } + + #[test] + fn several_hunks_and_several_files_are_all_kept() { + let patch = parse_str( + "\ +diff --git a/one b/one +index aaaaaaa..bbbbbbb 100644 +--- a/one ++++ b/one +@@ -1,1 +1,2 @@ + a ++b +@@ -10,1 +11,2 @@ + c ++d +diff --git a/two b/two +--- a/two ++++ b/two +@@ -5,1 +5,2 @@ + e ++f +", + ) + .unwrap(); + assert_eq!(patch.files.len(), 2); + assert_eq!(patch.files[0].hunks.len(), 2); + assert_eq!(patch.files[1].hunks.len(), 1); + // The second file has no index line of its own and must not inherit the first's. + assert_eq!(patch.files[1].pre_blob, None); + + // And a multi-file patch is refused where the tier expects one target, rather than being + // silently half-applied. + let err = patch.single_file().unwrap_err().to_string(); + assert!(err.contains("edits 2 files"), "{err}"); + } + + #[test] + fn an_omitted_count_means_one() { + let patch = parse_str("--- a/x\n+++ b/x\n@@ -7 +7,2 @@\n a\n+b\n").unwrap(); + let hunk = &patch.single_file().unwrap().hunks[0]; + assert_eq!((hunk.old_start, hunk.old_count), (7, 1)); + assert_eq!((hunk.new_start, hunk.new_count), (7, 2)); + } + + #[test] + fn a_truncated_hunk_is_rejected() { + // The header promises four old lines; two are present. Reconstructing the short pre-image + // and hunting for it is how a patch lands somewhere nobody intended. + let err = parse_str("--- a/x\n+++ b/x\n@@ -1,4 +1,4 @@\n a\n b\n") + .unwrap_err() + .to_string(); + assert!(err.contains("truncated or malformed"), "{err}"); + } + + #[test] + fn a_file_that_is_not_a_patch_is_rejected() { + assert!(parse_str("# patches\n\nUnified diffs against stock ServUO.\n").is_err()); + assert!(parse_str("").is_err()); + } + + #[test] + fn line_splitting_agrees_with_how_diffs_count_lines() { + assert_eq!(split_lines(b"a\nb\n"), vec![&b"a"[..], b"b"]); + assert_eq!(split_lines(b"a\r\nb"), vec![&b"a"[..], b"b"]); + assert_eq!(split_lines(b""), Vec::<&[u8]>::new()); + assert_eq!(split_lines(b"\n"), vec![&b""[..]]); + assert_eq!(split_lines(b"a"), vec![&b"a"[..]]); + } + + #[test] + fn normalization_covers_line_endings_and_trailing_space_and_nothing_else() { + assert_eq!(normalize(b"code \t"), b"code"); + assert_eq!(normalize(b"code\r"), b"code"); + // Leading indentation is content: two methods differing only in nesting are different + // places, and collapsing them is how an anchor becomes ambiguous. + assert_ne!(normalize(b" code"), normalize(b"code")); + assert_eq!(normalize(b" "), b""); + } + + #[test] + fn a_pure_context_hunk_is_a_noop() { + let patch = parse_str("--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n b\n").unwrap(); + assert!(patch.single_file().unwrap().hunks[0].is_noop()); + } + + #[test] + fn an_empty_context_line_written_without_its_space_is_still_context() { + // Mailers and editors strip the trailing space off a blank context line routinely, and + // every patch tool tolerates it. Both spellings must produce the same pre-image. + let padded = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n \n b\n+c\n").unwrap(); + let bare = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n\n b\n+c\n").unwrap(); + assert_eq!( + padded.single_file().unwrap().hunks[0].pre_image(), + bare.single_file().unwrap().hunks[0].pre_image() + ); + } +} diff --git a/src/install.rs b/src/install.rs index b2a8d88..2adde12 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,19 +1,17 @@ //! The `install` command. //! -//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync -//! the overlay, install the sidecar and register its service, record what was deployed, and print -//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so -//! in as many words rather than ending on a success line that would read as a finished install. An -//! operator who cannot tell which half ran is the failure this whole tool exists to remove. +//! 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. //! //! 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 sidecar.** The sidecar is what the shard dials out to, but the shard is -//! stopped throughout; deploying code the shard will compile is the step with a running-process -//! hazard attached, so it happens while the check that guards it is freshest. +//! 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. @@ -24,14 +22,14 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use crate::cli::{Cli, PatchChoice}; +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, ui}; +use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; pub fn run(cli: &Cli) -> Result<()> { let layout = paths::layout(); @@ -192,8 +190,21 @@ pub fn run(cli: &Cli) -> Result<()> { )); } - // ── What this build does not do ────────────────────────────────────────── - report_patch_tier(cli); + // ── 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, + &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())?; @@ -201,12 +212,16 @@ pub fn run(cli: &Cli) -> Result<()> { // ── Record ─────────────────────────────────────────────────────────────── let record = build_record( prior.as_ref(), - &bundle, - &bundle_url, - &root, - &manifest, - &planned, - sidecar.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 { @@ -232,7 +247,17 @@ pub fn run(cli: &Cli) -> Result<()> { // ── Closing notes ──────────────────────────────────────────────────────── println!(); - if summary.writes_anything() && !cli.verify { + if cli.verify { + println!("Nothing was written. Re-run without --verify to 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\ @@ -240,8 +265,6 @@ pub fn run(cli: &Cli) -> Result<()> { the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \ run `[bridge status` in game (INSTALL.md §6)." ); - } else if cli.verify { - println!("Nothing was written. Re-run without --verify to deploy."); } else { println!("The ServUO tree already has this overlay — nothing was changed there."); } @@ -306,32 +329,6 @@ fn prior_overlay_files<'a>( prior.overlay_files() } -fn report_patch_tier(cli: &Cli) { - println!(); - match cli.patches { - // --patches must never pass silently: an operator who asked for the tier and got a clean - // run would reasonably conclude that EventSink.cs had been patched. - PatchChoice::Yes => { - ui::warn( - "Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \ - build (Phase 3).\n \ - No stock ServUO file has been touched. Apply the patches by hand if you need \ - them: INSTALL.md Appendix A2.", - ); - } - PatchChoice::No => { - ui::row("Patch tier", "skipped (--no-patches)"); - } - PatchChoice::Ask => { - ui::row( - "Patch tier", - "skipped (not implemented in this build — Phase 3)", - ); - } - } - println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding."); -} - /// The sidecar half of a run: binary, config, service. `None` under `--verify`. struct SidecarOutcome { record: LinkRecord, @@ -568,15 +565,36 @@ fn resolve_host(cli: &Cli) -> String { answer.unwrap_or(detected) } -fn build_record( - prior: Option<&InstallRecord>, - bundle: &bundle::Bundle, - bundle_url: &str, - root: &ServUoRoot, - manifest: &overlay::Manifest, - planned: &[overlay::PlannedFile], - sidecar: Option<&SidecarOutcome>, -) -> InstallRecord { +/// 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 { @@ -608,7 +626,19 @@ fn build_record( Some(outcome) => serde_json::to_value(&outcome.record).ok(), None => prior.and_then(|p| p.link.clone()), }, - patches: prior.map(|p| p.patches.clone()).unwrap_or_default(), + // 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(), } } diff --git a/src/lib.rs b/src/lib.rs index e3e4fcf..35fb1d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,9 @@ //! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary //! existed, is `docs/installer/INSTALL.md`. //! -//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation, -//! the overlay sync, `install.json`, the uo-link sidecar and its service, and the token handoff. -//! The patch tier (Phase 3) and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and +//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation, +//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service, +//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and //! each of them says so when reached rather than failing as though it were a typo. //! //! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same @@ -28,14 +28,17 @@ pub mod bundle; pub mod cli; +pub mod diff; pub mod install; pub mod net; pub mod overlay; +pub mod patch; pub mod paths; pub mod record; pub mod service; pub mod servuo; pub mod sidecar; +pub mod tier; pub mod ui; pub mod util; diff --git a/src/overlay.rs b/src/overlay.rs index 375706a..83b7d0d 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -49,6 +49,12 @@ pub struct Manifest { /// boots — which is why it is checked against the bundle before anything is written. pub protocol: u32, pub servuo: ServUoCompat, + /// The patch tier this release ships, generated from `servuo-plugins/patches/tier.json` + /// (PLAN.md §2.2). `None` for a release that predates the declaration — see + /// [`crate::patch::Tier::resolve`], which substitutes a built-in description rather than + /// leaving the tier silently empty. + #[serde(default)] + pub patch_tier: Option, /// SHA256 per shipped file, keyed `overlay/...` and `patches/...`. pub files: BTreeMap, } @@ -674,6 +680,7 @@ mod tests { min_version: "57.4".into(), patches_verified_against: "57.4".into(), }, + patch_tier: None, files: BTreeMap::from([( cfg_rel.to_string(), sha256_file(&fx.overlay_dir.join(cfg_rel)).unwrap(), diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..a2ffec5 --- /dev/null +++ b/src/patch.rs @@ -0,0 +1,890 @@ +//! The patch tier — resolving and applying diffs against stock ServUO files. +//! +//! Most of the plugin ships as *added* files, which is why the overlay sync is a safe copy. Two +//! features cannot: they need edits to stock ServUO sources, because the events they depend on do +//! not exist (PLAN.md §2.2). This module is the part of the installer that edits a file the +//! operator owns, and it is written to be the most conservative thing in the tool. +//! +//! ## The rung ladder (PLAN.md §2.2.1) +//! +//! A whole-file hash compare answers "is this entire file stock?", which is the wrong question: +//! these patches touch three small regions of three large files, and an operator who added a +//! command to `Logging.cs` has changed its hash without going near the lines the patch edits. +//! Refusing on that would hand most real shards a manual job they did not need. So the decision is +//! made cheapest-and-safest first, and only the last rung gives up: +//! +//! | Rung | Test | Outcome | +//! |---|---|---| +//! | 0 `already-present` | every hunk's *post*-image is in the file | no-op, recorded — keeps re-runs idempotent | +//! | 1 `stock-hash` | the whole file reproduces the diff's `index` pre-image | apply | +//! | 2 `region-match` | every hunk's stock-side region is still byte-identical | apply at the matched offsets | +//! | 3 `region-modified` | anything else | **do not touch the file** — print it for the operator | +//! +//! Rungs 1 and 2 differ only in the *verdict recorded*, never in what is written: both place text +//! by content match, through [`apply`]. A `region-match` apply on a modified file is a different +//! support story from a clean apply to a stock tree, which is why `install.json` keeps them apart. +//! +//! ## The rules that make it safe +//! +//! - **Exact match, not fuzzy.** Only line-ending and trailing-whitespace normalization +//! ([`diff::normalize`]). No `patch --fuzz`, no context reduction — dropping context to force a +//! match is precisely how a hunk lands in the wrong method. +//! - **Exactly one occurrence, or it fails.** Zero means the region moved or was edited; more than +//! one means the anchor is ambiguous and nothing here can know which the author meant. Both are +//! rung 3. +//! - **All-or-nothing per patch file**, and rung 0 likewise: a file where some hunks are present +//! and others are not is a hand-merge in progress, not an idempotent re-run. +//! - **Untouched bytes stay byte-identical.** [`apply`] splices over the matched ranges rather than +//! re-rendering the file from parsed lines, so nothing outside a hunk can be reformatted by +//! accident — and inserted lines take the target file's own dominant line ending, so patching a +//! CRLF file does not leave LF islands in it. +//! +//! ## No `git` +//! +//! PLAN.md §2.2.1 wrote rung 1 as "apply verbatim with `git apply`", but §1 chose the release +//! tarball specifically so there would be **no git on the shard host**, and rung 2 needs a native +//! applier regardless. One engine serves both: rung 1 keeps its distinct, stronger verdict (the +//! whole file reproduced the pre-image hash) while the write goes through the same code path, so +//! there is no second set of CRLF and whitespace behaviours to reason about and a bug report never +//! has to say which engine ran. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::diff::{self, FilePatch, Hunk}; +use crate::util::git_blob_hash; + +/// How a patch was placed. Recorded in `install.json` and reported by `doctor` and `uninstall`, +/// because the three are different support stories. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rung { + /// Rung 0 — the change is already in the file. Nothing was written. + AlreadyPresent, + /// Rung 1 — the whole file was stock. + StockHash, + /// Rung 2 — the file had been modified, but every patched region was still stock. + RegionMatch, +} + +impl Rung { + pub fn as_str(self) -> &'static str { + match self { + Self::AlreadyPresent => "already-present", + Self::StockHash => "stock-hash", + Self::RegionMatch => "region-match", + } + } + + /// The one-line explanation an operator reads next to the patch name. + pub fn detail(self) -> &'static str { + match self { + Self::AlreadyPresent => "already applied — nothing to do", + Self::StockHash => "stock file", + Self::RegionMatch => "file modified, patched region stock", + } + } +} + +/// Why a patch could not be placed. Every variant is a refusal to write, and each one names +/// something the operator can act on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The target file is not in the ServUO tree at all. + Missing, + /// A hunk's stock region is not in the file — moved, edited, or already partly merged. + RegionModified { hunk: usize }, + /// A hunk's stock region appears more than once, so the anchor cannot identify one place. + Ambiguous { hunk: usize, occurrences: usize }, + /// Some hunks are already applied and others are not: a hand-merge in progress, which is the + /// one state where "finish the job" is the most dangerous thing the installer could do. + PartiallyApplied, + /// The file could not be read. + Unreadable(String), +} + +impl Refusal { + pub fn detail(&self) -> String { + match self { + Self::Missing => "the file is not in this ServUO tree — not applied".into(), + Self::RegionModified { hunk } => { + format!("patched region has been modified (hunk {hunk}) — not applied") + } + Self::Ambiguous { hunk, occurrences } => { + format!("hunk {hunk}'s region appears {occurrences} times — ambiguous, not applied") + } + Self::PartiallyApplied => { + "partly applied already — a hand merge in progress, not touched".into() + } + Self::Unreadable(why) => format!("cannot be read ({why}) — not applied"), + } + } +} + +/// What resolving one patch against one tree concluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Resolution { + /// Nothing to do — the change is already in the file. + AlreadyPresent { hunks: Vec }, + /// The patch can be placed. `edits` are byte ranges in the current file content, highest offset + /// first, so applying them in order keeps every later offset valid. + Applicable { + rung: Rung, + hunks: Vec, + edits: Vec, + }, + /// The patch will not be placed, and why. + Refused(Refusal), +} + +impl Resolution { + pub fn rung(&self) -> Option { + match self { + Self::AlreadyPresent { .. } => Some(Rung::AlreadyPresent), + Self::Applicable { rung, .. } => Some(*rung), + Self::Refused(_) => None, + } + } + + pub fn placements(&self) -> &[HunkPlacement] { + match self { + Self::AlreadyPresent { hunks } | Self::Applicable { hunks, .. } => hunks, + Self::Refused(_) => &[], + } + } +} + +/// Where a hunk was found, for the record and for the report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct HunkPlacement { + /// The `@@` header's stock line number — what the patch author saw. + pub declared_line: usize, + /// The 1-based line the region was actually found at. Insertions above the region shift this, + /// which is exactly why the match is by content and this number is an output rather than an + /// input. + pub matched_line: usize, +} + +/// A byte range of the file to replace with `replacement`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edit { + pub start: usize, + pub end: usize, + pub replacement: Vec, +} + +/// Resolves one patch against one file's current content. +/// +/// The order is the ladder's: rung 0 first (so a re-run is a no-op rather than a second apply), +/// then the whole-file hash, then the region match. `pre_blob` is the diff's `index` pre-image; a +/// patch without an `index` line simply cannot reach rung 1, which is not a defect — rung 2 is the +/// stronger check anyway. +pub fn resolve(file: &FilePatch, content: &[u8]) -> Resolution { + let hunks: Vec<&Hunk> = file.hunks.iter().filter(|h| !h.is_noop()).collect(); + if hunks.is_empty() { + return Resolution::Refused(Refusal::RegionModified { hunk: 1 }); + } + let lines = diff::split_lines(content); + let normalized: Vec<&[u8]> = lines.iter().map(|l| diff::normalize(l)).collect(); + + // ── Rung 0 ─────────────────────────────────────────────────────────────── + // All-or-nothing: a file where some hunks are present and others are not is a hand-merge in + // progress, and "finish it" is the one thing that must not happen automatically. + let post_hits: Vec>> = hunks + .iter() + .map(|h| find_block(&normalized, &normalize_all(&h.post_image()))) + .collect(); + let present = post_hits + .iter() + .filter(|hit| hit.as_ref().is_some_and(|m| m.len() == 1)) + .count(); + if present == hunks.len() { + return Resolution::AlreadyPresent { + hunks: hunks + .iter() + .zip(&post_hits) + .map(|(h, hit)| HunkPlacement { + declared_line: h.old_start, + matched_line: hit.as_ref().map(|m| m[0] + 1).unwrap_or(0), + }) + .collect(), + }; + } + if present > 0 { + return Resolution::Refused(Refusal::PartiallyApplied); + } + + // ── Rungs 1 and 2 ──────────────────────────────────────────────────────── + // Both place text by content match; only the verdict differs. A file whose hash says "stock" + // must match by content too — if it somehow did not, the honest answer is rung 3, not a write + // made on the strength of a hash alone. + let stock = file + .pre_blob + .as_deref() + .is_some_and(|expected| git_blob_hash(content).starts_with(expected)); + + let eol = dominant_eol(content); + let mut placements = Vec::with_capacity(hunks.len()); + let mut edits = Vec::with_capacity(hunks.len()); + + for (index, hunk) in hunks.iter().enumerate() { + let needle = normalize_all(&hunk.pre_image()); + let matches = match find_block(&normalized, &needle) { + Some(m) => m, + None => return Resolution::Refused(Refusal::RegionModified { hunk: index + 1 }), + }; + if matches.len() > 1 { + return Resolution::Refused(Refusal::Ambiguous { + hunk: index + 1, + occurrences: matches.len(), + }); + } + let at = matches[0]; + placements.push(HunkPlacement { + declared_line: hunk.old_start, + matched_line: at + 1, + }); + edits.push(splice(content, &lines, at, needle.len(), hunk, eol)); + } + + // Two hunks resolving onto overlapping text would corrupt the file even though each matched + // uniquely. It cannot happen with a well-formed diff — git never emits overlapping hunks — but + // a hand-assembled one could, and the cost of the check is nothing. + let mut ordered: Vec<&Edit> = edits.iter().collect(); + ordered.sort_by_key(|e| e.start); + if ordered.windows(2).any(|w| w[0].end > w[1].start) { + return Resolution::Refused(Refusal::RegionModified { hunk: 1 }); + } + + // Highest offset first, so applying one edit never invalidates the next one's range. + edits.sort_by(|a, b| b.start.cmp(&a.start)); + Resolution::Applicable { + rung: if stock { + Rung::StockHash + } else { + Rung::RegionMatch + }, + hunks: placements, + edits, + } +} + +/// Builds the replacement for one hunk: the byte range the matched pre-image occupies, and the +/// post-image rendered with the file's own line ending. +/// +/// Working in byte ranges rather than rebuilding the file from parsed lines is what guarantees the +/// rest of the file comes out unchanged down to the byte — including any mixed line endings, +/// trailing whitespace, or unusual encoding elsewhere in it, none of which is this tool's business. +fn splice( + content: &[u8], + lines: &[&[u8]], + at: usize, + span: usize, + hunk: &Hunk, + eol: &[u8], +) -> Edit { + let start = offset_of(content, lines, at); + let last = at + span - 1; + // The end of the matched block, terminator included — unless it is the file's last line and + // the file has no trailing newline. + let end = if last + 1 < lines.len() { + offset_of(content, lines, last + 1) + } else { + content.len() + }; + let trailing_newline = end > 0 && content[end - 1] == b'\n'; + + let post = hunk.post_image(); + let mut replacement = Vec::with_capacity(end - start); + for (i, line) in post.iter().enumerate() { + replacement.extend_from_slice(line); + let is_last = i + 1 == post.len(); + // The last line keeps whatever the region it replaces had: a hunk in the middle of a file + // is always terminated, and one at the end of a file with no final newline must not gain + // one. `\ No newline at end of file` on the new side says the same thing explicitly. + if !is_last || (trailing_newline && !hunk.new_no_newline) { + replacement.extend_from_slice(eol); + } + } + Edit { + start, + end, + replacement, + } +} + +/// The byte offset at which line `index` starts. +/// +/// Derived from the slice's position inside the buffer rather than by re-scanning: [`split_lines`] +/// borrows from `content`, so the arithmetic is exact and cannot disagree with the split. +fn offset_of(content: &[u8], lines: &[&[u8]], index: usize) -> usize { + if index >= lines.len() { + return content.len(); + } + lines[index].as_ptr() as usize - content.as_ptr() as usize +} + +/// The line ending the file mostly uses, for inserted lines. +/// +/// The three files this tier edits are CRLF. Writing LF into them would leave islands of the wrong +/// ending inside a method — harmless to the C# compiler, and a permanent source of noise in every +/// diff the operator takes afterwards. +fn dominant_eol(content: &[u8]) -> &'static [u8] { + let total = content.iter().filter(|b| **b == b'\n').count(); + let crlf = content.windows(2).filter(|w| w == b"\r\n").count(); + if total > 0 && crlf * 2 >= total { + b"\r\n" + } else { + b"\n" + } +} + +fn normalize_all<'a>(lines: &[&'a [u8]]) -> Vec<&'a [u8]> { + lines.iter().map(|l| diff::normalize(l)).collect() +} + +/// Every starting line at which `needle` appears in `haystack`, comparing normalized content. +/// +/// Returns `None` for an empty needle rather than "matches everywhere", which is the difference +/// between refusing a degenerate hunk and splicing at line 1 of the file. +fn find_block(haystack: &[&[u8]], needle: &[&[u8]]) -> Option> { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + let hits: Vec = (0..=haystack.len() - needle.len()) + .filter(|start| haystack[*start..*start + needle.len()] == *needle) + .collect(); + if hits.is_empty() { + None + } else { + Some(hits) + } +} + +/// Applies the edits of an [`Resolution::Applicable`] to a buffer. +pub fn apply(content: &[u8], edits: &[Edit]) -> Vec { + let mut out = content.to_vec(); + // The edits arrive highest-offset-first from `resolve`, so each splice leaves every remaining + // range valid. Re-sorting here rather than trusting the caller keeps that a local property. + let mut ordered: Vec<&Edit> = edits.iter().collect(); + ordered.sort_by(|a, b| b.start.cmp(&a.start)); + for edit in ordered { + out.splice(edit.start..edit.end, edit.replacement.iter().copied()); + } + out +} + +// ───────────────────────────────────────────────────────────────────────────── +// The tier's declared shape +// ───────────────────────────────────────────────────────────────────────────── + +/// The patch tier as the overlay release declares it (`patch_tier` in `manifest.json`, generated +/// from `servuo-plugins/patches/tier.json`). +/// +/// A `.patch` does not carry enough on its own: which patches form one all-or-nothing unit, which +/// companion `.cs` may only be copied once that unit lands, whether a **core** solution rebuild is +/// needed, and what the operator loses by declining are all things the diffs cannot say. Declaring +/// them in the release means adding a patch regenerates release metadata rather than requiring an +/// installer release — the same rule PLAN.md §7.1 applies to the bundle. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Tier { + pub features: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Feature { + pub name: String, + /// What the operator gains, for the offer. + pub summary: String, + /// What they lose by declining, phrased to complete "Without it: …". + pub lost: String, + /// `core` — the ServUO solution must be rebuilt (`dotnet build ServUO.sln`); the dynamic script + /// build is not enough. `scripts` — a shard restart suffices. + pub rebuild: Rebuild, + pub patches: Vec, + pub companions: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Rebuild { + Core, + Scripts, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PatchRef { + pub name: String, + /// Relative to the extracted tarball root, i.e. `patches/.patch`. + pub file: String, + /// Relative to the ServUO root, `/`-separated. + pub target: String, +} + +/// A source file that may only be copied once its feature's patches have landed, because it +/// references symbols they introduce. Shipping these in the base overlay would break the build on +/// every unpatched install, which is why they live in `patches/`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Companion { + /// Relative to the extracted tarball root. + pub file: String, + /// Relative to the ServUO root. + pub install_to: String, +} + +impl Tier { + /// The tier an overlay declares, or the built-in description of the one that shipped before + /// `patch_tier` existed. + /// + /// The fallback is not a convenience: overlay `v0.1.1` is in the current bundle and declares + /// nothing, so without it this whole phase would be unusable until a new overlay release + /// existed. It describes exactly the three patches that release ships. An overlay that declares + /// its own tier always wins, so the fallback goes quiet the moment it is wrong. + pub fn resolve(declared: Option<&Tier>) -> Tier { + declared.cloned().unwrap_or_else(Tier::builtin) + } + + /// Mirrors `servuo-plugins/patches/tier.json`, which is the source of truth. Only reached for + /// an overlay released before that file existed. + pub fn builtin() -> Tier { + Tier { + features: vec![ + Feature { + name: "vendor-sale".into(), + summary: "vendor.sale events — player-vendor purchases with buyer, owner, \ + item, price and commission" + .into(), + lost: "no vendor.sale events".into(), + rebuild: Rebuild::Core, + patches: vec![ + PatchRef { + name: "playervendor-sale-eventsink".into(), + file: "patches/playervendor-sale-eventsink.patch".into(), + target: "Server/EventSink.cs".into(), + }, + PatchRef { + name: "playervendor-sale-gump".into(), + file: "patches/playervendor-sale-gump.patch".into(), + target: "Scripts/Gumps/PlayerVendorGumps.cs".into(), + }, + ], + companions: vec![Companion { + file: "patches/BridgeVendorSale.cs".into(), + install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(), + }], + }, + Feature { + name: "moderation-audit".into(), + summary: "in-game moderation actions ([ban, [kick, [bcast) forwarded to the \ + website as admin.audit" + .into(), + lost: "no in-game moderation audit forwarding".into(), + rebuild: Rebuild::Scripts, + patches: vec![PatchRef { + name: "commandlogging-event".into(), + file: "patches/commandlogging-event.patch".into(), + target: "Scripts/Commands/Logging.cs".into(), + }], + companions: vec![Companion { + file: "patches/BridgeModerationAudit.cs".into(), + install_to: "Scripts/Custom/Bridge/BridgeModerationAudit.cs".into(), + }], + }, + ], + } + } + + pub fn patch_count(&self) -> usize { + self.features.iter().map(|f| f.patches.len()).sum() + } +} + +/// Joins a `/`-separated relative path onto a root, using this platform's separator. +pub fn join(root: &Path, rel: &str) -> PathBuf { + root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) +} + +/// Reads and parses one declared patch, checking that the diff edits the file the tier says it +/// does. +/// +/// The cross-check is not redundant with the release gate: the gate gives up if a patch is renamed +/// in one place and not the other, but an operator can also be running an installer against an +/// overlay whose tier declaration and patches were assembled by hand. The installer is about to +/// edit a stock file on the strength of that pairing, so it verifies it rather than assuming. +pub fn load(unpacked: &Path, patch: &PatchRef) -> Result<(Vec, FilePatch)> { + let path = join(unpacked, &patch.file); + let bytes = std::fs::read(&path) + .with_context(|| format!("cannot read {} from the overlay release", patch.file))?; + let parsed = diff::parse(&bytes) + .with_context(|| format!("{} is not a patch this installer can read", patch.file))?; + let file = parsed + .single_file() + .with_context(|| format!("{} cannot be applied as a single-target patch", patch.file))? + .clone(); + if file.path != patch.target { + bail!( + "{} edits {} but the overlay declares its target as {} — refusing to patch a file the \ + release does not claim it patches", + patch.file, + file.path, + patch.target + ); + } + Ok((bytes, file)) +} + +/// The applied patch tier, as `install.json` records it. +/// +/// Only features that are actually in place are recorded: a declined or refused feature left no +/// trace in the tree, and a record of it would make `doctor` and `uninstall` report work that was +/// never done. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureRecord { + pub feature: String, + pub rebuild: Rebuild, + /// The ServUO version detected when the tier ran, and whether it ran on an unsupported one. + /// + /// This is the label that follows the install (PLAN.md §2.2.2): `doctor` shows it on every + /// later run and the uninstall report carries it, so whoever inherits this shard can see it + /// without being told. + pub servuo_version: Option, + pub unsupported_servuo: bool, + pub patches: Vec, + pub companions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppliedPatch { + pub name: String, + /// Relative to the ServUO root. + pub target: String, + /// `stock-hash`, `region-match` or `already-present`. + pub rung: String, + /// SHA256 of the `.patch` file, which is also cached beside `install.json`. + pub sha256: String, + pub hunks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionRecord { + /// Relative to the ServUO root. + pub path: String, + pub sha256: String, +} + +/// Indexes recorded features by name, for the idempotence rule in `install.rs`. +pub fn index_records(records: &[FeatureRecord]) -> BTreeMap<&str, &FeatureRecord> { + records + .iter() + .map(|r| (r.feature.as_str(), r)) + .collect::>() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::parse; + + /// A file with the patched region buried in enough surrounding text that a match is meaningful. + fn stock_file() -> Vec { + let mut s = String::new(); + for i in 0..40 { + s.push_str(&format!("// filler {i}\n")); + } + s.push_str(" public void Alpha()\n"); + s.push_str(" {\n"); + s.push_str(" Work();\n"); + s.push_str(" }\n"); + for i in 0..40 { + s.push_str(&format!("// tail {i}\n")); + } + s.into_bytes() + } + + /// Adds a line inside the patched region. + const PATCH: &str = "\ +--- a/Target.cs ++++ b/Target.cs +@@ -41,4 +41,5 @@ + public void Alpha() + { ++ Hook(); + Work(); + } +"; + + fn file_patch(text: &str) -> FilePatch { + parse(text.as_bytes()) + .unwrap() + .single_file() + .unwrap() + .clone() + } + + fn applied(patch: &FilePatch, content: &[u8]) -> Vec { + match resolve(patch, content) { + Resolution::Applicable { edits, .. } => apply(content, &edits), + other => panic!("expected an applicable resolution, got {other:?}"), + } + } + + #[test] + fn a_stock_file_reaches_rung_one_and_applies() { + let content = stock_file(); + // A real diff's index line names the stock blob; build one so the hash rung is exercised + // against a hash this test did not also invent. + let patch = file_patch(&PATCH.replace( + "--- a/Target.cs", + &format!( + "index {}..0000000 100644\n--- a/Target.cs", + git_blob_hash(&content) + ), + )); + + let resolution = resolve(&patch, &content); + assert_eq!(resolution.rung(), Some(Rung::StockHash)); + let out = apply( + &content, + match &resolution { + Resolution::Applicable { edits, .. } => edits, + _ => unreachable!(), + }, + ); + let text = String::from_utf8(out).unwrap(); + assert!( + text.contains(" Hook();\n Work();\n"), + "{text}" + ); + } + + #[test] + fn an_edit_far_from_the_region_still_reaches_rung_two() { + // The whole point of the ladder: most real shards are hand-modified somewhere, and + // refusing on a whole-file hash would hand them a manual job they did not need. + let mut content = stock_file(); + content.extend_from_slice(b"// the operator added their own command down here\n"); + + let patch = file_patch(PATCH); + let resolution = resolve(&patch, &content); + assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); + assert_eq!(resolution.placements()[0].matched_line, 41); + assert!(String::from_utf8(applied(&patch, &content)) + .unwrap() + .contains("Hook();")); + } + + #[test] + fn an_edit_inside_the_region_is_refused_and_writes_nothing() { + let content = String::from_utf8(stock_file()) + .unwrap() + .replace(" Work();", " Work(withMyArgument);") + .into_bytes(); + match resolve(&file_patch(PATCH), &content) { + Resolution::Refused(Refusal::RegionModified { hunk }) => assert_eq!(hunk, 1), + other => panic!("expected a refusal, got {other:?}"), + } + } + + #[test] + fn a_region_that_appears_twice_fails_rather_than_picking_the_first() { + // The rule that keeps a hunk out of the wrong method. Both copies are legitimate code; + // nothing here can know which one the patch author meant, so it must not guess. + let mut content = stock_file(); + content.extend_from_slice( + b" public void Alpha()\n {\n Work();\n }\n", + ); + match resolve(&file_patch(PATCH), &content) { + Resolution::Refused(Refusal::Ambiguous { occurrences, .. }) => { + assert_eq!(occurrences, 2) + } + other => panic!("expected an ambiguity refusal, got {other:?}"), + } + } + + #[test] + fn an_already_patched_file_is_rung_zero_and_a_re_run_is_a_no_op() { + let content = stock_file(); + let patch = file_patch(PATCH); + let once = applied(&patch, &content); + + let resolution = resolve(&patch, &once); + assert_eq!(resolution.rung(), Some(Rung::AlreadyPresent)); + assert!(matches!(resolution, Resolution::AlreadyPresent { .. })); + // ...and there is nothing to apply, so a third run cannot double up. + assert_eq!(applied(&patch, &content), once); + } + + #[test] + fn a_half_merged_file_is_refused() { + // Some hunks present, others not. "Finish the job" is the most dangerous thing available + // here, because the operator is evidently mid-merge. + let two_hunks = "\ +--- a/Target.cs ++++ b/Target.cs +@@ -41,2 +41,3 @@ + public void Alpha() + { ++ Hook(); +@@ -60,1 +61,2 @@ + // tail 15 ++// second hook +"; + let content = stock_file(); + let patch = file_patch(two_hunks); + let both = applied(&patch, &content); + + // Undo only the second hunk, leaving the first in place. + let half = String::from_utf8(both) + .unwrap() + .replace("// tail 15\n// second hook\n", "// tail 15\n") + .into_bytes(); + assert!(matches!( + resolve(&patch, &half), + Resolution::Refused(Refusal::PartiallyApplied) + )); + } + + #[test] + fn a_crlf_file_keeps_its_line_endings_and_its_other_bytes() { + // All three ServUO files this tier edits are CRLF. Inserting LF lines into one would leave + // islands of the wrong ending inside a method and pollute every later diff. + let content = String::from_utf8(stock_file()) + .unwrap() + .replace('\n', "\r\n") + .into_bytes(); + let out = applied(&file_patch(PATCH), &content); + + assert!(!String::from_utf8_lossy(&out).contains("Hook();\n Work")); + assert!(String::from_utf8_lossy(&out).contains("Hook();\r\n Work")); + // Every line is still CRLF — no islands. + assert_eq!( + out.iter().filter(|b| **b == b'\n').count(), + out.windows(2).filter(|w| w == b"\r\n").count() + ); + } + + #[test] + fn everything_outside_the_hunk_comes_back_byte_identical() { + // The splice, not a re-render. Unusual bytes elsewhere in the file are the operator's + // business and must survive untouched. + let mut content = stock_file(); + content.extend_from_slice(b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n"); + let out = applied(&file_patch(PATCH), &content); + + let tail = b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n"; + assert!(out.ends_with(tail)); + assert_eq!(out.len(), content.len() + b" Hook();\n".len()); + } + + #[test] + fn a_file_without_a_trailing_newline_does_not_gain_one() { + let content = b"alpha\nbeta".to_vec(); + let patch = file_patch("--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n alpha\n+middle\n beta\n"); + let out = applied(&patch, &content); + assert_eq!(out, b"alpha\nmiddle\nbeta"); + } + + #[test] + fn trailing_whitespace_differences_do_not_block_a_match() { + // Editors and mail transports strip trailing whitespace routinely; PLAN.md §2.2.1 allows + // exactly this much normalization and no more. + let content = String::from_utf8(stock_file()) + .unwrap() + .replace(" {\n", " { \n") + .into_bytes(); + assert!(resolve(&file_patch(PATCH), &content).rung().is_some()); + } + + #[test] + fn a_hash_that_says_stock_but_content_that_does_not_is_refused() { + // A contradiction: the file claims to be the pre-image but the region is not there. The + // only safe reading is rung 3 — a write made on the strength of a hash alone is exactly + // what the ladder exists to avoid. + let content = b"nothing like the patched file at all\n".to_vec(); + let patch = file_patch(&PATCH.replace( + "--- a/Target.cs", + &format!( + "index {}..0000000 100644\n--- a/Target.cs", + git_blob_hash(&content) + ), + )); + assert!(matches!( + resolve(&patch, &content), + Resolution::Refused(Refusal::RegionModified { .. }) + )); + } + + #[test] + fn the_builtin_tier_describes_the_release_that_predates_the_declaration() { + // Mirrors servuo-plugins/patches/tier.json. If that file gains a feature, this fallback is + // only ever used for older overlays, which do not have it — so it stays as it is. + let tier = Tier::resolve(None); + assert_eq!(tier.features.len(), 2); + assert_eq!(tier.patch_count(), 3); + + let vendor = &tier.features[0]; + assert_eq!(vendor.rebuild, Rebuild::Core, "EventSink.cs is a core file"); + assert_eq!( + vendor.patches.len(), + 2, + "the two vendor patches are one unit" + ); + for feature in &tier.features { + assert!(!feature.companions.is_empty()); + for companion in &feature.companions { + assert!(companion.file.starts_with("patches/"), "{companion:?}"); + assert!( + companion.install_to.starts_with("Scripts/Custom/Bridge/"), + "{companion:?}" + ); + } + } + } + + #[test] + fn a_declared_tier_wins_over_the_builtin_one() { + let declared = Tier { + features: vec![Feature { + name: "future-feature".into(), + summary: "something later".into(), + lost: "nothing yet".into(), + rebuild: Rebuild::Scripts, + patches: vec![], + companions: vec![], + }], + }; + assert_eq!(Tier::resolve(Some(&declared)), declared); + } + + #[test] + fn the_tier_round_trips_through_the_manifests_json_shape() { + // The exact shape servuo-plugins/patches/tier.json produces, so a mismatch shows up here + // rather than as a silently empty tier on an operator's shard. + let json = r#"{ + "features": [{ + "name": "moderation-audit", + "summary": "in-game moderation actions forwarded as admin.audit", + "lost": "no in-game moderation audit forwarding", + "rebuild": "scripts", + "patches": [{ + "name": "commandlogging-event", + "file": "patches/commandlogging-event.patch", + "target": "Scripts/Commands/Logging.cs" + }], + "companions": [{ + "file": "patches/BridgeModerationAudit.cs", + "install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs" + }] + }] + }"#; + let tier: Tier = serde_json::from_str(json).unwrap(); + assert_eq!(tier.features[0].rebuild, Rebuild::Scripts); + assert_eq!(tier.patch_count(), 1); + assert_eq!( + tier.features[0].patches[0].target, + "Scripts/Commands/Logging.cs" + ); + } +} diff --git a/src/paths.rs b/src/paths.rs index f3415a2..edb9ec7 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -47,6 +47,27 @@ impl Layout { 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") + } + /// 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 { diff --git a/src/record.rs b/src/record.rs index 588a04a..3bf92c4 100644 --- a/src/record.rs +++ b/src/record.rs @@ -43,7 +43,13 @@ pub struct InstallRecord { /// in the other direction. Read it with [`InstallRecord::link_record`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, - /// Phase 3 (applied patches, with the rung that applied each). Carried through untouched. + /// The patch tier: one entry per feature actually in place, with the rung that applied each of + /// its patches (Phase 3). + /// + /// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer + /// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`]. + /// Only features that are *applied* appear: a declined or refused one left no trace in the + /// tree, and recording it would make `doctor` and `uninstall` report work nobody did. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub patches: Vec, /// Anything a newer installer wrote that this one has no name for. @@ -203,6 +209,19 @@ impl InstallRecord { pub fn link_record(&self) -> Option { serde_json::from_value(self.link.clone()?).ok() } + + /// The patch-tier entries this build understands. + /// + /// An entry it cannot parse is dropped from the returned list but still carried through on + /// save, exactly as with [`Self::link_record`]. The consequence of a dropped entry is that this + /// run re-derives that feature's state from the tree — which the rung ladder answers correctly + /// on its own — rather than an older installer refusing to run on a newer host. + pub fn patch_records(&self) -> Vec { + self.patches + .iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() + } } pub fn now_rfc3339() -> String { diff --git a/src/tier.rs b/src/tier.rs new file mode 100644 index 0000000..131c29a --- /dev/null +++ b/src/tier.rs @@ -0,0 +1,894 @@ +//! Running the patch tier as part of an `install`. +//! +//! [`crate::patch`] decides what may be written; this module decides whether it is offered at all, +//! writes it, reports it, and records it. The split matters because the two halves fail +//! differently: a wrong answer in `patch` corrupts a stock ServUO file, and a wrong answer here +//! means an operator was never asked, or believes something was patched that was not. +//! +//! ## Consent (PLAN.md §2.2.2) +//! +//! The tier is opt-in on every tree, and on a tree that is not stock ServUO 57.4 it is opt-in +//! *twice*: +//! +//! - The interactive prompt defaults to **no**, and on a non-57.4 tree prints an unmissable banner +//! before it is even offered — that 57.4 is the only supported version, that the operator is on +//! their own, and that a bad outcome may not surface until the shard is running. +//! - `--patches` alone is **not** consent there. An unattended run must also pass +//! `--patches-unsupported-servuo`, because a flag someone had to look up cannot be hit by +//! accident in a script copied from somewhere else. +//! +//! Withholding that second flag **skips the tier loudly; it does not fail the run.** By the time +//! this runs the overlay is deployed and the sidecar is about to be installed, and turning a +//! completed base install into exit 1 over a tier that is documented as optional would cost the +//! operator more than the tier is worth. Saying nothing would be the real failure, so the skip is +//! reported at the point it happens and again in the closing summary. +//! +//! ## All-or-nothing, at two levels +//! +//! `patch::resolve` is all-or-nothing per patch file: if one hunk reaches rung 3, none of that +//! patch's hunks are written. This module adds the second level — **per feature**. The two +//! vendor-sale patches are one unit (`EventSink.cs` grows the event, `PlayerVendorGumps.cs` raises +//! it, and the companion `BridgeVendorSale.cs` subscribes to it); applying either alone produces a +//! tree that either does not compile or silently never emits. So every patch in a feature is +//! resolved first, and nothing is written unless all of them can be. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::cli::{Cli, PatchChoice}; +use crate::patch::{ + self, AppliedPatch, CompanionRecord, Feature, FeatureRecord, Rebuild, Resolution, Rung, Tier, +}; +use crate::servuo::{self, ServUoRoot}; +use crate::util::{sha256_file, write_atomic}; +use crate::{paths, ui}; + +/// What the tier did, for the closing summary and the record. +pub struct Outcome { + /// One entry per feature now in place. Written to `install.json`. + pub records: Vec, + /// The tier ran (as opposed to being declined, skipped or unavailable). + pub ran: bool, + /// A feature whose patches touch a core file was applied, so the solution must be rebuilt — + /// ServUO's dynamic script build is not enough and will not say so. + pub core_rebuild: bool, +} + +impl Outcome { + fn skipped() -> Self { + Self { + records: Vec::new(), + ran: false, + core_rebuild: false, + } + } +} + +/// Decides whether the tier runs, then runs it. +/// +/// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than +/// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]). +#[allow(clippy::too_many_arguments)] +pub fn run( + cli: &Cli, + root: &ServUoRoot, + unpacked: &Path, + declared: Option<&Tier>, + layout: &paths::Layout, + prior: &[FeatureRecord], +) -> Result { + let tier = Tier::resolve(declared); + if tier.features.is_empty() { + ui::row( + "Patch tier", + "not offered — this overlay declares no patches", + ); + return Ok(Outcome::skipped()); + } + let supported = root.is_supported_version(); + + match consent(cli, root, supported, &tier)? { + Consent::Yes => {} + Consent::No(reason) => { + ui::row("Patch tier", &reason); + print_cost(&tier, prior); + return Ok(Outcome::skipped()); + } + Consent::RefusedUnsupported => { + println!(); + ui::warn(&format!( + "Patch tier REQUESTED BUT NOT RUN — this tree reports ServUO {}, and \ + {} is the\n only supported version. --patches on its own is not consent here.\n \ + No stock ServUO file has been touched.\n\n \ + To run it anyway, unsupported and untested, add --patches-unsupported-servuo.\n \ + Read INSTALL.md §4 first, and back up your ServUO tree.", + root.version_display(), + servuo::SUPPORTED_VERSION + )); + print_cost(&tier, prior); + return Ok(Outcome::skipped()); + } + } + + apply_tier(cli, root, unpacked, &tier, layout, prior, supported) +} + +enum Consent { + Yes, + /// Not selected, with the reason to print. + No(String), + /// Asked for on a tree where `--patches` alone is not enough. + RefusedUnsupported, +} + +fn consent(cli: &Cli, root: &ServUoRoot, supported: bool, tier: &Tier) -> Result { + if cli.patches == PatchChoice::No { + return Ok(Consent::No("skipped (--no-patches)".into())); + } + if cli.patches == PatchChoice::Yes { + return Ok(if supported || cli.patches_unsupported_servuo { + Consent::Yes + } else { + Consent::RefusedUnsupported + }); + } + + // PatchChoice::Ask — offer it. + ui::heading("Patch tier (optional)"); + println!( + " {} feature(s) need edits to stock ServUO files. Without them:", + tier.features.len() + ); + for feature in &tier.features { + println!(" - {}", feature.summary); + } + if !supported { + print_unsupported_banner(root); + } else { + println!( + "\n Every patch is checked before anything is written, and any whose target lines are\n \ + no longer stock is reported for you to apply by hand rather than forced." + ); + } + + // The default is no on every tree (INSTALL.md §2). `--yes` takes that default, which makes an + // unattended run that did not ask for the tier safely skip it. + match ui::confirm("Apply the patch tier?", false, cli.assume_yes) { + Ok(true) => Ok(Consent::Yes), + Ok(false) => Ok(Consent::No("not selected".into())), + // A piped run with no --yes cannot answer, and the base install has already succeeded. + // Declining for it is the documented default, so this is a note rather than a failure. + Err(_) => Ok(Consent::No( + "not selected (no terminal to ask; pass --patches to enable)".into(), + )), + } +} + +fn print_unsupported_banner(root: &ServUoRoot) { + println!(); + println!(" ┌──────────────────────────────────────────────────────────────────────────────┐"); + println!(" │ ⚠ UNSUPPORTED, UNTESTED, NOT GUARANTEED TO WORK │"); + println!(" └──────────────────────────────────────────────────────────────────────────────┘"); + println!( + " This tree reports ServUO {}. Runic Gateway is designed, built and tested against\n \ + stock ServUO {} — that is the only supported version.", + root.version_display(), + servuo::SUPPORTED_VERSION + ); + println!( + "\n You may run the tier here. If you do, you are on your own: it is not covered by\n \ + support, and a bad outcome may not show up until your shard is live, because ServUO's\n \ + script build reports success even when it failed and quietly keeps running the previous\n \ + Scripts.dll." + ); + println!( + "\n A patch is still refused wherever the exact lines it edits have changed — but matching\n \ + text is not matching behaviour. A hunk can land correctly and still be wrong for a tree\n \ + that has diverged around it." + ); + println!( + "\n Back up your ServUO tree first, and verify your shard boots and compiles afterwards." + ); +} + +/// What declining costs — counting only the features that are not already in place. +/// +/// A decline does not inspect the tree, so the previous run's record is the only evidence +/// available. Ignoring it produced a run that skipped the tier and then announced the loss of two +/// features `install.json` shows as applied, which is worse than saying nothing: an operator +/// reading it would go looking for a problem that does not exist. +fn print_cost(tier: &Tier, prior: &[FeatureRecord]) { + let applied = patch::index_records(prior); + let lost: Vec<&str> = tier + .features + .iter() + .filter(|f| !applied.contains_key(f.name.as_str())) + .map(|f| f.lost.as_str()) + .collect(); + + if lost.is_empty() && !applied.is_empty() { + println!(" Everything this tier provides is already applied — nothing was changed."); + } else { + println!(" Without it: {}.", lost.join(", ")); + if !applied.is_empty() { + println!( + " ({} already applied by an earlier run and left in place.)", + applied.keys().cloned().collect::>().join(", ") + ); + } + } +} + +/// Resolves and applies every feature. +#[allow(clippy::too_many_arguments)] +fn apply_tier( + cli: &Cli, + root: &ServUoRoot, + unpacked: &Path, + tier: &Tier, + layout: &paths::Layout, + prior: &[FeatureRecord], + supported: bool, +) -> Result { + let previous = patch::index_records(prior); + let mut records: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut lost: Vec<&str> = Vec::new(); + let mut applied_patches = 0usize; + let mut core_rebuild = false; + let mut core_targets: Vec = Vec::new(); + + for feature in &tier.features { + let resolved = resolve_feature(root, unpacked, feature)?; + let placeable = resolved.iter().all(|r| r.resolution.rung().is_some()); + + for r in &resolved { + lines.push(render(feature, r, placeable, layout, cli.verify)); + } + + // Every patch the tier *evaluated* is cached, applied or not. `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 this run has just told the operator to apply by hand, + // so pointing them at a path that only exists on success would be the less useful half. + if !cli.verify { + cache_patches(&resolved, layout)?; + } + + if !placeable { + lost.push(&feature.lost); + continue; + } + + if !cli.verify { + write_feature(root, unpacked, feature, &resolved, layout)?; + } + applied_patches += resolved.len(); + + // The rebuild warning is about files this run *edited*, not about every file the feature + // covers. A feature whose patches were all already present changed nothing, so telling the + // operator to rebuild the core would be noise — and on a re-run, noise that recurs forever. + // The list names only the files actually written, since a core feature can also carry + // patches against Scripts files, and calling one of those a core file is simply wrong. + let written: Vec = resolved + .iter() + .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) + .map(|r| r.target.clone()) + .collect(); + if feature.rebuild == Rebuild::Core && !written.is_empty() { + core_rebuild = true; + core_targets.extend(written); + } + records.push(record_for( + feature, + &resolved, + root, + supported, + previous.get(feature.name.as_str()).copied(), + )); + } + + // ── Report ─────────────────────────────────────────────────────────────── + println!(); + ui::row( + "Patch tier", + &format!( + "{applied_patches} of {} {}", + tier.patch_count(), + if cli.verify { + "would be applied [--verify]" + } else { + "applied" + } + ), + ); + for line in lines { + println!("{line}"); + } + + if core_rebuild { + println!(); + ui::warn(&format!( + "{} — a CORE ServUO file was patched. Rebuild the solution:\n \ + dotnet build ServUO.sln\n \ + A shard restart is not enough; ServUO's dynamic script build does not rebuild the \ + core, and it will not tell you so.", + core_targets.join(", ") + )); + } + if !lost.is_empty() { + println!(); + println!(" Not applied, so you do not get: {}.", lost.join(", ")); + println!( + " Everything else works. Apply the hunks by hand if you want them, then re-run \ + install to record it." + ); + } + if cli.verify { + println!("\n VERIFY only. No ServUO file was edited and no patch was cached."); + } + + Ok(Outcome { + records, + ran: true, + core_rebuild: core_rebuild && !cli.verify, + }) +} + +/// One patch, resolved against the tree. +struct Resolved { + name: String, + /// Relative to the ServUO root. + target: String, + /// Relative to the extracted release. + file: String, + bytes: Vec, + content: Vec, + resolution: Resolution, +} + +fn resolve_feature(root: &ServUoRoot, unpacked: &Path, feature: &Feature) -> Result> { + let mut out = Vec::with_capacity(feature.patches.len()); + for declared in &feature.patches { + let (bytes, parsed) = patch::load(unpacked, declared)?; + let target = patch::join(&root.path, &declared.target); + + let (content, resolution) = match std::fs::read(&target) { + Ok(content) => { + let resolution = patch::resolve(&parsed, &content); + (content, resolution) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + (Vec::new(), Resolution::Refused(patch::Refusal::Missing)) + } + Err(error) => ( + Vec::new(), + Resolution::Refused(patch::Refusal::Unreadable(error.to_string())), + ), + }; + + out.push(Resolved { + name: declared.name.clone(), + target: declared.target.clone(), + file: declared.file.clone(), + bytes, + content, + resolution, + }); + } + Ok(out) +} + +/// Copies every evaluated patch into the state directory. +/// +/// Deliberately separate from [`write_feature`] and called for **refused** features too, because +/// the refusal message names this path as the file to apply by hand. Caching only what applied +/// would make that message point at a file the run had chosen not to write. +fn cache_patches(resolved: &[Resolved], layout: &paths::Layout) -> Result<()> { + for r in resolved { + let cached = layout.patches_dir().join(file_name(&r.file)); + write_atomic(&cached, &r.bytes).with_context(|| { + format!( + "cannot cache {} — the run needs somewhere to put the patch it is about to \ + reference", + r.name + ) + })?; + } + Ok(()) +} + +/// Writes one feature: the patched files and its companion sources. +/// +/// Reached only when every patch in the feature is placeable, so a partial write is not a state +/// this function can produce. The pre-image is cached **before** the file is written and never +/// overwritten afterwards, so it stays the content from before the tier first touched it. +fn write_feature( + root: &ServUoRoot, + unpacked: &Path, + feature: &Feature, + resolved: &[Resolved], + layout: &paths::Layout, +) -> Result<()> { + for r in resolved { + if let Resolution::Applicable { edits, .. } = &r.resolution { + let original = patch::join(&layout.patch_originals_dir(), &r.target); + if !original.exists() { + write_atomic(&original, &r.content) + .with_context(|| format!("cannot save the pre-patch copy of {}", r.target))?; + } + let patched = patch::apply(&r.content, edits); + let path = patch::join(&root.path, &r.target); + write_atomic(&path, &patched) + .with_context(|| format!("cannot write the patched {}", r.target))?; + } + } + + // Companions last, and only now: they reference symbols the patches introduce, so a companion + // copied beside an unpatched file is a shard that does not compile — and ServUO would report a + // clean boot anyway (PLAN.md §2.1). + for companion in &feature.companions { + let src = patch::join(unpacked, &companion.file); + let dst = patch::join(&root.path, &companion.install_to); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + std::fs::copy(&src, &dst).with_context(|| { + format!( + "cannot copy {} into the ServUO tree — the patches applied, so this file is \ + required for the shard to compile", + companion.install_to + ) + })?; + } + Ok(()) +} + +/// Builds the `install.json` entry for an applied feature. +/// +/// **A rung-0 result reuses the previous record whole.** The rung is the support-relevant fact — +/// how did this land? — and re-deriving it on a later run answers `already-present` for something +/// that first landed as `region-match`. That flip would rewrite `install.json` on the second run of +/// an otherwise-identical install, which is the same class of bug as the `Bridge.cfg` comparison in +/// `overlay::plan`: a record that describes the run instead of the state. +fn record_for( + feature: &Feature, + resolved: &[Resolved], + root: &ServUoRoot, + supported: bool, + prior: Option<&FeatureRecord>, +) -> FeatureRecord { + let all_already_present = resolved + .iter() + .all(|r| r.resolution.rung() == Some(Rung::AlreadyPresent)); + if all_already_present { + if let Some(prior) = prior { + return prior.clone(); + } + } + + FeatureRecord { + feature: feature.name.clone(), + rebuild: feature.rebuild, + servuo_version: root.version.clone(), + unsupported_servuo: !supported, + patches: resolved + .iter() + .map(|r| AppliedPatch { + name: r.name.clone(), + target: r.target.clone(), + rung: r + .resolution + .rung() + .map(Rung::as_str) + .unwrap_or("unknown") + .to_string(), + sha256: crate::util::sha256_bytes(&r.bytes), + hunks: r.resolution.placements().to_vec(), + }) + .collect(), + companions: feature + .companions + .iter() + .map(|c| CompanionRecord { + path: c.install_to.clone(), + // Hashed from the tree after the copy, so the record describes what is actually + // there — which is what lets `doctor` notice a companion that was later edited. + sha256: sha256_file(&patch::join(&root.path, &c.install_to)).unwrap_or_default(), + }) + .collect(), + } +} + +/// One reported line per patch, in the layout INSTALL.md §4 illustrates. +fn render( + feature: &Feature, + r: &Resolved, + placeable: bool, + layout: &paths::Layout, + verify: bool, +) -> String { + let mark = if placeable { "✓" } else { "✗" }; + let mut out = format!(" {mark} {:<30} {}", r.name, r.target); + + let detail = match &r.resolution { + Resolution::AlreadyPresent { .. } => Rung::AlreadyPresent.detail().to_string(), + Resolution::Applicable { rung, hunks, .. } => { + let at = hunks + .iter() + .map(|h| h.matched_line.to_string()) + .collect::>() + .join(", "); + // "applied" is only ever claimed for something that was actually written. A dry run + // wrote nothing, and a patch held back by its feature's all-or-nothing rule was + // resolvable but left alone — reporting either as applied is the exact misreading this + // whole tier is built to avoid. + let verb = match (placeable, verify) { + (true, false) => "applied at line", + (true, true) => "would be applied at line", + (false, _) => "could have been placed at line", + }; + format!("{} — {verb} {at}", rung.detail()) + } + Resolution::Refused(refusal) => refusal.detail(), + }; + out.push_str(&format!("\n {detail}")); + + // A feature is all-or-nothing, so a patch that could have been placed is still not written when + // a sibling could not. Saying "applied" there would be a lie the operator finds out about later. + if !placeable { + if r.resolution.rung().is_some() { + out.push_str(&format!( + "\n held back — {} is applied as one unit and a sibling patch could not be \ + placed", + feature.name + )); + } + if !verify { + out.push_str(&format!( + "\n apply this by hand, then re-run install to record it:\n {}", + layout.patches_dir().join(file_name(&r.file)).display() + )); + } + } + out +} + +fn file_name(rel: &str) -> &str { + rel.rsplit('/').next().unwrap_or(rel) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::{Companion, PatchRef}; + + fn feature() -> Feature { + Feature { + name: "vendor-sale".into(), + summary: "vendor.sale events".into(), + lost: "no vendor.sale events".into(), + rebuild: Rebuild::Core, + patches: vec![PatchRef { + name: "playervendor-sale-eventsink".into(), + file: "patches/playervendor-sale-eventsink.patch".into(), + target: "Server/EventSink.cs".into(), + }], + companions: vec![Companion { + file: "patches/BridgeVendorSale.cs".into(), + install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(), + }], + } + } + + fn resolved(resolution: Resolution) -> Resolved { + Resolved { + name: "playervendor-sale-eventsink".into(), + target: "Server/EventSink.cs".into(), + file: "patches/playervendor-sale-eventsink.patch".into(), + bytes: b"--- a/x\n".to_vec(), + content: Vec::new(), + resolution, + } + } + + fn root() -> ServUoRoot { + ServUoRoot { + path: std::path::PathBuf::from("/opt/ServUO"), + version: Some("57.4".into()), + } + } + + fn layout() -> paths::Layout { + paths::Layout { + state_dir: std::path::PathBuf::from("/etc/runicgateway"), + data_dir: std::path::PathBuf::from("/var/lib/runicgateway"), + sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"), + relocated: false, + } + } + + #[test] + fn a_refused_patch_points_at_the_cached_file_to_apply_by_hand() { + let r = resolved(Resolution::Refused(patch::Refusal::RegionModified { + hunk: 2, + })); + let line = render(&feature(), &r, false, &layout(), false); + assert!(line.contains('✗'), "{line}"); + assert!(line.contains("patched region has been modified"), "{line}"); + assert!( + line.contains("playervendor-sale-eventsink.patch"), + "the operator needs the path of the file to apply: {line}" + ); + } + + #[test] + fn a_placeable_patch_held_back_by_its_sibling_says_so() { + // The failure this prevents: reporting a patch as applied because it *could* have been, + // when the feature's all-or-nothing rule meant nothing was written. + let r = resolved(Resolution::Applicable { + rung: Rung::RegionMatch, + hunks: vec![patch::HunkPlacement { + declared_line: 171, + matched_line: 173, + }], + edits: Vec::new(), + }); + let line = render(&feature(), &r, false, &layout(), false); + assert!(line.contains("held back"), "{line}"); + assert!(line.contains("as one unit"), "{line}"); + } + + #[test] + fn declining_does_not_claim_a_loss_that_an_earlier_run_already_prevented() { + // Caught live: `--no-patches` on a host whose install.json shows both features applied + // still announced the loss of both. A decline inspects nothing, so the record is the only + // evidence there is — and ignoring it sends an operator looking for a problem they do not + // have. + let tier = Tier::builtin(); + let applied = |name: &str| FeatureRecord { + feature: name.into(), + rebuild: Rebuild::Core, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: Vec::new(), + companions: Vec::new(), + }; + + let all: Vec = tier.features.iter().map(|f| applied(&f.name)).collect(); + let none: Vec = Vec::new(); + + // The three cases differ only in what the record holds, so assert on the filtering itself + // rather than on captured stdout. + let remaining = |prior: &[FeatureRecord]| -> Vec { + let have = patch::index_records(prior); + tier.features + .iter() + .filter(|f| !have.contains_key(f.name.as_str())) + .map(|f| f.lost.clone()) + .collect() + }; + assert_eq!(remaining(&none).len(), 2, "a fresh host loses both"); + assert!( + remaining(&all).is_empty(), + "a fully patched host loses nothing" + ); + assert_eq!( + remaining(&[applied("vendor-sale")]), + vec!["no in-game moderation audit forwarding".to_string()], + "only the feature that is genuinely absent is named" + ); + } + + #[test] + fn nothing_that_was_not_written_is_reported_as_applied() { + // Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch + // held back by its sibling said "applied" on the line above the one explaining it was not. + let r = || { + resolved(Resolution::Applicable { + rung: Rung::RegionMatch, + hunks: vec![patch::HunkPlacement { + declared_line: 75, + matched_line: 75, + }], + edits: Vec::new(), + }) + }; + let dry = render(&feature(), &r(), true, &layout(), true); + assert!(dry.contains("would be applied at line 75"), "{dry}"); + + let held = render(&feature(), &r(), false, &layout(), false); + assert!(held.contains("could have been placed at line 75"), "{held}"); + assert!(!held.contains("— applied at"), "{held}"); + + let real = render(&feature(), &r(), true, &layout(), false); + assert!(real.contains("applied at line 75"), "{real}"); + } + + #[test] + fn an_applied_patch_reports_the_line_it_matched_not_the_one_it_declared() { + // The declared line is advisory: an insertion above the region shifts it. Printing the + // declared number would send an operator to the wrong place in their own file. + let r = resolved(Resolution::Applicable { + rung: Rung::RegionMatch, + hunks: vec![patch::HunkPlacement { + declared_line: 171, + matched_line: 1180, + }], + edits: Vec::new(), + }); + let line = render(&feature(), &r, true, &layout(), false); + assert!(line.contains("applied at line 1180"), "{line}"); + assert!(!line.contains("171"), "{line}"); + assert!( + line.contains("file modified, patched region stock"), + "{line}" + ); + } + + #[test] + fn a_rung_zero_rerun_keeps_the_rung_that_first_applied_it() { + // Idempotence: re-deriving would answer `already-present` for something that landed as + // `region-match`, rewriting install.json on the second run of an identical install. + let first = record_for( + &feature(), + &[resolved(Resolution::Applicable { + rung: Rung::RegionMatch, + hunks: vec![patch::HunkPlacement { + declared_line: 171, + matched_line: 173, + }], + edits: Vec::new(), + })], + &root(), + true, + None, + ); + assert_eq!(first.patches[0].rung, "region-match"); + + let second = record_for( + &feature(), + &[resolved(Resolution::AlreadyPresent { + hunks: vec![patch::HunkPlacement { + declared_line: 171, + matched_line: 173, + }], + })], + &root(), + true, + Some(&first), + ); + assert_eq!( + second, first, + "a second run must record exactly the same thing" + ); + } + + #[test] + fn a_hand_patched_tree_with_no_prior_record_is_recorded_as_already_present() { + // The other half: someone applied the hunks by hand per INSTALL.md Appendix A2, then ran + // the installer. There is nothing to preserve, so the state it finds is what it records. + let record = record_for( + &feature(), + &[resolved(Resolution::AlreadyPresent { + hunks: vec![patch::HunkPlacement { + declared_line: 171, + matched_line: 171, + }], + })], + &root(), + true, + None, + ); + assert_eq!(record.patches[0].rung, "already-present"); + assert!(!record.unsupported_servuo); + } + + #[test] + fn an_unsupported_tree_labels_the_record_it_writes() { + // The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able + // to see it from install.json without being told. + let mut root = root(); + root.version = Some("58.1".into()); + let record = record_for( + &feature(), + &[resolved(Resolution::Applicable { + rung: Rung::RegionMatch, + hunks: Vec::new(), + edits: Vec::new(), + })], + &root, + false, + None, + ); + assert!(record.unsupported_servuo); + assert_eq!(record.servuo_version.as_deref(), Some("58.1")); + } + + #[test] + fn the_core_rebuild_warning_names_only_files_this_run_wrote() { + // Caught on a live tree whose vendor-sale patches were already applied by hand: the run + // wrote nothing and still demanded a core rebuild, listing a Scripts file as core. On a + // re-run that warning would recur forever, which is how a real one stops being read. + let already = [resolved(Resolution::AlreadyPresent { hunks: Vec::new() })]; + let written: Vec = already + .iter() + .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) + .map(|r| r.target.clone()) + .collect(); + assert!( + written.is_empty(), + "nothing was written, so nothing to rebuild" + ); + + let fresh = [resolved(Resolution::Applicable { + rung: Rung::StockHash, + hunks: Vec::new(), + edits: Vec::new(), + })]; + let written: Vec = fresh + .iter() + .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) + .map(|r| r.target.clone()) + .collect(); + assert_eq!(written, vec!["Server/EventSink.cs".to_string()]); + } + + #[test] + fn patches_alone_is_not_consent_on_an_unsupported_tree() { + let mut cli = Cli { + patches: PatchChoice::Yes, + ..Cli::default() + }; + assert!(matches!( + consent(&cli, &root(), false, &Tier::builtin()).unwrap(), + Consent::RefusedUnsupported + )); + + // ...and the separate flag is. + cli.patches_unsupported_servuo = true; + assert!(matches!( + consent(&cli, &root(), false, &Tier::builtin()).unwrap(), + Consent::Yes + )); + + // On a supported tree the extra flag is not needed and is simply ignored. + let plain = Cli { + patches: PatchChoice::Yes, + ..Cli::default() + }; + assert!(matches!( + consent(&plain, &root(), true, &Tier::builtin()).unwrap(), + Consent::Yes + )); + } + + #[test] + fn no_patches_declines_without_asking_anything() { + let cli = Cli { + patches: PatchChoice::No, + ..Cli::default() + }; + // False for `supported` too: an explicit decline is never escalated into a prompt. + assert!(matches!( + consent(&cli, &root(), false, &Tier::builtin()).unwrap(), + Consent::No(_) + )); + } + + #[test] + fn an_unattended_run_that_did_not_ask_for_the_tier_takes_the_no_default() { + // --yes means "take the default answer", and the default is no on every tree (INSTALL.md + // §2). An unattended install must not start editing stock files because nobody objected. + let cli = Cli { + patches: PatchChoice::Ask, + assume_yes: true, + ..Cli::default() + }; + assert!(matches!( + consent(&cli, &root(), true, &Tier::builtin()).unwrap(), + Consent::No(_) + )); + } +} diff --git a/src/util.rs b/src/util.rs index f0e994d..abc3ccc 100644 --- a/src/util.rs +++ b/src/util.rs @@ -23,9 +23,11 @@ pub fn hex(bytes: &[u8]) -> String { out } -/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a -/// straight-line hash of the same bytes; the run itself only ever hashes files and streams. -#[cfg(test)] +/// Hashes a buffer. +/// +/// The run hashes files and streams almost everywhere, since they are large. The exception is the +/// patch tier, which already holds each `.patch` in memory to parse it and would otherwise re-read +/// from disk purely to hash a few kilobytes it is looking at. pub fn sha256_bytes(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -52,6 +54,25 @@ pub fn sha256_file(path: &Path) -> Result { Ok(hex(&hasher.finalize())) } +/// The git object name of a buffer treated as a blob: `sha1("blob " + len + "\0" + content)`. +/// +/// This is what `git hash-object` prints and what a patch's `index ..` line records, so +/// reproducing it is how the patch tier answers rung 1 — "is this whole file still the one the +/// patch was written against?" (PLAN.md §2.2.1). Computed here rather than by shelling out, because +/// the entire reason the plugin ships as a release tarball is that a shard host has no git on it +/// (§1). +/// +/// The bytes are hashed exactly as they sit on disk. That matters: the three files this tier edits +/// are CRLF, and the recorded hashes were taken from those CRLF bytes, so any normalization here +/// would make every rung-1 check miss. +pub fn git_blob_hash(content: &[u8]) -> String { + use sha1::{Digest as _, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(format!("blob {}\0", content.len()).as_bytes()); + hasher.update(content); + hex(&hasher.finalize()) +} + /// A [`Write`] that hashes everything passing through it. /// /// Downloads are verified *while* being written rather than by re-reading the finished file: it @@ -247,6 +268,25 @@ mod tests { assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob)); } + #[test] + fn blob_hashing_matches_git_hash_object() { + // These are the values `git hash-object` prints, and the same ones a patch's `index` line + // carries. If this drifts, rung 1 silently stops recognising a stock file and every patch + // falls through to the region match — which still works, and would hide the bug for a long + // time. + assert_eq!( + git_blob_hash(b""), + "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + ); + assert_eq!( + git_blob_hash(b"hello\n"), + "ce013625030ba8dba906f756967f9e9ca394464a" + ); + // CRLF is hashed as it sits on disk — the ServUO files this is used on are all CRLF, and + // normalizing here would make every rung-1 check miss. + assert_ne!(git_blob_hash(b"a\r\n"), git_blob_hash(b"a\n")); + } + #[test] fn the_hashing_writer_sees_what_was_written() { let mut w = HashingWriter::new(Vec::new()); diff --git a/tests/fixtures/commandlogging-event.patch b/tests/fixtures/commandlogging-event.patch new file mode 100644 index 0000000..37d5f3f --- /dev/null +++ b/tests/fixtures/commandlogging-event.patch @@ -0,0 +1,33 @@ +--- a/Scripts/Commands/Logging.cs ++++ b/Scripts/Commands/Logging.cs +@@ -75,16 +75,27 @@ + return o; + } + ++ /// ++ /// Raised for every staff command log line — even when file logging is disabled — so an ++ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to ++ /// forward moderation actions (ban/kick, with the resolved target) to the website. ++ /// ++ public static event Action OnWrite; ++ + public static void WriteLine(Mobile from, string format, params object[] args) + { +- if (!m_Enabled) +- return; +- + WriteLine(from, String.Format(format, args)); + } + + public static void WriteLine(Mobile from, string text) + { ++ var onWrite = OnWrite; ++ if (onWrite != null) ++ { ++ try { onWrite(from, text); } ++ catch { } ++ } ++ + if (!m_Enabled) + return; + diff --git a/tests/fixtures/patch_tier.json b/tests/fixtures/patch_tier.json new file mode 100644 index 0000000..662c8f6 --- /dev/null +++ b/tests/fixtures/patch_tier.json @@ -0,0 +1,47 @@ +{ + "features": [ + { + "name": "vendor-sale", + "summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission", + "lost": "no vendor.sale events", + "rebuild": "core", + "patches": [ + { + "name": "playervendor-sale-eventsink", + "file": "patches/playervendor-sale-eventsink.patch", + "target": "Server/EventSink.cs" + }, + { + "name": "playervendor-sale-gump", + "file": "patches/playervendor-sale-gump.patch", + "target": "Scripts/Gumps/PlayerVendorGumps.cs" + } + ], + "companions": [ + { + "file": "patches/BridgeVendorSale.cs", + "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" + } + ] + }, + { + "name": "moderation-audit", + "summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit", + "lost": "no in-game moderation audit forwarding", + "rebuild": "scripts", + "patches": [ + { + "name": "commandlogging-event", + "file": "patches/commandlogging-event.patch", + "target": "Scripts/Commands/Logging.cs" + } + ], + "companions": [ + { + "file": "patches/BridgeModerationAudit.cs", + "install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs" + } + ] + } + ] +} diff --git a/tests/fixtures/playervendor-sale-eventsink.patch b/tests/fixtures/playervendor-sale-eventsink.patch new file mode 100644 index 0000000..d8714c7 --- /dev/null +++ b/tests/fixtures/playervendor-sale-eventsink.patch @@ -0,0 +1,66 @@ +diff --git a/Server/EventSink.cs b/Server/EventSink.cs +index d30788f..1da2667 100644 +--- a/Server/EventSink.cs ++++ b/Server/EventSink.cs +@@ -171,6 +171,8 @@ namespace Server + + public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e); + ++ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e); ++ + public delegate void CorpseLootEventHandler(CorpseLootEventArgs e); + + public delegate void RepairItemEventHandler(RepairItemEventArgs e); +@@ -1521,6 +1523,29 @@ namespace Server + } + } + ++ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in ++ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission ++ // are all in scope -- the data the bridge's cheat-detection feed needs. ++ public class PlayerVendorSaleEventArgs : EventArgs ++ { ++ public Mobile Buyer { get; set; } ++ public Mobile Vendor { get; set; } ++ public Mobile Owner { get; set; } ++ public Item Item { get; set; } ++ public int Price { get; set; } ++ public int Commission { get; set; } ++ ++ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission) ++ { ++ Buyer = buyer; ++ Vendor = vendor; ++ Owner = owner; ++ Item = item; ++ Price = price; ++ Commission = commission; ++ } ++ } ++ + public class CorpseLootEventArgs : EventArgs + { + public Mobile Mobile { get; set; } +@@ -1771,6 +1796,7 @@ namespace Server + public static event TameCreatureEventHandler TameCreature; + public static event ValidVendorPurchaseEventHandler ValidVendorPurchase; + public static event ValidVendorSellEventHandler ValidVendorSell; ++ public static event PlayerVendorSaleEventHandler PlayerVendorSale; + public static event CorpseLootEventHandler CorpseLoot; + public static event RepairItemEventHandler RepairItem; + public static event AlterItemEventHandler AlterItem; +@@ -2416,6 +2442,14 @@ namespace Server + } + } + ++ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e) ++ { ++ if (PlayerVendorSale != null) ++ { ++ PlayerVendorSale(e); ++ } ++ } ++ + public static void InvokeCorpseLoot(CorpseLootEventArgs e) + { + if (CorpseLoot != null) diff --git a/tests/fixtures/playervendor-sale-gump.patch b/tests/fixtures/playervendor-sale-gump.patch new file mode 100644 index 0000000..1eedccd --- /dev/null +++ b/tests/fixtures/playervendor-sale-gump.patch @@ -0,0 +1,15 @@ +diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs +index 049aae6..f1b30d2 100644 +--- a/Scripts/Gumps/PlayerVendorGumps.cs ++++ b/Scripts/Gumps/PlayerVendorGumps.cs +@@ -95,6 +95,10 @@ namespace Server.Gumps + + m_Vendor.HoldGold += m_VI.Price - commission; + ++ // uo-link: the only committed-sale hook for player vendors (no EventSink exists). ++ EventSink.InvokePlayerVendorSale( ++ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission)); ++ + from.SendLocalizedMessage(503201); // You take the item. + } + } diff --git a/tests/real_patches.rs b/tests/real_patches.rs new file mode 100644 index 0000000..e0ece08 --- /dev/null +++ b/tests/real_patches.rs @@ -0,0 +1,317 @@ +//! The rung ladder against the patches this tier actually ships. +//! +//! `src/patch.rs` proves the engine's rules on synthetic diffs, which is the right place to make a +//! rule fail on purpose. This file proves the same engine handles the three real ones — because +//! every property that matters here is a property of *those* files rather than of unified diffs in +//! general: +//! +//! - `commandlogging-event.patch` has no `diff --git` and no `index` line, so rung 1 is +//! structurally unavailable for it and it must still apply through rung 2. +//! - `playervendor-sale-eventsink.patch` carries four hunks against one file, so the all-or-nothing +//! rule, the descending-offset splice and the overlap check all get exercised at once. +//! - Every one of the three is CRLF in a Windows checkout and LF in the tarball CI builds, and both +//! spellings have to behave identically. +//! +//! The fixtures are copies of `servuo-plugins/patches/*.patch`. The *targets* are synthesized +//! rather than vendored: the real ones are ServUO's own sources, and reproducing the region a hunk +//! expects — surrounded by filler that is deliberately not ServUO — is a stricter test of a +//! content match than pasting in 2,600 lines that happen to contain it. + +use rgdeploy::diff; +use rgdeploy::patch::{self, Refusal, Resolution, Rung}; +use rgdeploy::util::git_blob_hash; + +const PATCHES: &[(&str, &str)] = &[ + ("commandlogging-event.patch", "Scripts/Commands/Logging.cs"), + ("playervendor-sale-eventsink.patch", "Server/EventSink.cs"), + ( + "playervendor-sale-gump.patch", + "Scripts/Gumps/PlayerVendorGumps.cs", + ), +]; + +fn fixture(name: &str) -> Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name); + std::fs::read(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +fn parse(name: &str) -> diff::FilePatch { + diff::parse(&fixture(name)) + .unwrap_or_else(|e| panic!("{name} did not parse: {e}")) + .single_file() + .unwrap_or_else(|e| panic!("{name} is not single-target: {e}")) + .clone() +} + +/// A stand-in for the stock ServUO file: each hunk's pre-image, in order, separated by filler that +/// could not be mistaken for context. +fn synthesize_target(file: &diff::FilePatch, eol: &str) -> Vec { + let mut out = String::new(); + for (i, hunk) in file.hunks.iter().enumerate() { + for f in 0..12 { + out.push_str(&format!("// unrelated shard code {i}/{f}{eol}")); + } + for line in hunk.pre_image() { + out.push_str(&String::from_utf8_lossy(line)); + out.push_str(eol); + } + } + out.push_str(&format!("// end of file{eol}")); + out.into_bytes() +} + +fn edits_of(resolution: &Resolution) -> &[patch::Edit] { + match resolution { + Resolution::Applicable { edits, .. } => edits, + other => panic!("expected an applicable resolution, got {other:?}"), + } +} + +#[test] +fn every_shipped_patch_parses_and_names_its_declared_target() { + for (name, target) in PATCHES { + let file = parse(name); + assert_eq!(&file.path, target, "{name}"); + assert!(!file.hunks.is_empty(), "{name}"); + assert!( + file.hunks.iter().any(|h| !h.is_noop()), + "{name} changes nothing" + ); + } +} + +#[test] +fn a_patch_without_an_index_line_still_applies_through_rung_two() { + // commandlogging-event.patch is a plain ---/+++ diff. Rung 1 cannot be reached for it at all, + // which must be a fact the tier reports rather than a reason to skip the patch. + let file = parse("commandlogging-event.patch"); + assert_eq!(file.pre_blob, None, "this fixture has no index line"); + + let target = synthesize_target(&file, "\r\n"); + let resolution = patch::resolve(&file, &target); + assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); + + let patched = patch::apply(&target, edits_of(&resolution)); + let text = String::from_utf8(patched).unwrap(); + assert!( + text.contains("public static event Action OnWrite;"), + "{text}" + ); + // The `m_Enabled` early return is deleted from the two-argument overload — a removal, not just + // an insertion, so the splice is doing more than appending. + assert_eq!(text.matches("if (!m_Enabled)").count(), 1, "{text}"); +} + +#[test] +fn the_four_hunk_patch_applies_all_of_them_at_the_right_offsets() { + // EventSink.cs is the one with several hunks in one file: the descending-offset splice, the + // overlap check and the all-or-nothing rule are all exercised together here. + let file = parse("playervendor-sale-eventsink.patch"); + assert_eq!(file.hunks.len(), 4); + + let target = synthesize_target(&file, "\r\n"); + let resolution = patch::resolve(&file, &target); + assert_eq!(resolution.rung(), Some(Rung::RegionMatch)); + assert_eq!(resolution.placements().len(), 4); + + // Every hunk landed somewhere different, and in the order the diff declares them. + let lines: Vec = resolution + .placements() + .iter() + .map(|p| p.matched_line) + .collect(); + assert!(lines.windows(2).all(|w| w[0] < w[1]), "{lines:?}"); + + let text = String::from_utf8(patch::apply(&target, edits_of(&resolution))).unwrap(); + for expected in [ + "public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);", + "public class PlayerVendorSaleEventArgs : EventArgs", + "public static event PlayerVendorSaleEventHandler PlayerVendorSale;", + "public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)", + ] { + assert!(text.contains(expected), "missing: {expected}"); + } +} + +#[test] +fn a_stock_target_reaches_rung_one() { + // The synthesized file is not ServUO's, so its blob hash is not the one the diff records. Feed + // the diff the hash of the file it is about to be resolved against, which is exactly the + // situation on a genuinely stock tree. + for (name, _) in PATCHES { + let file = parse(name); + let target = synthesize_target(&file, "\r\n"); + let stated = diff::FilePatch { + pre_blob: Some(git_blob_hash(&target)[..7].to_string()), + ..file + }; + assert_eq!( + patch::resolve(&stated, &target).rung(), + Some(Rung::StockHash), + "{name}" + ); + } +} + +#[test] +fn applying_twice_is_a_no_op_for_every_shipped_patch() { + // The idempotence promise: `install` is documented as safe to re-run, and the tier is the part + // of it that edits files the operator owns. + for (name, _) in PATCHES { + let file = parse(name); + let target = synthesize_target(&file, "\r\n"); + + let first = patch::apply(&target, edits_of(&patch::resolve(&file, &target))); + let second = patch::resolve(&file, &first); + assert_eq!(second.rung(), Some(Rung::AlreadyPresent), "{name}"); + assert!( + matches!(second, Resolution::AlreadyPresent { .. }), + "{name} would be applied a second time" + ); + } +} + +#[test] +fn an_edit_inside_a_patched_region_is_refused_for_every_shipped_patch() { + // Rung 3 is the outcome most real shards will see on at least one patch, so it must be the one + // that never writes. The edit is placed on the first line the hunk actually removes or keeps. + for (name, _) in PATCHES { + let file = parse(name); + let target = synthesize_target(&file, "\n"); + + let anchor = file.hunks[0] + .pre_image() + .iter() + .map(|l| String::from_utf8_lossy(l).to_string()) + .find(|l| l.trim().len() > 12) + .expect("a substantial context line to vandalize"); + let vandalized = String::from_utf8_lossy(&target) + .replacen(&anchor, &format!("{anchor} /* operator's own change */"), 1) + .into_bytes(); + assert_ne!(vandalized, target, "{name}: the fixture was not modified"); + + match patch::resolve(&file, &vandalized) { + Resolution::Refused(Refusal::RegionModified { .. }) => {} + other => panic!("{name}: expected a refusal, got {other:?}"), + } + } +} + +#[test] +fn line_endings_do_not_change_the_verdict_or_the_result() { + // A patch is CRLF in a Windows checkout and LF in the tarball, and a target may be either. The + // rung reached must not depend on that, and the patched file must keep the ending it had. + for (name, _) in PATCHES { + let raw = fixture(name); + let as_lf = String::from_utf8_lossy(&raw) + .replace("\r\n", "\n") + .into_bytes(); + let as_crlf = String::from_utf8_lossy(&as_lf) + .replace('\n', "\r\n") + .into_bytes(); + + let from_lf = diff::parse(&as_lf).unwrap().single_file().unwrap().clone(); + let from_crlf = diff::parse(&as_crlf) + .unwrap() + .single_file() + .unwrap() + .clone(); + assert_eq!( + from_lf, from_crlf, + "{name}: the two spellings parsed differently" + ); + + for eol in ["\n", "\r\n"] { + let target = synthesize_target(&from_lf, eol); + let resolution = patch::resolve(&from_lf, &target); + assert_eq!(resolution.rung(), Some(Rung::RegionMatch), "{name} {eol:?}"); + + let patched = patch::apply(&target, edits_of(&resolution)); + let newlines = patched.iter().filter(|b| **b == b'\n').count(); + let crlfs = patched.windows(2).filter(|w| w == b"\r\n").count(); + if eol == "\r\n" { + assert_eq!(crlfs, newlines, "{name}: LF islands in a CRLF file"); + } else { + assert_eq!(crlfs, 0, "{name}: CR appeared in an LF file"); + } + } + } +} + +#[test] +fn the_tier_the_release_workflow_emits_is_the_one_this_installer_reads() { + // `patch_tier.json` is the literal output of the jq filter in + // servuo-plugins/.gitea/workflows/release.yml, run over that repo's patches/tier.json. It is + // checked in so the two repos cannot drift apart quietly: a renamed field there would fail + // here rather than producing an empty tier on an operator's shard, where the only symptom is + // a patch tier that is never offered. + let json = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("patch_tier.json"), + ) + .unwrap(); + + // Deserialized through Manifest, not through Tier, because the field's name and its + // `Option`-ness are half of the contract. + let manifest: serde_json::Value = serde_json::json!({ + "component": "servuo-plugins-overlay", + "version": "0.2.0", + "commit": "0000000", + "repo": "RunicGateway/servuo-plugins", + "protocol": 3, + "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, + "patch_tier": serde_json::from_str::(&json).unwrap(), + "files": {} + }); + let manifest: rgdeploy::overlay::Manifest = serde_json::from_value(manifest).unwrap(); + let declared = manifest.patch_tier.expect("patch_tier must deserialize"); + + // The declared tier and the built-in fallback have to describe the same release, or an + // installer would behave differently against v0.1.1 and v0.2.0 of the same overlay. + assert_eq!(declared, rgdeploy::patch::Tier::builtin()); + + // ...and everything it names must be resolvable against the patches that ship. + for feature in &declared.features { + for declared_patch in &feature.patches { + let file = parse(&declared_patch.file.replace("patches/", "")); + assert_eq!(file.path, declared_patch.target, "{}", declared_patch.name); + } + for companion in &feature.companions { + assert!(companion.file.starts_with("patches/"), "{companion:?}"); + } + } +} + +#[test] +fn the_builtin_tier_names_exactly_the_patches_that_ship() { + // The fallback for overlay releases older than `patch_tier` in the manifest. It has to describe + // the release it stands in for, and the fixtures here are that release's patches. + let tier = rgdeploy::patch::Tier::resolve(None); + let mut declared: Vec = tier + .features + .iter() + .flat_map(|f| f.patches.iter()) + .map(|p| p.file.replace("patches/", "")) + .collect(); + declared.sort(); + + let mut shipped: Vec = PATCHES.iter().map(|(n, _)| n.to_string()).collect(); + shipped.sort(); + assert_eq!(declared, shipped); + + for feature in &tier.features { + for declared in &feature.patches { + let file = parse(&declared.file.replace("patches/", "")); + assert_eq!( + file.path, declared.target, + "{}: the fallback declares a target the diff does not edit", + declared.name + ); + } + } +} From 02c5ad9839ed5e516a4c2ca463515eb8423ee1d7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 20:01:37 -0500 Subject: [PATCH 05/13] fix(installer): satisfy clippy's unnecessary_sort_by on the CI toolchain The two descending sorts in the applier used an explicit comparator. CI runs clippy 1.97, where `unnecessary_sort_by` flags that and `-D warnings` turns it into a build failure; the local toolchain here is 1.94, which does not have the lint. `sort_by_key` with `Reverse` says the same thing. Co-Authored-By: Claude --- src/patch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/patch.rs b/src/patch.rs index a2ffec5..4d1c96c 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -259,7 +259,7 @@ pub fn resolve(file: &FilePatch, content: &[u8]) -> Resolution { } // Highest offset first, so applying one edit never invalidates the next one's range. - edits.sort_by(|a, b| b.start.cmp(&a.start)); + edits.sort_by_key(|e| std::cmp::Reverse(e.start)); Resolution::Applicable { rung: if stock { Rung::StockHash @@ -369,7 +369,7 @@ pub fn apply(content: &[u8], edits: &[Edit]) -> Vec { // The edits arrive highest-offset-first from `resolve`, so each splice leaves every remaining // range valid. Re-sorting here rather than trusting the caller keeps that a local property. let mut ordered: Vec<&Edit> = edits.iter().collect(); - ordered.sort_by(|a, b| b.start.cmp(&a.start)); + ordered.sort_by_key(|e| std::cmp::Reverse(e.start)); for edit in ordered { out.splice(edit.start..edit.end, edit.replacement.iter().copied()); } From 265911a58f98f5e6ee451080916db1aee450e83e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 20:02:52 -0500 Subject: [PATCH 06/13] docs(installer): drop phase references that are now this build's behaviour 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 --- src/install.rs | 2 +- src/paths.rs | 3 ++- src/record.rs | 2 +- src/servuo.rs | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/install.rs b/src/install.rs index 2adde12..db6b708 100644 --- a/src/install.rs +++ b/src/install.rs @@ -619,7 +619,7 @@ fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallR 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 (Phase 3) and any field a + // 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 { diff --git a/src/paths.rs b/src/paths.rs index edb9ec7..dc97445 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -145,7 +145,8 @@ mod tests { #[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 joins them in Phase 3. + // 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())); diff --git a/src/record.rs b/src/record.rs index 3bf92c4..b7bc42b 100644 --- a/src/record.rs +++ b/src/record.rs @@ -44,7 +44,7 @@ pub struct InstallRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// The patch tier: one entry per feature actually in place, with the rung that applied each of - /// its patches (Phase 3). + /// its patches. /// /// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer /// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`]. diff --git a/src/servuo.rs b/src/servuo.rs index ba5e2a8..1b7ea64 100644 --- a/src/servuo.rs +++ b/src/servuo.rs @@ -23,7 +23,7 @@ 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. + /// tier takes its unsupported path. pub version: Option, } @@ -312,7 +312,7 @@ mod tests { #[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. + // 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); From 80b1c0da24edbc59708d7bc60a7d14461fa533cf Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 02:57:57 -0500 Subject: [PATCH 07/13] =?UTF-8?q?feat(installer):=20implement=20Phase=204?= =?UTF-8?q?=20=E2=80=94=20doctor,=20update=20and=20uninstall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the command surface INSTALL.md §2 published before the binary existed. With this, `edge` cuts a binary that does everything that guide describes. doctor (src/doctor.rs) Reads only. Every row is answered by asking the thing itself — the installed binary (--version, --print-config), the service manager, and the sidecar's /health — because the record says what `install` did, which is a different question from what is true now. --print-config is run ONLY when the config already exists: that flag provisions, and a diagnosis must not create the state it reports on. It is also run under the environment the service pins (UOLINK_DB_PATH), so the config and database it names are the ones the service opens, not the ones the binary would pick on its own. Exit 1 when any row failed, so a monitoring script can read it; a ⚠ never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have not started it" and "it is running and the bridge is dead" are different problems and only the second is broken. Offline is a ⚠ too: a shard host with no route to Gitea is a supported way to run this. The patch row re-resolves each recorded patch against the tree from the cached .patch, so a core upgrade or a restored backup that silently removed the tier's edits is caught — nothing else here would notice. update (src/update.rs, install.rs::Mode) The same pipeline as install, not a second one: PLAN.md describes it as "re-resolve the bundle, then move both components to it", which is what an install over an existing deployment already does. Writing it twice would give the sync rules and the protocol cross-checks two places to disagree. What differs is small and lives in Mode — a prior record is required, the tree comes from that record rather than detection, the patch tier's scope narrows, and the close is a diff instead of a handoff. The token is not reprinted: it has not changed and the website has it. A changed protocol number IS called out, because a stale value in Admin → Shard is answered with 409 and looks like the shard going offline. Tier scope: features an earlier run recorded are re-resolved without asking again (the record is the evidence of consent, including on an unsupported ServUO); anything new the release offers is named but not applied without --patches. A shard that declined stays declined. uninstall (src/uninstall.rs, service::remove) Removes the binary, the service and install.json; prints the overlay files and the exact hunks, rendered from the cached patches with the rung each landed at. Files edited since deployment are flagged so nobody deletes their own work blind. The report is also written to a file in the working directory — it is the only thing still needed after the command exits, and it arrives at the end of the longest output this tool produces. Two deviations from PLAN.md §5, both deliberate: - The cached patch set and patches/originals/ SURVIVE. That table put them under "removed", but the report tells the operator to diff against those originals — advice the same command would have made impossible to follow. --purge removes them, with the config and the database. - --yes means yes here, not "take the default". The prompt defaults to no (destructive), but the operator typed the verb; reading --yes as "no" would leave an unattended uninstall unable to express itself, and a script that appears to succeed while removing nothing is the worse failure. Exit 1 if a step could not be carried out — everything else still was. Verified on this machine against a scratch tree built from the real ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay file, an edited one and a reverted patch (all three found, exit 1), a --verify update that wrote nothing, a real update that repaired all three and left install.json byte-identical, uninstall with and without --purge, a second uninstall, and doctor/update on a host with no record. Linux fmt/clippy/tests run in Docker as well as the Windows host. Co-Authored-By: Claude --- src/cli.rs | 3 + src/doctor.rs | 884 +++++++++++++++++++++++++++++++++++++++++++++++ src/install.rs | 102 +++++- src/lib.rs | 98 +++--- src/net.rs | 27 +- src/service.rs | 216 ++++++++++++ src/tier.rs | 168 ++++++++- src/uninstall.rs | 640 ++++++++++++++++++++++++++++++++++ src/update.rs | 218 ++++++++++++ 9 files changed, 2271 insertions(+), 85 deletions(-) create mode 100644 src/doctor.rs create mode 100644 src/uninstall.rs create mode 100644 src/update.rs diff --git a/src/cli.rs b/src/cli.rs index d88ff08..dac8608 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -135,6 +135,9 @@ Options: --site-url install. Your site's base URL, for the Admin → Shard link. --yes Assume the default answer to every prompt. + On uninstall it means yes: that prompt + defaults to no, and typing `uninstall + --yes` is not an accident. --purge uninstall. Also delete sidecar.toml and uo-link.db, which are otherwise kept. -V, --version Print the installer version and exit. diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..5500765 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,884 @@ +//! The `doctor` command — diagnose an existing deployment end to end. +//! +//! PLAN.md §5 Phase 4 calls this "the command that makes the whole thing supportable", and the row +//! that carries the phase is the last one: **has a shard actually dialed in?** Everything else can +//! be true — files copied, service running, hashes matching — while the bridge does nothing at all, +//! because ServUO shells out to `dotnet build`, prints the output, ignores the exit code and reloads +//! the previous `Scripts.dll` (§2.1). A clean boot is not evidence. `plugin_connected` is. +//! +//! Three rules shape this module: +//! +//! - **It writes nothing, anywhere.** Not to the ServUO tree, not to `install.json`, not to the +//! sidecar's config. That is why `--print-config` is run only when the config file already +//! exists: that flag *provisions* (it writes the file and mints a token when absent), so calling +//! it on a host that has none would have `doctor` create the very state it is reporting on. +//! - **It asks the thing itself, not the record.** The installed binary answers `--version` and +//! `--print-config`; the service manager answers `is-active`; the sidecar answers `/health`. The +//! record says what `install` *did*, which is a different question from what is true now — and +//! the gap between those two is the whole reason to run this. +//! - **A missing answer is a row, not an exception.** A host with no route to Gitea, a sidecar that +//! is down, a config this process cannot read: each degrades to one honest line and the report +//! still prints. The exit code is what a monitoring script reads — `1` if any row failed — and it +//! is deliberately not raised by a `⚠`, which means "worth knowing", not "broken". +//! +//! The token is never printed here. `--print-config` returns it (it is one document), and this +//! module reads the paths, the bind and the protocol out of that document and drops the rest. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Result; +use serde::Deserialize; + +use crate::cli::Cli; +use crate::record::{InstallRecord, LinkRecord}; +use crate::servuo::ServUoRoot; +use crate::{bundle, net, patch, paths, servuo, sidecar, ui}; + +/// Both network calls give up quickly. Every row here is context around local state, so a host with +/// no route out must produce its report seconds later rather than appear to hang. +const BUNDLE_TIMEOUT: Duration = Duration::from_secs(15); +const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// The verdict on one row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mark { + Ok, + Warn, + Fail, +} + +impl Mark { + fn glyph(self) -> &'static str { + match self { + Self::Ok => "✓", + Self::Warn => "⚠", + Self::Fail => "✗", + } + } +} + +/// One line of the report, plus any detail lines that belong underneath it. +#[derive(Debug, Clone)] +pub struct Row { + pub mark: Mark, + pub label: String, + pub detail: String, + pub notes: Vec, +} + +impl Row { + fn new(mark: Mark, label: &str, detail: impl Into) -> Self { + Self { + mark, + label: label.to_string(), + detail: detail.into(), + notes: Vec::new(), + } + } + + fn ok(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Ok, label, detail) + } + + fn warn(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Warn, label, detail) + } + + fn fail(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Fail, label, detail) + } + + fn note(mut self, note: impl Into) -> Self { + self.notes.push(note.into()); + self + } + + fn notes_from(mut self, notes: impl IntoIterator) -> Self { + self.notes.extend(notes); + self + } +} + +/// Runs every check and prints the report. The `i32` is the process exit code. +pub fn run(cli: &Cli) -> Result { + let layout = paths::layout(); + let record_path = layout.install_record(); + + println!( + "\nRunic Gateway installer {} — doctor", + env!("CARGO_PKG_VERSION") + ); + + let Some(record) = InstallRecord::load(&record_path)? else { + // Not an error in the `anyhow` sense — the command ran fine and the answer is "nothing is + // installed here". Exit 1 all the same, because a monitoring script asking after a + // deployment on this host has had its question answered in the negative. + println!(); + ui::warn(&format!( + "No deployment is recorded on this host.\n \ + Looked for {}\n \ + Run `install` first. If you installed with {} set, set it again for this run.", + record_path.display(), + paths::STATE_DIR_ENV + )); + return Ok(1); + }; + + let mut rows = vec![Row::ok( + "Install record", + format!( + "{} (bundle {}, installer {}, {})", + record_path.display(), + record.bundle.tag, + record.installer.version, + record.updated + ), + )]; + + // ── ServUO and the overlay ─────────────────────────────────────────────── + let root = open_root(cli, &record); + rows.push(servuo_row(&record, root.as_ref())); + rows.push(overlay_row(&record, root.as_ref())); + rows.push(patch_row(&record, root.as_ref(), &layout)); + + // ── The sidecar ────────────────────────────────────────────────────────── + // Asking the installed binary is what makes these rows describe the sidecar that will actually + // answer the website, rather than the one the record believes was installed. + let link = record.link_record(); + let live = link.as_ref().and_then(live_config); + let health = live.as_ref().and_then(|doc| health_of(&doc.web.bind)); + + rows.push(link_row(link.as_ref(), live.as_ref())); + rows.push(service_row(link.as_ref())); + rows.push(reachable_row(live.as_ref(), health.as_ref())); + rows.push(protocol_row(&record, live.as_ref(), health.as_ref())); + rows.push(shard_row(root.as_ref(), health.as_ref())); + + // ── The bundle ─────────────────────────────────────────────────────────── + rows.push(bundle_row(&record)); + + // ── Report ─────────────────────────────────────────────────────────────── + println!(); + for row in &rows { + println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail); + for note in &row.notes { + println!(" {note}"); + } + } + + let failed = rows.iter().filter(|r| r.mark == Mark::Fail).count(); + let warned = rows.iter().filter(|r| r.mark == Mark::Warn).count(); + println!(); + match (failed, warned) { + (0, 0) => println!("Everything checks out."), + (0, w) => println!("{w} thing(s) worth knowing about, nothing broken."), + (f, _) => println!( + "{f} check(s) failed. Start with the first ✗ above; \ + INSTALL.md's Troubleshooting table is keyed to these symptoms." + ), + } + Ok(if failed > 0 { 1 } else { 0 }) +} + +/// The ServUO root to inspect: `--servuo` if given, else the one the record names. +/// +/// [`servuo::open`] rather than `open_stopped`: a running shard is the *expected* state for a +/// diagnosis — it is the only state in which the shard-connected row can be true — and refusing to +/// report on a live host would make this command useless exactly when it is needed. +fn open_root(cli: &Cli, record: &InstallRecord) -> Result { + let path = cli + .servuo + .clone() + .unwrap_or_else(|| record.servuo.path.clone()); + servuo::open(Path::new(&path)) +} + +fn servuo_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row { + let root = match root { + Ok(root) => root, + Err(error) => { + return Row::fail("ServUO found", record.servuo.path.clone()) + .note(error.to_string().replace('\n', " ")) + } + }; + + let row = Row::ok( + "ServUO found", + format!("{} ({})", root.path.display(), root.version_display()), + ); + // A tree that has been upgraded under an install is the single most useful thing this row can + // say: the patch tier was resolved against the version recorded here, not against this one. + match &record.servuo.version { + Some(recorded) if Some(recorded) != root.version.as_ref() => Row::warn( + "ServUO found", + format!("{} ({})", root.path.display(), root.version_display()), + ) + .note(format!( + "this tree was {recorded} when Runic Gateway was installed — re-check the patch tier \ + below" + )), + _ if !root.is_supported_version() => row.note(format!( + "{} is the only supported version; the base overlay is expected to work anyway", + servuo::SUPPORTED_VERSION + )), + _ => row, + } +} + +/// Compares every deployed file against the record. +/// +/// The comparison that matters is *which* hash a file differs from (PLAN.md §7.0): differing from +/// what the installer put there means the operator edited it, while agreeing with the record on a +/// host whose bundle has moved on means the overlay upstream is newer. A file recorded as +/// `kept-operator-modified` is theirs by definition, so a further edit there is not a finding. +fn overlay_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row { + let Some(overlay) = &record.overlay else { + return Row::fail("Overlay in sync", "no overlay recorded in install.json"); + }; + let Ok(root) = root else { + return Row::fail( + "Overlay in sync", + format!("{} files recorded, tree unreadable", overlay.files.len()), + ); + }; + + let mut missing = Vec::new(); + let mut edited = Vec::new(); + let mut operator_owned = 0usize; + + for (rel, file) in &overlay.files { + if file.state == "kept-operator-modified" { + operator_owned += 1; + continue; + } + let path = patch::join(&root.path, rel); + match crate::util::sha256_file(&path) { + Ok(actual) if actual == file.on_disk_sha256 => {} + Ok(_) => edited.push(rel.clone()), + Err(_) => missing.push(rel.clone()), + } + } + + let total = overlay.files.len(); + let owned = if operator_owned > 0 { + format!(", {operator_owned} operator-owned") + } else { + String::new() + }; + + if missing.is_empty() && edited.is_empty() { + return Row::ok( + "Overlay in sync", + format!("{total} files, all hashes match install.json{owned}"), + ); + } + + let mark = if missing.is_empty() { + Mark::Warn + } else { + Mark::Fail + }; + let mut row = Row::new( + mark, + "Overlay in sync", + format!( + "{total} files{owned} — {} missing, {} edited since deployment", + missing.len(), + edited.len() + ), + ) + .notes_from(missing.iter().map(|f| format!("missing: {f}"))) + .notes_from(edited.iter().map(|f| format!("edited: {f}"))); + + if !missing.is_empty() { + row = row.note("run `update` (or `install`) to put the release's copies back"); + } + if !edited.is_empty() { + row = row.note( + "these are code files the overlay owns — an `update` overwrites them without asking", + ); + } + row +} + +/// Reports the patch tier from the record, then checks the tree still agrees with it. +/// +/// The check is not decoration. The tier's whole risk is that its edits sit inside files ServUO +/// itself ships, so a core upgrade, a hand revert, or a restored backup silently removes them — +/// and nothing else in this report would notice. The cached `.patch` (PLAN.md §2.2) is what makes +/// the check possible offline: resolving it against the current file must land on rung 0, because +/// the record says it is already applied. +fn patch_row( + record: &InstallRecord, + root: Result<&ServUoRoot, &anyhow::Error>, + layout: &paths::Layout, +) -> Row { + let records = record.patch_records(); + if records.is_empty() { + return Row::warn("Patch tier", "not applied — this is optional").note( + "the two optional features (vendor.sale events, in-game moderation audit) are \ + unavailable; INSTALL.md §4", + ); + } + + let applied: Vec = records + .iter() + .map(|f| { + let rungs: Vec<&str> = f.patches.iter().map(|p| p.rung.as_str()).collect(); + format!("{} ({})", f.feature, rungs.join(", ")) + }) + .collect(); + + let unsupported: Vec<&str> = records + .iter() + .filter(|f| f.unsupported_servuo) + .map(|f| f.feature.as_str()) + .collect(); + + let mut notes = Vec::new(); + let mut gone = 0usize; + + match root { + Ok(root) => { + for feature in &records { + for applied_patch in &feature.patches { + match verify_applied(layout, &applied_patch.name, &applied_patch.sha256) { + None => notes.push(format!( + "{}: no cached copy of the patch, so it could not be re-checked", + applied_patch.name + )), + Some(parsed) => { + let target = patch::join(&root.path, &applied_patch.target); + let content = std::fs::read(&target).unwrap_or_default(); + let resolution = patch::resolve(&parsed, &content); + if !matches!(resolution, patch::Resolution::AlreadyPresent { .. }) { + gone += 1; + notes.push(format!( + "{} is NO LONGER in {} — the file was replaced, reverted or \ + upgraded since it was applied", + applied_patch.name, applied_patch.target + )); + } + } + } + } + for companion in &feature.companions { + if !patch::join(&root.path, &companion.path).is_file() { + gone += 1; + notes.push(format!("{} is missing from the tree", companion.path)); + } + } + } + } + Err(_) => { + notes.push("the ServUO tree is unreadable, so nothing could be re-checked".into()) + } + } + + if !unsupported.is_empty() { + // The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able + // to see it here, not only in the output of a run they never saw. + notes.push(format!( + "applied on an UNSUPPORTED ServUO ({}): {}", + records + .iter() + .find_map(|f| f.servuo_version.clone()) + .unwrap_or_else(|| "unknown".into()), + unsupported.join(", ") + )); + } + + let detail = format!("{} applied — {}", records.len(), applied.join("; ")); + let mark = if gone > 0 { + Mark::Fail + } else if unsupported.is_empty() { + Mark::Ok + } else { + Mark::Warn + }; + Row::new(mark, "Patch tier", detail).notes_from(notes) +} + +/// Finds the cached `.patch` for a recorded patch and parses it. +/// +/// By name first, which is what the tier writes; then by content hash across the cache, so a +/// release that renames a patch file does not silently turn this check off. +fn verify_applied( + layout: &paths::Layout, + name: &str, + sha256: &str, +) -> Option { + let by_name = layout.patches_dir().join(format!("{name}.patch")); + let candidates: Vec = if by_name.is_file() { + vec![by_name] + } else { + std::fs::read_dir(layout.patches_dir()) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "patch")) + .collect() + }; + + for path in candidates { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if path.file_stem().is_some_and(|s| s == name) + || crate::util::sha256_bytes(&bytes) == sha256 + { + if let Ok(parsed) = crate::diff::parse(&bytes) { + return parsed.single_file().ok().cloned(); + } + } + } + None +} + +fn link_row(link: Option<&LinkRecord>, live: Option<&sidecar::ConfigDoc>) -> Row { + let Some(link) = link else { + return Row::fail("uo-link installed", "no sidecar recorded in install.json") + .note("the overlay alone does not reach a website — see INSTALL.md §2"); + }; + let binary = Path::new(&link.binary.path); + if !binary.is_file() { + return Row::fail( + "uo-link installed", + format!("{} is missing", link.binary.path), + ); + } + + // The version line comes from the binary, the hash decides whether it is the one that was + // installed. A hand-replaced binary that still reports the right version is exactly the case a + // version string alone would call healthy. + let reported = + sidecar::version_line(binary).unwrap_or_else(|| format!("uo-link {}", link.version)); + let row = match crate::util::sha256_file(binary) { + Ok(actual) if actual.eq_ignore_ascii_case(&link.binary.sha256) => { + Row::ok("uo-link installed", reported) + } + Ok(_) => Row::warn("uo-link installed", reported).note(format!( + "{} is not the binary this installer recorded — it was replaced by hand or by another \ + tool", + link.binary.path + )), + Err(error) => Row::warn("uo-link installed", reported) + .note(format!("cannot hash {}: {error}", link.binary.path)), + }; + + // The paths the *binary* resolves, under the same environment the service definition pins (see + // `live_config`) — not the ones the record believes it was told. A sidecar reading a different + // config from the one the installer wrote is a failure mode nothing else here would surface. + match live { + Some(doc) => row.note(format!( + "config {} database {}", + doc.config_path, doc.store.path + )), + None => row.note(format!( + "config {} (not readable by this run) database {}", + link.config_path, link.db_path + )), + } +} + +fn service_row(link: Option<&LinkRecord>) -> Row { + let Some(link) = link else { + return Row::fail("Service", "nothing recorded"); + }; + let Some(service) = &link.service else { + // A deliberate outcome, not a bug: a host with no service manager the installer can drive + // gets the binary, the config, and printed instructions (PLAN.md Phase 2). + return Row::warn("Service", "not registered") + .note("the binary and config are installed but nothing runs them — INSTALL.md §7"); + }; + + let status = crate::service::observe(&service.kind, &service.name); + let account = service + .user + .as_deref() + .map(|u| format!(" as {u}")) + .unwrap_or_default(); + let detail = format!("{} {}{account}", service.name, status.detail); + + if !status.present { + Row::fail("Service", detail).note("re-run `install` to register it again") + } else if !status.running { + Row::fail("Service", detail) + .note("a service that will not stay up usually cannot read its config — INSTALL.md §7") + } else if !status.enabled { + Row::warn("Service", detail).note("it is running but will not come back after a reboot") + } else { + Row::ok("Service", detail) + } +} + +/// Asks the installed binary what it resolves — but only if it has a config to read. +/// +/// Two rules are load-bearing here: +/// +/// - **Only when the config already exists.** `--print-config` provisions: it writes the file and +/// mints a token when there is none. `doctor` must not write, and a diagnosis that created the +/// very state it was asked to report on would be worse than one that said "no config". +/// - **Under the environment the service runs with.** The systemd unit pins `UOLINK_DB_PATH` +/// wherever the database does not land beside the config (`crate::paths`), so a bare +/// `--print-config` would report the path the binary picks *on its own* — `/etc/runicgateway/` +/// rather than `/var/lib/runicgateway/` — and INSTALL.md §7 promises this row names the file the +/// service actually opens. +/// +/// The returned document holds the auth token. Nothing here reads it, and nothing prints it. +fn live_config(link: &LinkRecord) -> Option { + let config = Path::new(&link.config_path); + if !config.is_file() { + return None; + } + let db = Path::new(&link.db_path); + let db_env = (config.parent() != db.parent()).then_some(db); + sidecar::print_config(Path::new(&link.binary.path), config, db_env).ok() +} + +/// The sidecar's `/health`, which needs no auth and is therefore safe to ask for from here. +/// +/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and +/// this check is about whether the process on *this* host is answering. +fn health_of(bind: &str) -> Option { + let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind); + let url = format!("http://127.0.0.1:{port}/health"); + let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?; + serde_json::from_str(&body).ok() +} + +/// The sidecar's `/health` document. Every field is optional so a newer sidecar that drops or +/// renames one still produces a report rather than a parse failure. +#[derive(Debug, Clone, Deserialize)] +pub struct Health { + pub status: Option, + pub protocol: Option, + pub plugin_connected: Option, + pub database: Option, + pub uptime: Option, + pub last_event: Option, +} + +fn reachable_row(live: Option<&sidecar::ConfigDoc>, health: Option<&Health>) -> Row { + let Some(doc) = live else { + return Row::fail("Sidecar reachable", "could not read the sidecar's config").note( + "either the config file is gone or this process cannot read it — it is deliberately \ + readable only by root/Administrator and the service account", + ); + }; + let port = doc + .web + .bind + .rsplit_once(':') + .map(|(_, p)| p.to_string()) + .unwrap_or_else(|| doc.web.bind.clone()); + + match health { + Some(health) => { + let uptime = health + .uptime + .as_deref() + .map(|u| format!(", up {u}")) + .unwrap_or_default(); + let db = health.database.as_deref().unwrap_or("unknown"); + Row::ok( + "Sidecar reachable", + format!( + "127.0.0.1:{port} /health {}{uptime}, database {db}", + health.status.as_deref().unwrap_or("ok") + ), + ) + } + None => Row::fail( + "Sidecar reachable", + format!("nothing answered http://127.0.0.1:{port}/health"), + ) + .note("the binary is installed; this is about whether it is running and listening"), + } +} + +fn protocol_row( + record: &InstallRecord, + live: Option<&sidecar::ConfigDoc>, + health: Option<&Health>, +) -> Row { + let overlay = record.overlay.as_ref().map(|o| o.protocol); + // `/health` first: that is the number the website is answered with. `--print-config` is the + // same value from the same binary and covers a sidecar that is installed but not running. + let sidecar = health.and_then(|h| h.protocol).or(live.map(|d| d.protocol)); + + match (sidecar, overlay) { + (Some(s), Some(o)) if s == o => { + Row::ok("Protocol", format!("sidecar {s} = overlay manifest {o}")) + } + (Some(s), Some(o)) => Row::fail("Protocol", format!("sidecar {s} ≠ overlay manifest {o}")) + .note( + "the sidecar rejects a mismatched website with 409 rather than mis-parsing it; \ + this pair was never checked together — run `update` to move both to one bundle", + ), + (s, o) => Row::warn( + "Protocol", + format!( + "sidecar {}, overlay manifest {}", + s.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()), + o.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()) + ), + ), + } +} + +/// The row the rest of the report exists to make trustworthy. +/// +/// A shard that is not running cannot have dialed in, so that case is a `⚠` with the reason rather +/// than a `✗`: reporting a stopped shard as a failure would train operators to ignore this line, +/// which is the one line worth reading. +fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>) -> Row { + let running = root.ok().and_then(|r| servuo::find_running(&r.path)); + + match (health.and_then(|h| h.plugin_connected), running) { + (Some(true), _) => { + let last = health + .and_then(|h| h.last_event.clone()) + .map(|e| format!(" (last event {e})")) + .unwrap_or_default(); + Row::ok("Shard connected", format!("yes{last}")) + } + (Some(false), Some(shard)) => Row::fail( + "Shard connected", + format!( + "no — the shard is running (pid {}) but has not dialed in", + shard.pid + ), + ) + .note( + "the classic silent failure: ServUO ignores the script build's exit code and reloads \ + the previous Scripts.dll", + ) + .note("run `[bridge status` in game, and check [shard] bind against Config/Bridge.cfg"), + (Some(false), None) => Row::warn( + "Shard connected", + "no — the shard process is not running on this host", + ) + .note("start ServUO and re-run doctor; nothing reaches the website until it dials in"), + (None, _) => Row::warn( + "Shard connected", + "unknown — the sidecar did not answer /health", + ), + } +} + +/// Whether this deployment is still the current bundle. +/// +/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way +/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it +/// would report a working deployment as broken. +fn bundle_row(record: &InstallRecord) -> Row { + let url = bundle::url_for(None); + let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) { + Ok(bundle) => bundle, + Err(error) => { + return Row::warn( + "Bundle", + format!("{} — could not check for a newer one", record.bundle.tag), + ) + .note(error.to_string().replace('\n', " ")) + } + }; + + if current.bundle == record.bundle.tag { + return Row::ok("Bundle", format!("{} — up to date", record.bundle.tag)); + } + + let mut moves = Vec::new(); + if let Some(link) = record.link_record() { + if link.version != current.link.version { + moves.push(format!( + "uo-link {} → {}", + link.version, current.link.version + )); + } + } + if let Some(overlay) = &record.overlay { + if overlay.version != current.overlay.version { + moves.push(format!( + "overlay {} → {}", + overlay.version, current.overlay.version + )); + } + } + let detail = if moves.is_empty() { + format!( + "{} → {} (no component changed)", + record.bundle.tag, current.bundle + ) + } else { + format!( + "{} → {}: {}", + record.bundle.tag, + current.bundle, + moves.join(", ") + ) + }; + Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA}; + use std::collections::BTreeMap; + + fn record() -> 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: "/opt/ServUO".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::new(), + }), + link: None, + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + fn health(protocol: u32, connected: bool) -> Health { + Health { + status: Some("ok".into()), + protocol: Some(protocol), + plugin_connected: Some(connected), + database: Some("ok".into()), + uptime: Some("2m".into()), + last_event: Some("2026-08-05T10:00:00Z".into()), + } + } + + #[test] + fn the_documented_health_document_parses() { + // Copied from INSTALL.md §6 — the shape doctor's last three rows are read from. + let body = r#"{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok", + "uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}"#; + let health: Health = serde_json::from_str(body).unwrap(); + assert_eq!(health.protocol, Some(3)); + assert_eq!(health.plugin_connected, Some(true)); + } + + #[test] + fn a_health_document_missing_fields_still_parses() { + // A newer sidecar dropping or renaming a key must degrade to an unknown row, not to a + // doctor that cannot report at all. + let health: Health = serde_json::from_str(r#"{"status":"ok"}"#).unwrap(); + assert_eq!(health.protocol, None); + assert_eq!(health.plugin_connected, None); + } + + #[test] + fn a_protocol_mismatch_fails_the_row() { + assert_eq!( + protocol_row(&record(), None, Some(&health(3, true))).mark, + Mark::Ok + ); + assert_eq!( + protocol_row(&record(), None, Some(&health(4, true))).mark, + Mark::Fail + ); + // Nothing to compare is not a failure — an unreachable sidecar is already its own ✗ row, + // and reporting the same outage twice buries the one that names the cause. + assert_eq!(protocol_row(&record(), None, None).mark, Mark::Warn); + } + + #[test] + fn a_stopped_shard_is_a_warning_and_a_silent_one_is_a_failure() { + // The distinction the whole row exists for: "you have not started it" and "it is running + // and the bridge is dead" are different problems, and only the second is broken. + let err = anyhow::anyhow!("no tree"); + assert_eq!( + shard_row(Err(&err), Some(&health(3, false))).mark, + Mark::Warn + ); + assert_eq!(shard_row(Err(&err), Some(&health(3, true))).mark, Mark::Ok); + assert_eq!(shard_row(Err(&err), None).mark, Mark::Warn); + } + + #[test] + fn an_empty_patch_tier_is_a_warning_not_a_failure() { + // The tier is optional and most shards will decline it. A ✗ there would make a correct + // install look broken forever. + let layout = paths::layout(); + let err = anyhow::anyhow!("no tree"); + assert_eq!(patch_row(&record(), Err(&err), &layout).mark, Mark::Warn); + } + + #[test] + fn a_missing_overlay_file_fails_and_an_edited_one_warns() { + let dir = crate::util::TempDir::new("rg-test-doctor").unwrap(); + let root = ServUoRoot { + path: dir.path().to_path_buf(), + version: Some("57.4".into()), + }; + std::fs::create_dir_all(dir.path().join("Scripts")).unwrap(); + std::fs::write(dir.path().join("Scripts/A.cs"), b"deployed").unwrap(); + let deployed = crate::util::sha256_bytes(b"deployed"); + + let mut record = record(); + let files = &mut record.overlay.as_mut().unwrap().files; + files.insert( + "Scripts/A.cs".into(), + FileRecord { + overlay_sha256: deployed.clone(), + on_disk_sha256: deployed.clone(), + state: "deployed".into(), + }, + ); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Ok); + + std::fs::write(dir.path().join("Scripts/A.cs"), b"edited by hand").unwrap(); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Warn); + + std::fs::remove_file(dir.path().join("Scripts/A.cs")).unwrap(); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Fail); + } + + #[test] + fn an_operator_owned_file_is_never_reported_as_drift() { + // Bridge.cfg is *meant* to be edited in place; flagging it every run would teach operators + // to ignore this row. + let dir = crate::util::TempDir::new("rg-test-doctor-owned").unwrap(); + let root = ServUoRoot { + path: dir.path().to_path_buf(), + version: Some("57.4".into()), + }; + std::fs::create_dir_all(dir.path().join("Config")).unwrap(); + std::fs::write(dir.path().join("Config/Bridge.cfg"), b"edited again").unwrap(); + + let mut record = record(); + record.overlay.as_mut().unwrap().files.insert( + "Config/Bridge.cfg".into(), + FileRecord { + overlay_sha256: "aa".into(), + on_disk_sha256: "bb".into(), + state: "kept-operator-modified".into(), + }, + ); + let row = overlay_row(&record, Ok(&root)); + assert_eq!(row.mark, Mark::Ok); + assert!(row.detail.contains("operator-owned"), "{}", row.detail); + } +} diff --git a/src/install.rs b/src/install.rs index db6b708..b06447d 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,9 +1,18 @@ -//! The `install` command. +//! 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 @@ -31,8 +40,37 @@ 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 @@ -41,8 +79,13 @@ pub fn run(cli: &Cli) -> Result<()> { let sidecar_asset = bundle.sidecar_asset()?.clone(); println!( - "\nRunic Gateway installer {} — bundle {} (protocol {}){}", + "\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}", env!("CARGO_PKG_VERSION"), + if mode.is_update() { + "update" + } else { + "install" + }, bundle.bundle, bundle.protocol, if cli.verify { @@ -54,7 +97,7 @@ pub fn run(cli: &Cli) -> Result<()> { println!(); // ── Where to install it ────────────────────────────────────────────────── - let root = resolve_root(cli)?; + let root = resolve_root(cli, mode, prior.as_ref())?; ui::row( "ServUO", &format!("{} ({})", root.path.display(), root.version_display()), @@ -137,8 +180,6 @@ pub fn run(cli: &Cli) -> Result<()> { } // ── Plan the sync ──────────────────────────────────────────────────────── - let record_path = layout.install_record(); - let prior = InstallRecord::load(&record_path)?; let prior_files = prior_overlay_files(prior.as_ref(), &root); let planned = overlay::plan(&unpacked, &root.path, prior_files)?; @@ -196,6 +237,7 @@ pub fn run(cli: &Cli) -> Result<()> { // near side of the running-shard check that guards it. let tier = tier::run( cli, + mode, &root, &unpacked, manifest.patch_tier.as_ref(), @@ -248,7 +290,12 @@ pub fn run(cli: &Cli) -> Result<()> { // ── Closing notes ──────────────────────────────────────────────────────── println!(); if cli.verify { - println!("Nothing was written. Re-run without --verify to deploy."); + // 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 @@ -272,27 +319,46 @@ pub fn run(cli: &Cli) -> Result<()> { // ── 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). - if let Some(sidecar) = &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.", + // + // 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 detection (confirmed), else a prompt. -fn resolve_root(cli: &Cli) -> Result { +/// 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()); diff --git a/src/lib.rs b/src/lib.rs index 35fb1d5..0f41f3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,15 @@ //! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary //! existed, is `docs/installer/INSTALL.md`. //! -//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation, -//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service, -//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and -//! each of them says so when reached rather than failing as though it were a typo. +//! **This build implements Phases 1 to 4** — the whole of what `INSTALL.md` describes: bundle +//! resolution, ServUO detection and validation, the overlay sync, the optional patch tier, +//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two +//! commands `doctor`, `update` and `uninstall`. //! //! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same -//! convention as the sidecar's CLI. +//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that +//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that. +//! `uninstall` does the same for a step it could not carry out — everything else was still removed. //! //! ## Why the library target is called `rgdeploy` //! @@ -29,6 +31,7 @@ pub mod bundle; pub mod cli; pub mod diff; +pub mod doctor; pub mod install; pub mod net; pub mod overlay; @@ -40,6 +43,8 @@ pub mod servuo; pub mod sidecar; pub mod tier; pub mod ui; +pub mod uninstall; +pub mod update; pub mod util; use cli::{Command, Mode}; @@ -58,57 +63,36 @@ pub fn run() -> i32 { } }; - let result = match parsed.mode { + // Every arm yields the process exit code, because one of them has more than two outcomes: + // `doctor` completes successfully while reporting a broken deployment, and a monitoring script + // has to be able to tell that from a healthy one (see `doctor::run`). + let result: anyhow::Result = match parsed.mode { Mode::Help => { print!("{}", cli::USAGE); - Ok(()) + Ok(0) } Mode::Version => { println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION")); - Ok(()) + Ok(0) } - Mode::Run(Command::Install) => install::run(&parsed), - Mode::Run(command) => Err(not_implemented(command)), + Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0), + Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0), + Mode::Run(Command::Doctor) => doctor::run(&parsed), + Mode::Run(Command::Uninstall) => uninstall::run(&parsed), }; - if let Err(error) = result { - // The chain is printed, not just the outermost message: "cannot write - // /etc/runicgateway/install.json" is only actionable with the OS error still attached. - eprintln!("\nerror: {error}"); - for cause in error.chain().skip(1) { - eprintln!(" caused by: {cause}"); + match result { + Ok(code) => code, + Err(error) => { + // The chain is printed, not just the outermost message: "cannot write + // /etc/runicgateway/install.json" is only actionable with the OS error still attached. + eprintln!("\nerror: {error}"); + for cause in error.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + 1 } - return 1; } - 0 -} - -/// A command the contract documents but this phase has not built. -/// -/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished. -fn not_implemented(command: Command) -> anyhow::Error { - let (phase, workaround) = match command { - Command::Doctor => ( - "Phase 4", - "Check the deployment by hand: `[bridge status` in game, and \ - `curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).", - ), - Command::Update => ( - "Phase 4", - "Re-run `install` to move the overlay to the current bundle; replace the sidecar \ - binary by hand (INSTALL.md Appendix A6).", - ), - Command::Uninstall => ( - "Phase 4", - "Remove the sidecar service and binary by hand; the overlay files this installer \ - deployed are listed in install.json.", - ), - Command::Install => unreachable!("install is implemented"), - }; - anyhow::anyhow!( - "`{command}` is not implemented in this build — it arrives in {phase} \ - (see docs/installer/PLAN.md §5).\n{workaround}" - ) } #[cfg(test)] @@ -116,17 +100,17 @@ mod tests { use super::*; #[test] - fn unfinished_commands_name_their_phase_and_a_way_through() { - // An operator who runs `doctor` today must not be left thinking they typed it wrong, and - // must not be left with nothing to do either. - for command in [Command::Doctor, Command::Update, Command::Uninstall] { - let message = not_implemented(command).to_string(); - assert!(message.contains(&command.to_string()), "{message}"); - assert!(message.contains("Phase 4"), "{message}"); - assert!( - message.contains("INSTALL.md") || message.contains("install.json"), - "{message}" - ); + fn every_documented_command_has_an_implementation() { + // The published contract is INSTALL.md §2's four commands. This build answers all of them, + // so the parser and the dispatcher must not be able to drift apart — an unhandled arm here + // used to be a "not implemented" message, and is now a compile error by construction. + for command in [ + Command::Install, + Command::Doctor, + Command::Update, + Command::Uninstall, + ] { + assert!(cli::USAGE.contains(&command.to_string()), "{command}"); } } } diff --git a/src/net.rs b/src/net.rs index c407c3d..2049e74 100644 --- a/src/net.rs +++ b/src/net.rs @@ -21,22 +21,33 @@ fn user_agent() -> String { format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION")) } +/// The default global timeout. Generous because the overlay tarball travels over whatever link the +/// shard host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the +/// run with a message instead of hanging an operator's terminal indefinitely. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); + /// One agent per call is fine at this volume, and it keeps the timeouts in one place. -/// -/// The global timeout is generous because the overlay tarball travels over whatever link the shard -/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run -/// with a message instead of hanging an operator's terminal indefinitely. -fn agent() -> ureq::Agent { +fn agent(timeout: Duration) -> ureq::Agent { ureq::Agent::config_builder() .user_agent(user_agent()) - .timeout_global(Some(Duration::from_secs(300))) + .timeout_global(Some(timeout)) .build() .into() } /// Fetches a small text document (the bundle manifest). pub fn get_text(url: &str) -> Result { - let mut response = agent() + get_text_within(url, DEFAULT_TIMEOUT) +} + +/// Fetches a small text document, giving up after `timeout`. +/// +/// `doctor` uses this for both of its network calls, and the short timeout is the point: every one +/// of its rows is optional context around local state, so a host with no route out must produce a +/// report a few seconds later rather than a terminal that appears to have hung. The install path +/// keeps [`DEFAULT_TIMEOUT`], where a slow answer is still worth waiting for. +pub fn get_text_within(url: &str, timeout: Duration) -> Result { + let mut response = agent(timeout) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; @@ -60,7 +71,7 @@ pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Resul bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})"); } - let mut response = agent() + let mut response = agent(DEFAULT_TIMEOUT) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; diff --git a/src/service.rs b/src/service.rs index 3a2a81d..ce18d95 100644 --- a/src/service.rs +++ b/src/service.rs @@ -31,6 +31,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use crate::record::ServiceRecord; // `command_line` is used only by the Windows registration path, so it is qualified at its call // site rather than imported here — an unconditional import is an unused-import error on Linux. use crate::util::{run, run_ok}; @@ -471,6 +472,221 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> { } } +/// What the service manager says about a registered service, read without changing anything. +/// +/// Every field is answered by asking the manager rather than by trusting `install.json`: the record +/// says what registration *did*, and `doctor`'s job is to find out what is true now. A service an +/// operator disabled by hand is exactly the case a record cannot know about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + pub present: bool, + pub running: bool, + pub enabled: bool, + /// The one-line form for a `doctor` row — `running, enabled`, `stopped, enabled`, `not found`. + pub detail: String, +} + +impl Status { + fn absent(detail: impl Into) -> Self { + Self { + present: false, + running: false, + enabled: false, + detail: detail.into(), + } + } +} + +/// Reads back the state of the service `install.json` recorded, changing nothing. +/// +/// `kind` is taken from the record rather than from this platform so that a record written on the +/// other OS produces an honest "this host has no such manager" instead of a confident answer from +/// the wrong tool. +pub fn observe(kind: &str, name: &str) -> Status { + observe_platform(kind, name) +} + +#[cfg(unix)] +fn observe_platform(kind: &str, name: &str) -> Status { + if kind != "systemd" { + return Status::absent(format!("recorded as {kind}, which this host does not run")); + } + let active = one_word(run("systemctl", &["is-active", name])); + // `is-enabled` on an absent unit fails with an empty stdout, which `one_word` reports as + // "unknown" — so a unit that is neither known nor active is one systemd has never heard of. + let enabled = one_word(run("systemctl", &["is-enabled", name])); + if enabled == "unknown" && active != "active" { + return Status::absent("not found by systemd".to_string()); + } + Status { + present: true, + running: active == "active", + enabled: enabled == "enabled", + detail: format!("{active}, {enabled}"), + } +} + +#[cfg(windows)] +fn observe_platform(kind: &str, name: &str) -> Status { + if kind != "windows-scm" { + return Status::absent(format!("recorded as {kind}, which this host does not run")); + } + // Only the service this installer registers is queried by name; anything else would be reading + // another product's service out of a hand-edited record. + if name != WINDOWS_SERVICE || !windows_service_exists() { + return Status::absent("not registered with the service manager".to_string()); + } + let state = windows_service_state(); + let start = windows_start_type(); + Status { + present: true, + running: state.contains("RUNNING"), + enabled: start.contains("AUTO_START"), + detail: format!("{}, {}", state.to_lowercase(), start.to_lowercase()), + } +} + +/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but +/// was set to manual start is reported as such rather than as healthy. +#[cfg(windows)] +fn windows_start_type() -> String { + let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else { + return "unknown".to_string(); + }; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.trim_start().starts_with("START_TYPE") { + // " START_TYPE : 2 AUTO_START" + if let Some((_, value)) = line.split_once(':') { + return value.split_whitespace().last().unwrap_or("unknown").into(); + } + } + } + "unknown".to_string() +} + +/// What removing a service actually managed to do. +/// +/// Never an `Err`: `uninstall` has usually already removed something by the time this runs, so a +/// step that fails must be *reported* and the rest carried out. Ending halfway with an error would +/// leave a host in a state neither the record nor the operator can describe. +#[derive(Debug, Default, Clone)] +pub struct Removal { + pub done: Vec, + pub problems: Vec, +} + +/// Stops, disables and deletes the service recorded in `install.json`. +/// +/// The service account is removed only when the record says **this installer created it** +/// (PLAN.md §5): deleting an account that was already on the host is not this tool's business, and +/// on Windows there is nothing to delete — the SCM's virtual account goes with the service. +pub fn remove(record: &ServiceRecord) -> Removal { + remove_platform(record) +} + +#[cfg(unix)] +fn remove_platform(record: &ServiceRecord) -> Removal { + let mut out = Removal::default(); + if record.kind != "systemd" { + out.problems.push(format!( + "the record describes a {} service, which this host does not run — remove it from the \ + host that has it", + record.kind + )); + return out; + } + + // Neither stop nor disable is `run_ok`: a unit that is already stopped, already disabled, or + // gone entirely exits non-zero, and all three are the desired end state rather than failures. + let _ = run("systemctl", &["stop", &record.name]); + let _ = run("systemctl", &["disable", &record.name]); + out.done + .push(format!("stopped and disabled {}", record.name)); + + if let Some(unit) = &record.unit_path { + let path = Path::new(unit); + match std::fs::remove_file(path) { + Ok(()) => out.done.push(format!("removed {unit}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + out.done.push(format!("{unit} was already gone")) + } + Err(error) => out.problems.push(format!("cannot remove {unit}: {error}")), + } + } + let _ = run("systemctl", &["daemon-reload"]); + // A unit that failed before being removed stays listed as failed until this is run. + let _ = run("systemctl", &["reset-failed", &record.name]); + + if let (Some(user), true) = (record.user.as_deref(), record.user_created) { + match run_ok("userdel", &[user]).or_else(|_| run_ok("deluser", &[user])) { + Ok(_) => out.done.push(format!("removed the {user} service user")), + Err(error) => out.problems.push(format!( + "cannot remove the {user} service user ({}); remove it by hand if you want it gone", + error.to_string().replace('\n', " ") + )), + } + } else if let Some(user) = record.user.as_deref() { + out.done.push(format!( + "left the {user} account alone — this installer did not create it" + )); + } + out +} + +#[cfg(windows)] +fn remove_platform(record: &ServiceRecord) -> Removal { + let mut out = Removal::default(); + if record.kind != "windows-scm" { + out.problems.push(format!( + "the record describes a {} service, which this host does not run — remove it from the \ + host that has it", + record.kind + )); + return out; + } + if !windows_service_exists() { + out.done + .push(format!("{} was already unregistered", record.name)); + return out; + } + + // Stopping first is not politeness: `sc delete` on a running service only marks it for deletion, + // and the service — and its lock on the binary this uninstall is about to remove — survives + // until the process exits. + if let Err(error) = stop_windows_service() { + out.problems + .push(error.to_string().replace('\n', " ").to_string()); + } else { + out.done.push(format!("stopped {}", record.name)); + } + + match run("sc.exe", &["delete", &record.name]) { + // 1072 is ERROR_SERVICE_MARKED_FOR_DELETE: something still holds a handle (an open + // services.msc is the usual culprit) and the entry goes when it is released. + Ok(output) if output.status.success() => { + out.done + .push(format!("deleted the {} service", record.name)); + } + Ok(output) if output.status.code() == Some(1072) => out.done.push(format!( + "{} is marked for deletion — it disappears once whatever has it open (services.msc?) \ + is closed", + record.name + )), + Ok(output) => out.problems.push(format!( + "sc.exe delete {} failed with exit code {}", + record.name, + output.status.code().unwrap_or(-1) + )), + Err(error) => out + .problems + .push(format!("cannot run sc.exe delete: {error}")), + } + + // The virtual account exists only as long as the service does, so there is nothing to remove. + out +} + /// Registers, enables and starts the service — or explains why it did not. /// /// `binary_changed` decides restart versus start: a replaced binary under an already-running diff --git a/src/tier.rs b/src/tier.rs index 131c29a..b041a04 100644 --- a/src/tier.rs +++ b/src/tier.rs @@ -69,17 +69,35 @@ impl Outcome { /// /// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than /// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]). +/// +/// ## Scope under `update` +/// +/// An `update` re-resolves **only the features a previous run recorded as applied**, and does so +/// without asking again. Two things follow from that, and both are deliberate: +/// +/// - It is not a fresh offer. A shard that declined the tier stays unpatched through every update, +/// which is what "opt-in" has to mean if it means anything; the new release's features are named +/// so the operator knows they exist, and `--patches` is how they are taken up. +/// - Consent is not re-asked for what is already in the tree — including on an unsupported ServUO, +/// where `install` demanded a second flag. The record is the evidence that the operator opted in, +/// and re-prompting would make an unattended update of a working shard impossible on precisely +/// the hosts that most need the patches re-checked after an overlay moves. +/// +/// Normally every one of those re-resolutions lands on rung 0 and writes nothing. When a release +/// genuinely changes a patch, it is applied through the same ladder as any other — the target is +/// still a file the operator may have edited, and nothing here loosens that check. #[allow(clippy::too_many_arguments)] pub fn run( cli: &Cli, + mode: crate::install::Mode, root: &ServUoRoot, unpacked: &Path, declared: Option<&Tier>, layout: &paths::Layout, prior: &[FeatureRecord], ) -> Result { - let tier = Tier::resolve(declared); - if tier.features.is_empty() { + let declared_tier = Tier::resolve(declared); + if declared_tier.features.is_empty() { ui::row( "Patch tier", "not offered — this overlay declares no patches", @@ -88,6 +106,34 @@ pub fn run( } let supported = root.is_supported_version(); + // Under `update`, scope narrows to what is already applied unless --patches widens it back. + let widen = cli.patches == PatchChoice::Yes; + let tier = if mode.is_update() && !widen { + scope_to_applied(&declared_tier, prior) + } else { + declared_tier.clone() + }; + + if mode.is_update() && !widen { + if tier.features.is_empty() { + ui::row( + "Patch tier", + "nothing to re-check — no feature was applied by an earlier run", + ); + print_available(&declared_tier); + return Ok(Outcome::skipped()); + } + ui::row( + "Patch tier", + &format!( + "re-checking {} feature(s) an earlier run applied", + tier.features.len() + ), + ); + announce_new_features(&declared_tier, &tier); + return apply_tier(cli, root, unpacked, &tier, layout, prior, supported); + } + match consent(cli, root, supported, &tier)? { Consent::Yes => {} Consent::No(reason) => { @@ -114,6 +160,52 @@ pub fn run( apply_tier(cli, root, unpacked, &tier, layout, prior, supported) } +/// The subset of a release's tier that a previous run actually applied. +fn scope_to_applied(declared: &Tier, prior: &[FeatureRecord]) -> Tier { + let applied = patch::index_records(prior); + Tier { + features: declared + .features + .iter() + .filter(|f| applied.contains_key(f.name.as_str())) + .cloned() + .collect(), + } +} + +/// Names what this release offers that the shard does not have, without offering it. +/// +/// An update must not quietly become the moment a shard acquires edits to stock ServUO files, but +/// an operator who never learns the feature exists cannot opt in either. +fn announce_new_features(declared: &Tier, in_scope: &Tier) { + let scoped: Vec<&str> = in_scope.features.iter().map(|f| f.name.as_str()).collect(); + let new: Vec<&Feature> = declared + .features + .iter() + .filter(|f| !scoped.contains(&f.name.as_str())) + .collect(); + if new.is_empty() { + return; + } + println!( + " This release also offers {} feature(s) this shard does not have:", + new.len() + ); + for feature in new { + println!(" - {}", feature.summary); + } + println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); +} + +/// The tier's offer, printed by an update that has nothing of its own to re-check. +fn print_available(declared: &Tier) { + println!(" This release offers:"); + for feature in &declared.features { + println!(" - {}", feature.summary); + } + println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); +} + enum Consent { Yes, /// Not selected, with the reason to print. @@ -288,6 +380,19 @@ fn apply_tier( )); } + // A feature an earlier run applied that this release no longer declares still has its edits + // sitting in the ServUO tree. Its record is carried through rather than dropped: `uninstall` + // renders the hunks to revert from these entries, and a record that quietly forgot them would + // leave the operator with modified stock files and nothing saying so. + let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); + let carried: Vec<&FeatureRecord> = prior + .iter() + .filter(|r| !in_scope.contains(&r.feature.as_str())) + .collect(); + for record in &carried { + records.push((*record).clone()); + } + // ── Report ─────────────────────────────────────────────────────────────── println!(); ui::row( @@ -305,6 +410,13 @@ fn apply_tier( for line in lines { println!("{line}"); } + for record in &carried { + println!( + " · {:<30} applied by an earlier run; this release's tier does not describe it,\n \ + so it was left exactly as it is and its record kept", + record.feature + ); + } if core_rebuild { println!(); @@ -681,6 +793,58 @@ mod tests { ); } + #[test] + fn an_update_re_checks_only_what_an_earlier_run_applied() { + // The scope rule for `update`: it must not become the moment a shard acquires edits to + // stock ServUO files, and it must not stop re-checking the ones it already has. + let tier = Tier::builtin(); + let applied = |name: &str| FeatureRecord { + feature: name.into(), + rebuild: Rebuild::Scripts, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: Vec::new(), + companions: Vec::new(), + }; + + let scoped = scope_to_applied(&tier, &[applied("moderation-audit")]); + assert_eq!(scoped.features.len(), 1); + assert_eq!(scoped.features[0].name, "moderation-audit"); + + // A host that declined the tier stays declined through every update. + assert!(scope_to_applied(&tier, &[]).features.is_empty()); + // And one that took everything keeps re-checking everything. + let all: Vec = tier.features.iter().map(|f| applied(&f.name)).collect(); + assert_eq!( + scope_to_applied(&tier, &all).features.len(), + tier.features.len() + ); + } + + #[test] + fn a_feature_the_release_no_longer_declares_is_still_scoped_out_not_forgotten() { + // `scope_to_applied` can only return what the release declares, so a record for a feature + // that has been withdrawn falls outside it — which is exactly why `apply_tier` carries such + // records through instead of rebuilding the section from what it processed. + let tier = Tier { + features: vec![feature()], + }; + let withdrawn = FeatureRecord { + feature: "some-old-feature".into(), + rebuild: Rebuild::Scripts, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: Vec::new(), + companions: Vec::new(), + }; + assert!(scope_to_applied(&tier, std::slice::from_ref(&withdrawn)) + .features + .is_empty()); + + let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); + assert!(!in_scope.contains(&withdrawn.feature.as_str())); + } + #[test] fn nothing_that_was_not_written_is_reported_as_applied() { // Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch diff --git a/src/uninstall.rs b/src/uninstall.rs new file mode 100644 index 0000000..27a33bf --- /dev/null +++ b/src/uninstall.rs @@ -0,0 +1,640 @@ +//! 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 { + 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 = Vec::new(); + let mut problems: Vec = 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); + } 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() + )); + } + 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!(); + 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 _ = 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); + + 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() + ); + } +} + +/// Renders one cached patch's added and removed lines, indented for the report. +fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option { + 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 { + 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, problems: &mut Vec) { + 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, problems: &mut Vec) { + 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"), + 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); + } +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..3394604 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,218 @@ +//! The `update` command — move an existing deployment to the current bundle. +//! +//! The deployment itself is [`crate::install`] in [`Mode::Update`]; this module holds only what is +//! genuinely different, which is smaller than it looks: +//! +//! - **A prior record is required.** `update` on a host that has never been installed is a typo, or +//! a state directory the run cannot see — never a reason to perform a first install under a verb +//! that promises to preserve what is already there. +//! - **Both halves move together.** The bundle is the compat matrix (PLAN.md §7.1): resolving it +//! and taking both components from it is what stops an update from landing two independently +//! latest artifacts whose protocol versions disagree. That property comes free from reusing the +//! install pipeline — it is stated here because it is the whole reason `update` is not simply +//! "download the newest sidecar". +//! - **The close is a diff, not a handoff.** What moved, what the operator must now do (restart the +//! shard; and, only if the protocol number changed, edit one field in Admin → Shard), and nothing +//! else. The auth token is not reprinted: it has not changed, the website already has it, and a +//! secret that requires no action does not belong in another terminal scrollback. +//! +//! What `update` deliberately does **not** do is restart the shard (the installer never owns +//! another process's lifecycle — PLAN.md §8) or widen the patch tier on its own. The tier's scope +//! under this verb is `[crate::tier]`'s business: features a previous run recorded are re-resolved +//! against the new release, and anything new is named but not applied without `--patches`. + +use std::path::Path; + +use anyhow::{bail, Result}; + +use crate::bundle::Bundle; +use crate::cli::Cli; +use crate::install::{self, Mode}; +use crate::record::InstallRecord; +use crate::{paths, ui}; + +pub fn run(cli: &Cli) -> Result<()> { + install::deploy(cli, Mode::Update) +} + +/// Refuses an update on a host with nothing recorded. +/// +/// Phrased around the state directory rather than the verb, because the overwhelmingly likely cause +/// is a run that cannot see the state it is looking for: a re-run without the `RUNICGATEWAY_STATE_DIR` +/// that the install used, or an unelevated shell on Windows. +pub fn require_prior(prior: Option<&InstallRecord>, record_path: &Path) -> Result<()> { + if prior.is_some() { + return Ok(()); + } + bail!( + "there is no deployment to update — {} does not exist.\n\ + Run `install` to deploy for the first time. If this host *is* installed, this run cannot \ + see its record: check that you are running as root/Administrator, and that {} is set to \ + the same value the install used (if any).", + record_path.display(), + paths::STATE_DIR_ENV + ) +} + +/// The end of an update: what moved, and what the operator has to do about it. +pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallRecord, verify: bool) { + let moves = describe_moves(prior, now); + + println!(); + ui::heading(if verify { + "Would move [--verify]" + } else { + "Updated" + }); + if moves.is_empty() { + println!(" Both halves were already on bundle {}.", bundle.bundle); + } else { + for line in &moves { + println!(" {line}"); + } + } + + // The one thing an update can change that the *website* has to be told about. The token, the + // URLs and the ports are all unchanged, so this is the only reason to reopen Admin → Shard — + // and it must be said plainly, because a stale number there is answered with 409 by the + // sidecar rather than mis-parsed, which looks to an operator like the shard going offline. + let previous_protocol = prior.map(|p| p.bundle.protocol); + if previous_protocol.is_some_and(|p| p != bundle.protocol) { + println!(); + ui::warn(&format!( + "The protocol version changed: {} → {}.\n \ + Update the Protocol version field in Admin → Shard on your website. Nothing else \ + changed —\n the URLs and the auth token are the same, and the sidecar answers a \ + website still set to\n {} with 409 rather than mis-parsing it.", + previous_protocol.unwrap_or(bundle.protocol), + bundle.protocol, + previous_protocol.unwrap_or(bundle.protocol), + )); + } + + // No "nothing was written" line here: the shared closing in `install::deploy` has already said + // it, in the wording of the verb that was typed. Saying it twice reads like two dry runs. +} + +/// The version moves between two records, as printed lines. +/// +/// Compared per component rather than by bundle tag: a new bundle whose components happen to be +/// unchanged is not something to report as an upgrade, and the tag alone cannot say which half +/// actually moved. +fn describe_moves(prior: Option<&InstallRecord>, now: &InstallRecord) -> Vec { + let Some(prior) = prior else { + return Vec::new(); + }; + let mut moves = Vec::new(); + + if prior.bundle.tag != now.bundle.tag { + moves.push(format!( + "bundle {} → {}", + prior.bundle.tag, now.bundle.tag + )); + } + match (prior.link_record(), now.link_record()) { + (Some(before), Some(after)) if before.version != after.version => moves.push(format!( + "uo-link {} → {} (service restarted)", + before.version, after.version + )), + _ => {} + } + match (&prior.overlay, &now.overlay) { + (Some(before), Some(after)) if before.version != after.version => moves.push(format!( + "overlay {} → {} (ServUO must be restarted to compile it)", + before.version, after.version + )), + _ => {} + } + moves +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{ + BinaryRef, BundleRef, InstallerInfo, LinkRecord, OverlayRecord, ServUoRef, SCHEMA, + }; + use std::collections::BTreeMap; + + fn record(bundle_tag: &str, protocol: u32, link: &str, overlay: &str) -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: "2026-08-05T10:00:00Z".into(), + bundle: BundleRef { + tag: bundle_tag.into(), + protocol, + url: "https://example/current.json".into(), + }, + servuo: ServUoRef { + path: "/opt/ServUO".into(), + version: Some("57.4".into()), + }, + overlay: Some(OverlayRecord { + repo: "RunicGateway/servuo-plugins".into(), + tag: format!("v{overlay}"), + version: overlay.into(), + commit: "3a52abb".into(), + protocol, + files: BTreeMap::new(), + }), + link: serde_json::to_value(LinkRecord { + repo: "RunicGateway/link".into(), + tag: format!("v{link}"), + version: link.into(), + protocol, + 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: None, + }) + .ok(), + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + #[test] + fn an_update_with_nothing_recorded_is_refused_with_the_state_dir_named() { + // The failure this message exists for is a run that cannot *see* an install, not one that + // has none — so the text has to point at the state directory, not just say "run install". + let error = require_prior(None, Path::new("/etc/runicgateway/install.json")).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("install.json"), "{message}"); + assert!(message.contains(paths::STATE_DIR_ENV), "{message}"); + assert!(require_prior(Some(&record("a", 3, "1.1.0", "0.1.1")), Path::new("x")).is_ok()); + } + + #[test] + fn only_components_that_actually_moved_are_reported() { + let before = record("2026.08.04", 3, "1.1.0", "0.1.1"); + let after = record("2026.09.01", 3, "1.2.0", "0.1.1"); + let moves = describe_moves(Some(&before), &after); + + assert!( + moves.iter().any(|m| m.contains("uo-link 1.1.0 → 1.2.0")), + "{moves:?}" + ); + // The overlay did not move, so nothing may tell the operator to restart their shard for it. + assert!(!moves.iter().any(|m| m.contains("overlay")), "{moves:?}"); + assert!(moves.iter().any(|m| m.contains("bundle")), "{moves:?}"); + } + + #[test] + fn a_new_bundle_with_unchanged_components_reports_only_the_bundle() { + // The nightly cron can publish a new tag whose matrix is identical; calling that an upgrade + // would send an operator looking for a change that does not exist. + let before = record("2026.08.04", 3, "1.1.0", "0.1.1"); + let after = record("2026.08.05", 3, "1.1.0", "0.1.1"); + let moves = describe_moves(Some(&before), &after); + assert_eq!(moves.len(), 1, "{moves:?}"); + assert!(moves[0].contains("bundle"), "{moves:?}"); + } +} From 060b8815cf3431a4cb08ebfb3ca06e2b9c73f3ae Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 03:59:26 -0500 Subject: [PATCH 08/13] fix(installer): keep user_created sticky across re-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the first real systemd host this installer has ever run on: a container with systemd as PID 1, installing into /usr/bin, /etc and /var/lib for real. `service::prepare` answers "did THIS run create the service account", which is false on every run after the first — by then the account exists. Recording that verbatim made the field describe the run rather than the state, with two consequences: - `install.json` changed on an otherwise-identical second run, breaking the Phase 1 promise that a re-run with nothing new to do writes nothing. - `uninstall` removes only an account it created, so after any second `install` it silently left behind the very user this tool had added. Reproduced before the fix: "left the runicgateway account alone — this installer did not create it", on a host where it plainly had. The record now inherits `true` from a prior record naming the same account, and only that account: inheriting across a rename would authorize deleting a user this installer never made. Not visible on Windows, where the SCM's virtual account is never created by us and goes with the service — which is why three phases of Windows smoke runs never showed it. Verified after the fix on the same host: fresh install records user_created true, an identical second run leaves install.json byte-identical, and uninstall then removes the account, the unit, the service and the binary — leaving sidecar.toml, the database and all 24 overlay files in the ServUO tree exactly where they were. Co-Authored-By: Claude --- src/install.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/src/install.rs b/src/install.rs index b06447d..017ff0b 100644 --- a/src/install.rs +++ b/src/install.rs @@ -249,7 +249,14 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { )?; // ── The sidecar and its service ────────────────────────────────────────── - let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?; + let sidecar = install_sidecar( + cli, + &bundle, + &sidecar_asset, + &layout, + scratch.path(), + prior.as_ref(), + )?; // ── Record ─────────────────────────────────────────────────────────────── let record = build_record( @@ -415,6 +422,7 @@ fn install_sidecar( asset: &bundle::Asset, layout: &paths::Layout, scratch: &Path, + prior: Option<&InstallRecord>, ) -> Result> { ui::heading("uo-link sidecar"); @@ -539,7 +547,7 @@ fn install_sidecar( name: name.clone(), unit_path: unit_path.as_ref().map(|p| p.display().to_string()), user: user.clone(), - user_created: *user_created, + user_created: *user_created || created_by_an_earlier_run(prior, user.as_deref()), }) } service::Outcome::Skipped { reason, manual } => { @@ -572,6 +580,29 @@ fn install_sidecar( })) } +/// 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 @@ -759,6 +790,61 @@ mod tests { } } + #[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"); From 6941925fa5d916bbe6fab5906bde72e8f612ff62 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 05:34:08 -0500 Subject: [PATCH 09/13] feat(installer): build for and install on linux-aarch64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of PLAN.md §5.2, and the half that faces the operator: the release now cross-compiles aarch64-unknown-linux-gnu, and platform_key() resolves ("linux","aarch64") to the bundle key link publishes under instead of refusing the host by name. Same toolchain shape as the Windows step -- a linker plus a CC/AR pair, because ring (under ureq's rustls) compiles C and assembly. And the same packaging trap named in the sums comment: an artifact missing from SHA256SUMS is one `sha256sum -c` passes over silently, so the new binary is added to both the sums and the upload list. Two test changes fall out of the asset map growing a key: - The exact `assets.len() == 2` assertion is replaced by a check that each key CI requires is present and well-formed. An exact count would fail on the first bundle that adds arm64 -- reporting correct behaviour as a regression. - The host-binary lookup now accepts either outcome, and says why. Bundles are kept unchanged forever so `--bundle` stays reproducible, which means one published before arm64 existed can never gain that key. On such a host the run must fail with the reason rather than something that reads like a corrupt document, so sidecar_asset()'s error now says so and the test asserts it. Verified by cross-building this crate for aarch64 in a rust:1-slim-bookworm container -- ELF 64-bit LSB pie executable, ARM aarch64 -- and by running fmt, clippy -D warnings and the tests on both Linux and the Windows host, since only half of service.rs compiles on either. Co-Authored-By: Claude --- .gitea/workflows/release.yml | 14 +++++-- src/bundle.rs | 73 +++++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index fdb3457..627b169 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -66,6 +66,9 @@ env: BIN: runicgateway-installer LINUX_TARGET: x86_64-unknown-linux-gnu WINDOWS_TARGET: x86_64-pc-windows-gnu + # The installer has to run wherever the sidecar it installs can run, and link + # publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4). + ARM64_TARGET: aarch64-unknown-linux-gnu jobs: release: @@ -237,7 +240,7 @@ jobs: echo "Release credentials present." # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── - - name: Install Rust toolchain, Windows target, and MinGW linker + - name: Install Rust toolchain, cross targets, and their linkers if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail @@ -254,6 +257,7 @@ jobs: export PATH="${HOME}/.cargo/bin:${PATH}" rustup component add rustfmt rustup target add "${WINDOWS_TARGET}" + rustup target add "${ARM64_TARGET}" - name: Set the crate version to match the release if: ${{ steps.plan.outputs.release == 'true' }} @@ -299,8 +303,12 @@ jobs: run: | set -euo pipefail cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" + cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64" cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" - ( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS ) + # Every artifact must be listed: `sha256sum -c` passes silently over a + # file the sums do not mention, and an operator verifying a download + # would get a pass on a binary nobody vouched for. + ( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" > SHA256SUMS ) ls -l dist && echo "----" && cat dist/SHA256SUMS # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── @@ -365,7 +373,7 @@ jobs: | jq -r '.id')" echo "Created release ${TAG} (id=${REL_ID})" - for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do + for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null diff --git a/src/bundle.rs b/src/bundle.rs index 59ff818..c36c5da 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -43,8 +43,10 @@ pub struct LinkComponent { pub tag: String, pub version: String, pub protocol: u32, - /// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and - /// the installer runs on both, so a single hash could only ever describe one of them. + /// Keyed by platform (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`) — link publishes a + /// binary per target and the installer runs on each, so a single hash could only ever describe + /// one of them. The set grows over time, so a bundle is not expected to carry every key this + /// binary knows about: an older one pinned with `--bundle` predates arm64 entirely. pub assets: BTreeMap, } @@ -85,7 +87,10 @@ impl Bundle { let key = platform_key()?; self.link.assets.get(key).ok_or_else(|| { anyhow::anyhow!( - "bundle {} has no uo-link binary for {key} (it has: {})", + "bundle {} has no uo-link binary for {key} (it has: {}).\n\ + Bundles published before uo-link built for this platform cannot gain one \ + retroactively — they are kept unchanged so `--bundle` stays reproducible. \ + Run without `--bundle` to take the current one.", self.bundle, self.link .assets @@ -102,12 +107,16 @@ impl Bundle { pub fn platform_key() -> Result<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("linux", "x86_64") => Ok("linux-x86_64"), + // Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the + // sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a + // Windows-on-arm box or a Mac. + ("linux", "aarch64") => Ok("linux-aarch64"), ("windows", "x86_64") => Ok("windows-x86_64"), - // arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats - // failing later with a missing-key error that reads like a corrupt bundle. + // Naming the platforms that do exist beats failing later with a missing-key error that + // reads like a corrupt bundle. (os, arch) => bail!( "no Runic Gateway build exists for {os}/{arch}. \ - The released components target linux-x86_64 and windows-x86_64." + The released components target linux-x86_64, linux-aarch64 and windows-x86_64." ), } } @@ -190,18 +199,56 @@ mod tests { assert_eq!(bundle.link.version, "1.1.0"); assert_eq!(bundle.overlay.version, "0.1.1"); assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4"); - assert_eq!(bundle.link.assets.len(), 2); assert!(bundle.overlay.asset.name.ends_with(".tar.gz")); } #[test] - fn both_platforms_have_a_sidecar_binary() { - // Whichever of the two this test runs on, the lookup must resolve — a bundle missing the - // host's binary would fail an install after the overlay had already been deployed. + fn every_platform_the_bundle_names_is_well_formed() { + // A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release + // (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it + // rather than on anything being wrong. let bundle = parse(CURRENT).unwrap(); - let asset = bundle.sidecar_asset().unwrap(); - assert_eq!(asset.sha256.len(), 64); - assert!(asset.url.contains(&bundle.link.tag)); + for required in ["linux-x86_64", "windows-x86_64"] { + let asset = bundle + .link + .assets + .get(required) + .unwrap_or_else(|| panic!("bundle carries no {required} binary")); + assert_eq!(asset.sha256.len(), 64); + assert!(asset.url.contains(&bundle.link.tag)); + } + } + + #[test] + fn the_hosts_binary_either_resolves_or_says_why_not() { + // On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise + // fail an install after the overlay had already been deployed. On a host whose platform + // postdates the bundle (an arm64 box reading the first published one), it must fail with + // the reason, since every bundle is kept unchanged forever so `--bundle` stays + // reproducible and therefore cannot gain a key retroactively. + let bundle = parse(CURRENT).unwrap(); + match bundle.sidecar_asset() { + Ok(asset) => { + assert_eq!(asset.sha256.len(), 64); + assert!(asset.url.contains(&bundle.link.tag)); + } + Err(e) => { + let msg = e.to_string(); + assert!(msg.contains(platform_key().unwrap()), "{msg}"); + assert!(msg.contains("--bundle"), "{msg}"); + } + } + } + + #[test] + fn the_host_is_a_platform_the_components_are_built_for() { + // `cargo test` running at all means the host is one the crate compiles on, so a refusal + // here is a build target the release workflows have not caught up with. + let key = platform_key().unwrap(); + assert!( + ["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key), + "unexpected platform key {key}" + ); } #[test] From 82900da939094f9d8f45a06dce3023c0e1387a9d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 05:47:16 -0500 Subject: [PATCH 10/13] feat(installer): back up what a run is about to overwrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §5.3. Before anything is written, every file this run will replace is copied into /backups// with a manifest naming where each came from. --no-backup opts out; --verify takes none. Scoped by what cannot be fetched again. The sidecar binary and the overlay files are re-downloadable and hash-named in the bundle, and the database is a cache with a schema -- link's store.rs creates every table IF NOT EXISTS over shard state the sweeps repopulate. What a run can destroy for good is an operator's edits to a deployed .cs file, which Phase 1 overwrites unconditionally and by design, and sidecar.toml, whose token the website already holds. Two deviations from §5.3 as written, both found by building it: - The trigger is "this run is about to overwrite something", not "an update, or an install over an existing record". §5.3 justified the latter with "a first install overwrites nothing" -- which is not true of a tree deployed by hand per INSTALL.md Appendix A2, a documented path. There the first install finds .cs files that differ, plans them as Change, and overwrites them with no record anywhere. The direct test covers that case and still writes nothing for a genuine first install, because there is nothing to copy. - sidecar.toml joins a backup that is already being taken and is never the reason for one. Nothing here rewrites it, so making it a trigger would put a dated directory on disk after every no-op update; it is copied so a restored set of files comes with the token that matches them. The directory is created lazily and the manifest is written last, so a directory carrying one is a complete backup -- and pruning only considers those, so a run interrupted mid-copy cannot evict a good backup by being newer than it. Three are kept. uninstall keeps them and names them in its report; --purge removes them, alongside the config, the database and the cached patch set. doctor reports the newest. Restoring stays printed rather than done, as the uninstall report is: the installer cannot know what has changed since, and putting an old .cs file back over a newer overlay eats work rather than saving it. Verified live against two scratch ServUO trees built from the real 57.4 files: a clean first install leaving no backups directory at all, an update after editing a deployed .cs (copy holds the edit, tree gets the release's file, manifest lists both it and sidecar.toml), a no-op update taking none, --no-backup and --verify each taking none, a fourth backup pruning the oldest, doctor's row, uninstall keeping three and listing them, --purge removing them, and a --patches run capturing the pre-patch Logging.cs while the two rung-0 patches correctly captured nothing. fmt, clippy -D warnings and 144 tests on both Linux and Windows. Co-Authored-By: Claude --- src/backup.rs | 485 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 16 +- src/doctor.rs | 32 ++++ src/install.rs | 51 ++++- src/lib.rs | 1 + src/paths.rs | 10 + src/tier.rs | 20 +- src/uninstall.rs | 77 +++++++- 8 files changed, 680 insertions(+), 12 deletions(-) create mode 100644 src/backup.rs diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..8417aa2 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,485 @@ +//! Copies of what a run is about to overwrite (PLAN.md §5.3). +//! +//! ## Scoped by what cannot be fetched again +//! +//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are +//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema +//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state +//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not +//! free: it would bury the two things that matter. +//! +//! What a run can destroy irrecoverably is short: +//! +//! 1. **The operator's own edits to a file the overlay owns.** `Bridge.cfg` is deliberately kept +//! (PLAN.md §5 Phase 1), but every `.cs` file and `Scripts.csproj` is overwritten +//! *unconditionally and by design* — so the one place this tool knowingly discards work is the +//! one place it should keep a copy first. +//! 2. **A stock ServUO file the patch tier edits.** `patches/originals/` already holds the +//! pre-*tier* copy and is never overwritten, which is the right revert target; it is not a +//! record of what the file looked like *this morning*, after the operator's own later edits. +//! 3. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's +//! saved configuration starts answering `401` with nothing on the sidecar to explain why. +//! +//! ## What decides whether a backup happens +//! +//! **Whether this run is about to overwrite something** — not which verb was typed and not whether +//! a prior record exists. PLAN.md §5.3 framed it as "`update`, and `install` over an existing +//! record", on the reasoning that a first install overwrites nothing. That reasoning does not +//! survive contact with `INSTALL.md` Appendix A2, which documents deploying the overlay **by hand**: +//! a first `install` over such a tree finds `.cs` files that differ, plans them as `Change`, and +//! overwrites them with no record anywhere of what was there. So the test is the direct one, and a +//! genuine first install onto a clean tree still writes nothing because there is nothing to copy. +//! +//! ## Restoring is printed, not done +//! +//! Same rule as the uninstall report, and for the same reason: the installer cannot know what has +//! changed since, and a restore that puts an old `.cs` file back over a newer overlay eats work +//! rather than saving it. The path and the manifest are what this module hands over. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::paths::Layout; +use crate::util; + +/// How many backup directories survive. Older ones are pruned as new ones are written. +/// +/// An unbounded directory of ServUO source copies on a shard host is its own support problem, and +/// the value of an old backup falls off a cliff: what an operator reaches for is "before this +/// upgrade", occasionally "before the one before". Three is that, plus one. +pub const KEEP: usize = 3; + +/// The `schema` written into `manifest.json`, so a future reader can tell shapes apart. +const SCHEMA: u32 = 1; + +/// Why a file was copied. Recorded per entry, because "what did this upgrade touch" is answered +/// very differently by an overlay file and by a stock ServUO file the patch tier edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reason { + /// An overlay file whose on-disk content the sync is about to replace. + OverlayChange, + /// A stock ServUO file the patch tier is about to edit. + PatchTarget, + /// A companion `.cs` the tier copies in, which already existed in the tree. + PatchCompanion, + /// `sidecar.toml` — the token the website holds. + SidecarConfig, +} + +impl Reason { + fn as_str(self) -> &'static str { + match self { + Self::OverlayChange => "overlay-change", + Self::PatchTarget => "patch-target", + Self::PatchCompanion => "patch-companion", + Self::SidecarConfig => "sidecar-config", + } + } + + /// Which sub-directory of the backup the copy lands under. + /// + /// The two roots are kept apart because a path is only meaningful relative to one of them, and + /// without the split a state file could collide with a tree file of the same name. + fn root_dir(self) -> &'static str { + match self { + Self::SidecarConfig => "state", + _ => "servuo", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Manifest { + pub schema: u32, + /// RFC 3339, when the backup was taken. + pub taken: String, + /// `install` or `update` — the verb that displaced these files. + pub command: String, + pub installer: String, + /// The bundle in the record before this run, when there was one. + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_from: Option, + /// The bundle this run is moving to. + pub bundle_to: String, + pub servuo_root: String, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Entry { + /// Where the copy sits inside the backup directory, `/`-separated. + pub path: String, + /// Where it was copied from, absolute, as it was on this host. + pub source: String, + pub sha256: String, + pub reason: String, +} + +/// One run's backup. Created up front and handed to each stage that writes. +/// +/// **The directory is created lazily, on the first capture.** A run that overwrites nothing must +/// leave nothing behind — an empty dated directory per run would be indistinguishable from a +/// backup that failed to record anything, and would push real ones out of the retention window. +pub struct Session { + dir: PathBuf, + enabled: bool, + started: bool, + root: PathBuf, + layout: Layout, + command: &'static str, + bundle_from: Option, + bundle_to: String, + taken: String, + entries: Vec, +} + +impl Session { + /// `enabled` is false for `--verify` (a dry run must not create state, the same rule that keeps + /// it away from `--print-config`) and for `--no-backup`. + pub fn new( + layout: &Layout, + root: &Path, + command: &'static str, + bundle_from: Option, + bundle_to: String, + enabled: bool, + ) -> Self { + let taken = chrono::Utc::now(); + Self { + // Colons are not legal in a Windows path component, so the stamp is the basic ISO 8601 + // form. It still sorts lexicographically, which is what the pruning relies on. + dir: layout + .backups_dir() + .join(taken.format("%Y%m%dT%H%M%SZ").to_string()), + enabled, + started: false, + root: root.to_path_buf(), + layout: layout.clone(), + command, + bundle_from, + bundle_to, + taken: taken.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + entries: Vec::new(), + } + } + + /// True once something has actually been copied. + pub fn has_entries(&self) -> bool { + !self.entries.is_empty() + } + + /// Copies `source` into this backup, if it exists and backups are enabled. + /// + /// A file that does not exist is not an error and not an entry: the caller asks for anything it + /// *may* be about to overwrite, and "there was nothing there" is the common answer on a first + /// install. + pub fn capture(&mut self, source: &Path, reason: Reason) -> Result<()> { + if !self.enabled || !source.exists() { + return Ok(()); + } + + let rel = self.relative_to_root(source, reason); + let dest = self.dir.join(reason.root_dir()).join(&rel); + if dest.exists() { + // Two stages can name the same file — a patch target that is also a companion path, or + // a re-entrant caller. First copy wins: it is the one taken furthest from any write. + return Ok(()); + } + + if !self.started { + fs::create_dir_all(&self.dir).with_context(|| { + format!("cannot create the backup directory {}", self.dir.display()) + })?; + self.started = true; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + + fs::copy(source, &dest).with_context(|| { + format!( + "cannot back up {} before overwriting it. Re-run with --no-backup to proceed \ + without a copy", + source.display() + ) + })?; + + self.entries.push(Entry { + path: format!( + "{}/{}", + reason.root_dir(), + rel.replace(std::path::MAIN_SEPARATOR, "/") + ), + source: source.display().to_string(), + sha256: util::sha256_file(source)?, + reason: reason.as_str().to_string(), + }); + Ok(()) + } + + /// Writes `manifest.json` and prunes older backups. Returns the directory when one was written. + /// + /// The manifest is written **last**, so a directory carrying one is a complete backup. Pruning + /// only considers directories that have one, for the same reason: a run interrupted mid-copy + /// must not be able to evict a good backup by being newer than it. + pub fn finish(mut self) -> Result> { + if !self.started { + return Ok(None); + } + + self.entries.sort_by(|a, b| a.path.cmp(&b.path)); + let manifest = Manifest { + schema: SCHEMA, + taken: self.taken.clone(), + command: self.command.to_string(), + installer: env!("CARGO_PKG_VERSION").to_string(), + bundle_from: self.bundle_from.clone(), + bundle_to: self.bundle_to.clone(), + servuo_root: self.root.display().to_string(), + files: self.entries.clone(), + }; + let body = serde_json::to_string_pretty(&manifest)? + "\n"; + util::write_atomic(&manifest_path(&self.dir), body.as_bytes()) + .context("cannot write the backup manifest")?; + + prune(&self.layout, KEEP)?; + Ok(Some(self.dir.clone())) + } + + /// The path a captured file takes inside the backup, relative to the root it belongs to. + fn relative_to_root(&self, source: &Path, reason: Reason) -> String { + let rel = match reason.root_dir() { + "servuo" => source.strip_prefix(&self.root).unwrap_or(source), + _ => source + .strip_prefix(&self.layout.state_dir) + .unwrap_or(source), + }; + // An absolute path outside the root it was filed under would escape the backup directory + // when joined. Falling back to the file name keeps the copy inside; the manifest still + // records exactly where it came from. + if rel.is_absolute() || rel.as_os_str().is_empty() { + return source + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + } + rel.to_string_lossy().to_string() + } +} + +fn manifest_path(dir: &Path) -> PathBuf { + dir.join("manifest.json") +} + +/// Every complete backup on this host, newest first. +pub fn list(layout: &Layout) -> Vec { + let mut dirs: Vec = match fs::read_dir(layout.backups_dir()) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| manifest_path(p).is_file()) + .collect(), + Err(_) => Vec::new(), + }; + // The stamp is fixed-width and zero-padded, so lexicographic order is chronological order. + dirs.sort(); + dirs.reverse(); + dirs +} + +/// Reads one backup's manifest. +pub fn read_manifest(dir: &Path) -> Result { + let body = fs::read_to_string(manifest_path(dir)) + .with_context(|| format!("cannot read {}", manifest_path(dir).display()))?; + serde_json::from_str(&body) + .with_context(|| format!("{} is not a backup manifest", manifest_path(dir).display())) +} + +/// Removes all but the `keep` newest complete backups. +pub fn prune(layout: &Layout, keep: usize) -> Result<()> { + for old in list(layout).into_iter().skip(keep) { + fs::remove_dir_all(&old) + .with_context(|| format!("cannot remove the old backup {}", old.display()))?; + } + Ok(()) +} + +/// Deletes every backup. Reached only from `uninstall --purge`, alongside the config, the database +/// and the cached patch set — they are all the same kind of thing: the only offline record of what +/// was here before. +pub fn remove_all(layout: &Layout) -> Result<()> { + let dir = layout.backups_dir(); + if dir.exists() { + fs::remove_dir_all(&dir).with_context(|| format!("cannot remove {}", dir.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + fn layout_in(root: &Path) -> Layout { + let mut layout = crate::paths::layout(); + layout.state_dir = root.join("state"); + layout + } + + fn write(path: &Path, body: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); + } + + #[test] + fn a_run_that_overwrites_nothing_leaves_nothing_behind() { + let tmp = TempDir::new("backup-empty").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true); + // Nothing on disk to copy — the common first-install case. + session + .capture( + &root.join("Scripts/Custom/Bridge/BridgeLink.cs"), + Reason::OverlayChange, + ) + .unwrap(); + assert!(session.finish().unwrap().is_none()); + assert!( + !layout.backups_dir().exists(), + "an empty dated directory would be indistinguishable from a failed backup" + ); + } + + #[test] + fn a_captured_file_is_copied_verbatim_and_recorded() { + let tmp = TempDir::new("backup-capture").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs"); + write(&source, "the operator's own edit\n"); + write(&layout.sidecar_config(), "[web]\nauth_token = \"secret\"\n"); + + let mut session = Session::new( + &layout, + &root, + "update", + Some("2026.08.04".into()), + "2026.08.05".into(), + true, + ); + session.capture(&source, Reason::OverlayChange).unwrap(); + session + .capture(&layout.sidecar_config(), Reason::SidecarConfig) + .unwrap(); + let dir = session.finish().unwrap().expect("a backup was taken"); + + let copy = dir.join("servuo/Scripts/Custom/Bridge/BridgeLink.cs"); + assert_eq!( + fs::read_to_string(©).unwrap(), + "the operator's own edit\n" + ); + assert!(dir.join("state/sidecar.toml").is_file()); + + let manifest = read_manifest(&dir).unwrap(); + assert_eq!(manifest.command, "update"); + assert_eq!(manifest.bundle_from.as_deref(), Some("2026.08.04")); + assert_eq!(manifest.files.len(), 2); + let overlay = manifest + .files + .iter() + .find(|f| f.reason == "overlay-change") + .unwrap(); + // The path inside the backup is always `/`-separated, so a manifest written on Windows + // reads the same as one written on Linux. + assert_eq!(overlay.path, "servuo/Scripts/Custom/Bridge/BridgeLink.cs"); + assert_eq!(overlay.sha256, util::sha256_file(&source).unwrap()); + assert!(overlay.source.contains("BridgeLink.cs")); + } + + #[test] + fn the_first_copy_of_a_file_wins() { + // Two stages can name the same path. The earlier capture is the one taken furthest from + // any write, so a later one must not overwrite it with content that has already changed. + let tmp = TempDir::new("backup-twice").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Server/EventSink.cs"); + write(&source, "before\n"); + + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true); + session.capture(&source, Reason::PatchTarget).unwrap(); + write(&source, "after\n"); + session.capture(&source, Reason::PatchTarget).unwrap(); + let dir = session.finish().unwrap().unwrap(); + + assert_eq!( + fs::read_to_string(dir.join("servuo/Server/EventSink.cs")).unwrap(), + "before\n" + ); + assert_eq!(read_manifest(&dir).unwrap().files.len(), 1); + } + + #[test] + fn disabled_sessions_write_nothing() { + let tmp = TempDir::new("backup-off").unwrap(); + let layout = layout_in(tmp.path()); + let root = tmp.path().join("ServUO"); + let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs"); + write(&source, "content\n"); + + let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), false); + session.capture(&source, Reason::OverlayChange).unwrap(); + assert!(session.finish().unwrap().is_none()); + assert!(!layout.backups_dir().exists()); + } + + #[test] + fn pruning_keeps_the_newest_and_ignores_incomplete_directories() { + let tmp = TempDir::new("backup-prune").unwrap(); + let layout = layout_in(tmp.path()); + for stamp in ["20260801T000000Z", "20260802T000000Z", "20260803T000000Z"] { + write( + &layout.backups_dir().join(stamp).join("manifest.json"), + "{\"schema\":1}", + ); + } + // A run interrupted before its manifest was written. It must neither be listed nor be able + // to evict a complete backup by being newer. + write( + &layout.backups_dir().join("20260804T000000Z/servuo/x.cs"), + "half a copy\n", + ); + + assert_eq!(list(&layout).len(), 3); + prune(&layout, 2).unwrap(); + + let kept: Vec = list(&layout) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!(kept, vec!["20260803T000000Z", "20260802T000000Z"]); + assert!( + layout.backups_dir().join("20260804T000000Z").exists(), + "an incomplete directory is left for a human to look at, not silently deleted" + ); + } + + #[test] + fn purge_removes_every_backup() { + let tmp = TempDir::new("backup-purge").unwrap(); + let layout = layout_in(tmp.path()); + write( + &layout.backups_dir().join("20260801T000000Z/manifest.json"), + "{\"schema\":1}", + ); + remove_all(&layout).unwrap(); + assert!(!layout.backups_dir().exists()); + // Removing what is not there is not an error: `uninstall --purge` runs on hosts that never + // took a backup. + remove_all(&layout).unwrap(); + } +} diff --git a/src/cli.rs b/src/cli.rs index dac8608..518e39c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,8 +81,11 @@ pub struct Cli { pub site_url: Option, /// `--yes`: assume the default answer to every prompt. pub assume_yes: bool, - /// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`. + /// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set + /// and every backup. pub purge: bool, + /// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3). + pub no_backup: bool, } impl Default for Cli { @@ -98,6 +101,7 @@ impl Default for Cli { site_url: None, assume_yes: false, purge: false, + no_backup: false, } } } @@ -138,8 +142,13 @@ Options: On uninstall it means yes: that prompt defaults to no, and typing `uninstall --yes` is not an accident. - --purge uninstall. Also delete sidecar.toml and - uo-link.db, which are otherwise kept. + --no-backup install, update. Do not copy the files + this run is about to overwrite. They are + otherwise saved under /backups/, + newest 3 kept. + --purge uninstall. Also delete sidecar.toml, + uo-link.db, the cached patch set and every + backup, all of which are otherwise kept. -V, --version Print the installer version and exit. -h, --help Print this help and exit. @@ -182,6 +191,7 @@ pub fn parse>(args: I) -> Result { "--verify" => cli.verify = true, "--yes" | "-y" => cli.assume_yes = true, "--purge" => cli.purge = true, + "--no-backup" => cli.no_backup = true, "--patches" => cli.patches = PatchChoice::Yes, "--no-patches" => cli.patches = PatchChoice::No, "--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true, diff --git a/src/doctor.rs b/src/doctor.rs index 5500765..fc7861a 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -158,6 +158,9 @@ pub fn run(cli: &Cli) -> Result { // ── The bundle ─────────────────────────────────────────────────────────── rows.push(bundle_row(&record)); + // ── Backups ────────────────────────────────────────────────────────────── + rows.push(backup_row(&layout)); + // ── Report ─────────────────────────────────────────────────────────────── println!(); for row in &rows { @@ -673,6 +676,35 @@ fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>) /// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way /// to run this — the operator downloads artifacts elsewhere — and failing a health check over it /// would report a working deployment as broken. +/// The most recent backup, so "can I go back?" is answerable without knowing the layout. +/// +/// Always `✓`, never a failure: having no backup is the correct state on a host that has never +/// overwritten anything, and a shard that is running fine does not become broken because nothing +/// has displaced a file yet. +fn backup_row(layout: &paths::Layout) -> Row { + let backups = crate::backup::list(layout); + let Some(newest) = backups.first() else { + return Row::ok("Backups", "none taken — no run has replaced a file yet"); + }; + let detail = match crate::backup::read_manifest(newest) { + Ok(manifest) => format!( + "{} — {} file(s) replaced by {} to bundle {}", + manifest.taken, + manifest.files.len(), + manifest.command, + manifest.bundle_to + ), + // A directory with an unreadable manifest is still a directory of the operator's files, so + // it is reported rather than skipped. + Err(_) => format!("{} — manifest unreadable", newest.display()), + }; + Row::ok("Backups", detail).note(format!( + "{} kept in {}", + backups.len(), + layout.backups_dir().display() + )) +} + fn bundle_row(record: &InstallRecord) -> Row { let url = bundle::url_for(None); let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) { diff --git a/src/install.rs b/src/install.rs index 017ff0b..254f746 100644 --- a/src/install.rs +++ b/src/install.rs @@ -38,7 +38,7 @@ use crate::record::{ }; use crate::servuo::ServUoRoot; use crate::util::TempDir; -use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; +use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; /// Which verb is driving the pipeline. /// @@ -185,6 +185,23 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let planned = overlay::plan(&unpacked, &root.path, prior_files)?; let summary = overlay::summarize(&planned); + // ── Backup ─────────────────────────────────────────────────────────────── + // Created before the first write and handed to every stage that overwrites, so each copy is + // taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily: + // a run that displaces nothing leaves nothing behind. + let mut backup = backup::Session::new( + &layout, + &root.path, + if mode.is_update() { + "update" + } else { + "install" + }, + prior.as_ref().map(|p| p.bundle.tag.clone()), + bundle.bundle.clone(), + !cli.verify && !cli.no_backup, + ); + ui::heading("Overlay sync"); let lines = overlay::render(&planned); if lines.is_empty() { @@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { summary.add, summary.change, summary.unchanged, summary.kept ); } else { + // `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what + // would replace it, and `KeptOperatorModified` is not written at all — copying those three + // would bury the files that are actually being displaced. + for file in planned + .iter() + .filter(|f| f.action == overlay::Action::Change) + { + backup.capture(&file.dst, backup::Reason::OverlayChange)?; + } 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. @@ -246,8 +272,18 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { .as_ref() .map(|p| p.patch_records()) .unwrap_or_default(), + &mut backup, )?; + // The config joins a backup that is already being taken; it is never the reason for one. The + // installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that + // a restored set of files comes with the token that matches them, rather than an operator + // restoring a tree and then finding the website pointed at a token that has moved on. + if backup.has_entries() { + backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?; + } + let backup_dir = backup.finish()?; + // ── The sidecar and its service ────────────────────────────────────────── let sidecar = install_sidecar( cli, @@ -294,6 +330,19 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { } } + // Named after the record rather than at the moment it was taken, because that is where an + // operator looks when a run has finished and something is wrong. Restoring is theirs to do: + // the installer cannot know what has changed since, and putting an old `.cs` file back over a + // newer overlay eats work rather than saving it. + if let Some(dir) = &backup_dir { + println!("\n Backed up {}", dir.display()); + println!( + " the files this run replaced, with a manifest naming each one.\n\ + \x20 The newest {} backups are kept; `uninstall --purge` removes them.", + backup::KEEP + ); + } + // ── Closing notes ──────────────────────────────────────────────────────── println!(); if cli.verify { diff --git a/src/lib.rs b/src/lib.rs index 0f41f3a..a832142 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ //! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering //! name. Nothing an operator sees changes. +pub mod backup; pub mod bundle; pub mod cli; pub mod diff; diff --git a/src/paths.rs b/src/paths.rs index dc97445..ed73b5c 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -68,6 +68,16 @@ impl Layout { 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 { diff --git a/src/tier.rs b/src/tier.rs index b041a04..00523fa 100644 --- a/src/tier.rs +++ b/src/tier.rs @@ -95,6 +95,7 @@ pub fn run( declared: Option<&Tier>, layout: &paths::Layout, prior: &[FeatureRecord], + backup: &mut crate::backup::Session, ) -> Result { let declared_tier = Tier::resolve(declared); if declared_tier.features.is_empty() { @@ -131,7 +132,7 @@ pub fn run( ), ); announce_new_features(&declared_tier, &tier); - return apply_tier(cli, root, unpacked, &tier, layout, prior, supported); + return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup); } match consent(cli, root, supported, &tier)? { @@ -157,7 +158,7 @@ pub fn run( } } - apply_tier(cli, root, unpacked, &tier, layout, prior, supported) + apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup) } /// The subset of a release's tier that a previous run actually applied. @@ -322,6 +323,7 @@ fn apply_tier( layout: &paths::Layout, prior: &[FeatureRecord], supported: bool, + backup: &mut crate::backup::Session, ) -> Result { let previous = patch::index_records(prior); let mut records: Vec = Vec::new(); @@ -353,7 +355,7 @@ fn apply_tier( } if !cli.verify { - write_feature(root, unpacked, feature, &resolved, layout)?; + write_feature(root, unpacked, feature, &resolved, layout, backup)?; } applied_patches += resolved.len(); @@ -521,9 +523,18 @@ fn write_feature( feature: &Feature, resolved: &[Resolved], layout: &paths::Layout, + backup: &mut crate::backup::Session, ) -> Result<()> { for r in resolved { if let Resolution::Applicable { edits, .. } = &r.resolution { + // `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the + // right thing to revert to. It is not a copy of what this file looked like before *this* + // run, though — on a second tier pass the operator's own later edits are only in the + // backup (PLAN.md §5.3). + backup.capture( + &patch::join(&root.path, &r.target), + crate::backup::Reason::PatchTarget, + )?; let original = patch::join(&layout.patch_originals_dir(), &r.target); if !original.exists() { write_atomic(&original, &r.content) @@ -542,6 +553,9 @@ fn write_feature( for companion in &feature.companions { let src = patch::join(unpacked, &companion.file); let dst = patch::join(&root.path, &companion.install_to); + // Copied unconditionally, like every other `.cs` the overlay owns — so an operator who + // edited one loses it here unless a copy is taken first. + backup.capture(&dst, crate::backup::Reason::PatchCompanion)?; if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; diff --git a/src/uninstall.rs b/src/uninstall.rs index 27a33bf..e34951e 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -121,11 +121,24 @@ pub fn run(cli: &Cli) -> Result { if cli.purge { remove_dir(&layout.patches_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() - )); + 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); @@ -181,6 +194,7 @@ fn print_intent( println!(" · {}", layout.install_record().display()); if purge { println!(" · {} [--purge]", layout.patches_dir().display()); + println!(" · {} [--purge]", layout.backups_dir().display()); } println!(); @@ -199,6 +213,14 @@ fn print_intent( " · {} (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!(); @@ -241,6 +263,7 @@ fn render_report( render_overlay_section(&mut out, record); render_patch_section(&mut out, record, layout, purge); + render_backup_section(&mut out, layout, purge); let _ = writeln!( out, @@ -373,6 +396,50 @@ fn render_patch_section( } } +/// 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 { let path = layout.patches_dir().join(format!("{name}.patch")); From c79374ff06bfc1b6b57ff90395375d4e90c6b8a4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 05:52:27 -0500 Subject: [PATCH 11/13] docs(readme): describe the tool that exists, not Phase 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §5.4. The status section still announced Phase 1 as built and Phase 2 as next, four phases later -- it is the first thing a visitor to this repo reads, and it has been wrong since Phase 2 merged. - The phase table now shows 1-4 built on `edge` and 5 in progress, and the opening says what the binary actually does. - "What the cutover is waiting on" is stated, because "nothing is released yet" invites the question: Phase 5, and the Windows SCM half never having been executed anywhere. - "Planned commands" is now "Commands". All four are implemented. - RUNICGATEWAY_STATE_DIR was described as relocating install.json. Since Phase 2 it relocates everything the installer writes, including the sidecar binary, and suppresses service registration -- an out-of-date description of where a tool writes is worse than none. - A design-constraint bullet for the backup behaviour Phase 5 adds. Co-Authored-By: Claude --- README.md | 50 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 89c280c..37ea320 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,13 @@ never restarts the shard. ## Status -**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.** +**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.** + +The binary does everything +[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md) +describes: bundle resolution, ServUO detection and validation, the overlay sync, +the opt-in patch tier, `install.json`, the uo-link sidecar and its service, the +token handoff, and `doctor` / `update` / `uninstall`. The design of record is [`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) @@ -42,17 +48,29 @@ sidecar + overlay combination, recomposed on every component release and nightly |---|---| | 0 — prerequisites in the other repos | ✅ merged | | 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` | -| 2 — uo-link install + service registration | next | -| 3 — the opt-in stock-file patch tier | | -| 4 — `doctor`, `update`, `uninstall` | | +| 2 — uo-link install + service registration | ✅ on `edge` | +| 3 — the opt-in stock-file patch tier | ✅ on `edge` | +| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` | +| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress | **Why `edge`:** `release.yml` publishes an installer binary on every push to -`main`, and a binary that deploys the overlay but cannot yet install the sidecar -is not something to hand an operator. Phases 1 and 2 land on `edge`; the -`edge → main` cutover cuts the first release. PRs into `edge` run the same gates -as PRs into `main`. +`main`, so nothing lands there until the whole tool is worth handing to an +operator. The `edge → main` cutover cuts the first release. PRs into `edge` run +the same gates as PRs into `main`. -Until then, the way to install is by hand — +**What the cutover is waiting on**, per PLAN.md §5: + +1. **Phase 5**, packaging polish — deliberately *before* the first release rather + than after it, because it changes the release layout, and shipping first would + mean a first release immediately superseded by the next. There is no `.deb` + and no MSI: both would give the sidecar binary, its service unit and its + service account a second owner beside this tool. +2. **The Windows SCM half verified on a real host.** `sc create`, the virtual + service account, the failure actions and the token-file ACL have never been + executed anywhere. Running the *systemd* half for real is what turned up a bug + no unit test had, so this is not a formality. + +Until the cutover, the way to install is by hand — [INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand) is the same deployment done with `curl`, `tar` and `systemctl`. @@ -66,7 +84,7 @@ is the same deployment done with `curl`, `tar` and `systemctl`. | [RunicGateway/website](https://gitea.whitlocktech.com/RunicGateway/website) | The public site and admin panel. The installer never contacts it — it prints values for Admin → Shard. | | [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. | -## Planned commands +## Commands | Command | What it does | |---|---| @@ -90,6 +108,10 @@ is the same deployment done with `curl`, `tar` and `systemctl`. - **A successful copy is not a working bridge.** ServUO ignores the script build's exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify post-boot state rather than trusting a clean boot. +- **What a run overwrites is copied first.** Every `.cs` file the overlay owns is + replaced unconditionally, so an operator's edit to one is saved under + `backups//` before it goes. Restoring is theirs to do — this tool + will not put an old file back over a newer release. - **The audience is public** — any ServUO operator, not only shards we run. ## Build & run @@ -103,8 +125,12 @@ cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothi cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test ``` -`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway` -or `%ProgramData%\RunicGateway`), which is how a run is tested without root. +`RUNICGATEWAY_STATE_DIR` relocates **everything the installer writes** — state, +data, and the sidecar binary (normally `/etc/runicgateway`, `/var/lib/runicgateway` +and `/usr/bin`, or `%ProgramData%\RunicGateway` and `%ProgramFiles%\RunicGateway`). +It also suppresses service registration, since there is no such thing as a +relocated systemd unit or Windows service. That is how a full run is tested +without root. Two layout notes that look odd until you know why: From 2787eaadffe0e536c2281f146b90edc901eda754 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 17:20:18 -0500 Subject: [PATCH 12/13] feat(installer): read bundles from the branch they are published to `main` is protected, so the compose job cannot push a bundle there -- the pre-receive hook declines it, which is not something a nightly cron can resolve. Bundles now publish to a branch of their own, at its root, so BUNDLE_BASE follows them. Everything the original choice was for survives the move: a reviewable diff, a git history of the compat matrix, and a plain anonymous URL that needs no credentials on the shard host. The test's bundle is now a frozen fixture rather than an include of the published file, which this checkout no longer carries. Frozen is the honest shape anyway: a test that silently re-targeted whatever CI published last would change meaning without a commit. It is still a real CI-emitted document, copied verbatim. Nothing is released from `edge`, so no shipped binary ever read the old URL. Co-Authored-By: Claude --- bundles/current.json | 40 ------------------- src/bundle.rs | 17 ++++++-- .../fixtures/published-bundle.json | 0 3 files changed, 13 insertions(+), 44 deletions(-) delete mode 100644 bundles/current.json rename bundles/bundle-2026.08.04.json => tests/fixtures/published-bundle.json (100%) diff --git a/bundles/current.json b/bundles/current.json deleted file mode 100644 index 7d99bb5..0000000 --- a/bundles/current.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schema": 1, - "bundle": "2026.08.04", - "generated": "2026-08-04T16:07:13Z", - "protocol": 3, - "link": { - "repo": "RunicGateway/link", - "tag": "v1.1.0", - "version": "1.1.0", - "protocol": 3, - "assets": { - "linux-x86_64": { - "name": "uo-link-sidecar-linux-x86_64", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64", - "sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9" - }, - "windows-x86_64": { - "name": "uo-link-sidecar-windows-x86_64.exe", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe", - "sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f" - } - } - }, - "overlay": { - "repo": "RunicGateway/servuo-plugins", - "tag": "v0.1.1", - "version": "0.1.1", - "commit": "3a52abbd77047e7c94883934533edcfef3ede555", - "protocol": 3, - "servuo": { - "min_version": "57.4", - "patches_verified_against": "57.4" - }, - "asset": { - "name": "runicgateway-overlay-0.1.1.tar.gz", - "url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz", - "sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7" - } - } -} diff --git a/src/bundle.rs b/src/bundle.rs index 59ff818..3791ce8 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -19,8 +19,13 @@ use std::collections::BTreeMap; /// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS — /// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1). +/// +/// They live on a **branch of their own**, not on `main`, and at its root. `main` is protected, so +/// the unattended compose job cannot push there — a pre-receive hook declines it, which is not +/// something a nightly cron can resolve. Everything the original choice was for survives the move: +/// a reviewable diff, a git history of the compat matrix, and a plain anonymous URL. const BUNDLE_BASE: &str = - "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles"; + "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles"; /// The only `schema` this build understands. const SUPPORTED_SCHEMA: u32 = 1; @@ -177,9 +182,13 @@ pub fn parse(body: &str) -> Result { mod tests { use super::*; - /// The first published bundle, verbatim from `bundles/current.json`. Using the real document - /// rather than a hand-written stand-in is the point: it is what CI actually emits. - const CURRENT: &str = include_str!("../bundles/current.json"); + /// The first published bundle, verbatim. Using a real document rather than a hand-written + /// stand-in is the point: it is what CI actually emits. + /// + /// It is a frozen copy rather than a live include, because published bundles moved off `main` + /// onto a branch this checkout does not carry. Frozen is the honest shape anyway — a test that + /// silently re-targeted whatever CI published last would change meaning without a commit. + const CURRENT: &str = include_str!("../tests/fixtures/published-bundle.json"); #[test] fn the_published_bundle_parses() { diff --git a/bundles/bundle-2026.08.04.json b/tests/fixtures/published-bundle.json similarity index 100% rename from bundles/bundle-2026.08.04.json rename to tests/fixtures/published-bundle.json From b7d1bbbc788c55917a1bf91eaf3aa778e0a2c9de Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 13:36:05 -0500 Subject: [PATCH 13/13] fix(service): diagnose 1053 as a handshake, not a bad config Every failed `sc.exe start` was reported with "a service that exits immediately usually cannot read its config", which for the one error code that actually occurs is the wrong place to look. 1053 is the SCM giving up after 30 seconds waiting for the process to identify itself; the process started fine and is very likely serving traffic. A reader who follows the old sentence goes and stares at a config file that is correct. Replace it with windows_start_failure(), which names the real cause per code: - 1053: a handshake failure, almost always a sidecar older than v1.2.0 (the first release that speaks the SCM protocol). Says how to check the version, and how to prove the binary is healthy by running it in the foreground. - 1069: the virtual service account was refused, which is local policy rather than a bad credential, and points at INSTALL.md Appendix A4. - anything else: does not guess, and hands over the event log, `sc query` for the service's own exit code, and the foreground command. Pure and tested on both platforms, like windows_bin_path above it, so the text is covered on the Linux CI runner that never sees an SCM. Co-Authored-By: Claude --- src/service.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/src/service.rs b/src/service.rs index ce18d95..75f072a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink"; pub const SERVICE_USER: &str = "runicgateway"; /// What both platforms show a human. const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar"; +/// The first `link` release whose sidecar speaks the Windows SCM startup protocol, and so the +/// oldest one that can be started as a service at all. Named only in the 1053 diagnosis; nothing +/// enforces it, because the Linux side has no such floor and a version gate on an installed binary +/// would refuse deployments that are working. +const MIN_SERVICE_SIDECAR: &str = "v1.2.0"; /// Which service manager this host has — or why it has none this installer can drive. #[derive(Debug, Clone, PartialEq, Eq)] @@ -307,6 +312,57 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String { format!("\"{}\" --config \"{}\"", binary.display(), config.display()) } +/// What to tell the operator when `sc.exe start` fails. +/// +/// Pure and tested on both platforms, because the *wrong* explanation here is expensive. This +/// originally blamed every failure on the config file — "a service that exits immediately usually +/// cannot read its config" — which for the one error code that actually shows up sends the reader +/// to inspect a file that is almost certainly fine. +/// +/// **1053 is not a crash.** It is the SCM giving up after 30 seconds waiting for the service +/// process to call `StartServiceCtrlDispatcher` and identify itself. The process starts, runs, and +/// is very likely serving traffic; it simply never had the conversation the SCM required. A sidecar +/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good +/// config — so the config is the last thing to look at, not the first. +pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String { + let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]); + match code { + 1053 => format!( + "`{command}` failed with 1053 — the service did not respond to the start request in \ + time.\n\n This is a handshake failure, not a crash: Windows waited 30 seconds for \ + the process to identify itself to the service control manager. The usual cause is a \ + sidecar built before the service support was added, which runs perfectly in the \ + foreground and can never start as a service. Check its version:\n\n \ + \"{binary}\" --version\n\n and confirm it is at least {MIN_SERVICE_SIDECAR}. To \ + see whether the sidecar itself is healthy, run it in the foreground — if that works, \ + the binary is the problem, not the configuration:\n\n \"{binary}\" --config \ + \"{config}\"", + binary = binary.display(), + config = config.display(), + ), + // ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this + // is a policy that forbids virtual service accounts rather than a wrong password. + 1069 => format!( + "`{command}` failed with 1069 — the service could not log on as {account}.\n\n \ + That account is a virtual service account created by the SCM itself and has no \ + password, so this is a local policy forbidding them rather than a bad credential. \ + Register the service by hand against an account this host allows — INSTALL.md \ + Appendix A4.", + account = windows_service_account(), + ), + _ => format!( + "`{command}` failed with exit code {code}.\n\n Check the Windows event log \ + (System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \ + the service's own exit code. A sidecar that exits immediately usually cannot read its \ + config: {}\n\n Running it in the foreground prints the reason:\n\n \ + \"{}\" --config \"{}\"", + config.display(), + binary.display(), + config.display(), + ), + } +} + #[cfg(windows)] fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result { let bin_path = windows_bin_path(binary, config); @@ -373,13 +429,11 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result