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 47ce699..da51b73 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 @@ -62,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: @@ -234,7 +241,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 @@ -251,6 +258,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' }} @@ -296,8 +304,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 ─────────────────────── @@ -374,7 +386,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/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0b7b0d2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,953 @@ +# 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", + "sha1", + "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 = "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" +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..183bbe9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,66 @@ +[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" + +# 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" + +# 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..37ea320 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,52 @@ never restarts the shard. ## Status -**Planning — no installer code exists 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) 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 | ✅ 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 | -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`, 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`. + +**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`. ## Related repos @@ -54,7 +84,7 @@ templates. | [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 | |---|---| @@ -78,17 +108,41 @@ templates. - **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 -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 **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: + +- **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/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/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..28fea89 --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,284 @@ +//! 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). +/// +/// 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/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`, `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, +} + +#[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: {}).\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 + .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"), + // 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"), + // 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, linux-aarch64 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. 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() { + 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!(bundle.overlay.asset.name.ends_with(".tar.gz")); + } + + #[test] + 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(); + 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] + 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..518e39c --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,349 @@ +//! 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 +//! 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 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, + 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`, `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 { + 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, + no_backup: 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. + On uninstall it means yes: that prompt + defaults to no, and typing `uninstall + --yes` is not an accident. + --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. + +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, + "--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, + "--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/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/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..fc7861a --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,916 @@ +//! 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)); + + // ── Backups ────────────────────────────────────────────────────────────── + rows.push(backup_row(&layout)); + + // ── 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. +/// 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)) { + 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 new file mode 100644 index 0000000..254f746 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,912 @@ +//! The `install` command — and, in [`Mode::Update`], the deployment half of `update`. +//! +//! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync +//! the overlay, run the optional patch tier, install the sidecar and register its service, record +//! what was deployed, and print the values the website needs. +//! +//! **`update` is this same pipeline, not a second one.** PLAN.md §5 Phase 4 describes it as +//! "re-resolve the bundle, then move both components to it" — which is what an `install` over an +//! existing deployment already does, down to keeping a modified `Bridge.cfg` and restarting the +//! service after replacing its binary. Writing it twice would mean two places for the sync rules, +//! the protocol cross-checks and the record-carrying logic to disagree. What actually differs is +//! decided by [`Mode`] and is small: where the ServUO root comes from, whether a prior record is +//! required, how much of the patch tier is in scope, and what is printed at the end. The +//! update-only parts live in [`crate::update`]. +//! +//! The order of the run is not incidental: +//! +//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this +//! platform, the ServUO root, and whether this process can write where it must. A run that +//! cannot finish should end before a single file enters the ServUO tree. +//! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree +//! happens together, on the near side of the running-shard check that guards it — and the tier +//! comes second because a feature's companion `.cs` lands in a directory the overlay creates. +//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes +//! the file the service definition points at, so the service is never started against a config +//! that does not exist yet. +//! 4. **Record last, print the handoff after that.** The token block is the final thing on screen +//! because it is the only thing the operator still has to act on. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use crate::cli::Cli; +use crate::record::{ + now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord, + ServUoRef, ServiceRecord, SCHEMA, +}; +use crate::servuo::ServUoRoot; +use crate::util::TempDir; +use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; + +/// Which verb is driving the pipeline. +/// +/// The two runs are the same deployment; what differs is what may be assumed. An `install` may be +/// the first thing that ever ran on this host, so it detects or asks for a ServUO root and offers +/// the patch tier. An `update` is by definition a second run, so it already knows the tree, and its +/// tier scope is what a previous run recorded rather than a fresh offer (PLAN.md §5 Phase 4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Install, + Update, +} + +impl Mode { + pub fn is_update(self) -> bool { + matches!(self, Self::Update) + } +} + +pub fn run(cli: &Cli) -> Result<()> { + deploy(cli, Mode::Install) +} + +pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { + let layout = paths::layout(); + let record_path = layout.install_record(); + // Loaded before anything else because `update` is defined by it: without a record there is + // nothing to update, and the honest answer is to say so before touching the network. + let prior = InstallRecord::load(&record_path)?; + if mode.is_update() { + crate::update::require_prior(prior.as_ref(), &record_path)?; + } + + // ── What to install ────────────────────────────────────────────────────── + // The bundle is resolved first, and its sidecar asset looked up immediately, so a run that + // cannot be completed fails here — before a single file has entered the ServUO tree. + let (bundle, bundle_url) = bundle::fetch(cli.bundle.as_deref())?; + let sidecar_asset = bundle.sidecar_asset()?.clone(); + + println!( + "\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}", + env!("CARGO_PKG_VERSION"), + if mode.is_update() { + "update" + } else { + "install" + }, + bundle.bundle, + bundle.protocol, + if cli.verify { + " [--verify: nothing will be written]" + } else { + "" + } + ); + println!(); + + // ── Where to install it ────────────────────────────────────────────────── + let root = resolve_root(cli, mode, prior.as_ref())?; + ui::row( + "ServUO", + &format!("{} ({})", root.path.display(), root.version_display()), + ); + // Reaching this line means `servuo::open_stopped` found no shard running out of this tree; a + // running one has already ended the run. + ui::row("Shard process", "not running"); + ui::row( + "Overlay", + &format!( + "{:<24} protocol {}", + format!("servuo-plugins {}", bundle.overlay.tag), + bundle.overlay.protocol + ), + ); + ui::row( + "Sidecar", + &format!( + "{:<24} protocol {}", + format!("uo-link {}", bundle.link.tag), + bundle.link.protocol + ), + ); + if !root.is_supported_version() { + println!(); + ui::warn(&format!( + "This tree reports ServUO {}. {} is the only supported version.\n \ + The base overlay only adds files and is expected to work broadly, so the install \ + continues.\n \ + The patch tier is the part that is version-sensitive — see INSTALL.md §4.", + root.version_display(), + servuo::SUPPORTED_VERSION + )); + } + + // Everything below writes into system directories. Finding out here beats finding out after + // the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot + // simply re-run their way out of. + if !cli.verify { + preflight_writable(&layout)?; + } + + // ── Fetch and unpack the overlay ───────────────────────────────────────── + let scratch = TempDir::new("runicgateway-installer")?; + let tarball = scratch.path().join(&bundle.overlay.asset.name); + println!(); + net::download_verified( + &bundle.overlay.asset.url, + &tarball, + &bundle.overlay.asset.sha256, + )?; + ui::ok(&format!( + "overlay tarball verified sha256 {}…", + &bundle.overlay.asset.sha256[..8.min(bundle.overlay.asset.sha256.len())] + )); + + let unpacked = overlay::extract(&tarball, &scratch.path().join("unpacked"))?; + let manifest = overlay::read_manifest(&unpacked)?; + overlay::verify_payload(&unpacked, &manifest)?; + + // The bundle and the artifact must agree. They are produced by different repos at different + // times, and gate 1 of the compose job (PLAN.md §7.1) is what normally keeps them in step — + // this is the same check applied to the artifact actually on disk. + if manifest.protocol != bundle.overlay.protocol { + bail!( + "the overlay release declares protocol {} but bundle {} recorded {}. \ + Refusing to deploy a pair that was never checked together.", + manifest.protocol, + bundle.bundle, + bundle.overlay.protocol + ); + } + if manifest.version != bundle.overlay.version { + bail!( + "bundle {} names overlay {} but the downloaded tarball contains {}", + bundle.bundle, + bundle.overlay.version, + manifest.version + ); + } + + // ── Plan the sync ──────────────────────────────────────────────────────── + let prior_files = prior_overlay_files(prior.as_ref(), &root); + + let planned = overlay::plan(&unpacked, &root.path, prior_files)?; + let summary = overlay::summarize(&planned); + + // ── 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() { + 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 { + // `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. + println!( + "\n {} add={} change={} unchanged={} kept={}", + if summary.writes_anything() { + "deployed." + } else { + "unchanged." + }, + summary.add, + summary.change, + summary.unchanged, + summary.kept + ); + } + + for file in planned + .iter() + .filter(|f| f.action == overlay::Action::KeptOperatorModified) + { + println!(); + ui::warn(&format!( + "{} has local edits — left exactly as it is.\n \ + The release ships its own copy of this file; if you want the new defaults, compare \ + yours against\n the one in {}\n and merge by hand. \ + Every other overlay file is code and is overwritten unconditionally.", + file.rel, bundle.overlay.asset.url + )); + } + + // ── The patch tier ─────────────────────────────────────────────────────── + // After the overlay, because a feature's companion `.cs` lands in the directory the overlay + // creates, and before the sidecar, because everything that edits the ServUO tree belongs on the + // near side of the running-shard check that guards it. + let tier = tier::run( + cli, + mode, + &root, + &unpacked, + manifest.patch_tier.as_ref(), + &layout, + &prior + .as_ref() + .map(|p| p.patch_records()) + .unwrap_or_default(), + &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, + &bundle, + &sidecar_asset, + &layout, + scratch.path(), + prior.as_ref(), + )?; + + // ── Record ─────────────────────────────────────────────────────────────── + let record = build_record( + prior.as_ref(), + &Deployment { + bundle: &bundle, + bundle_url: &bundle_url, + root: &root, + manifest: &manifest, + planned: &planned, + sidecar: sidecar.as_ref(), + tier: &tier, + verify: cli.verify, + }, + ); + + if cli.verify { + println!("\n {} not written (--verify)", record_path.display()); + } else { + match prior.as_ref() { + Some(previous) if previous.same_deployment_as(&record) => { + println!("\n {} unchanged", record_path.display()); + } + _ => { + record.save(&record_path).with_context(|| { + format!( + "cannot write {} — run as root/Administrator, or set {} to a writable \ + directory for a test run", + record_path.display(), + paths::STATE_DIR_ENV + ) + })?; + println!("\n Recorded {}", record_path.display()); + } + } + } + + // 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 { + // Worded for the verb that was typed, and said exactly once: `update`'s own closing block + // deliberately does not repeat it. + println!( + "Nothing was written. Re-run without --verify to {}.", + if mode.is_update() { "update" } else { "deploy" } + ); + } else if summary.writes_anything() || tier.core_rebuild { + if tier.core_rebuild { + // Said again here, after everything else, because it is the one step whose omission + // produces a shard that boots perfectly and never emits the events it was patched for. + println!( + "A CORE ServUO file was patched — rebuild the solution before starting:\n \ + dotnet build ServUO.sln\n" + ); + } + println!( + "Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\ + Start your shard when ready; the installer does not start it for you.\n\ + Note that ServUO ignores the script build's exit code, so a clean boot is not proof \ + the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \ + run `[bridge status` in game (INSTALL.md §6)." + ); + } else { + println!("The ServUO tree already has this overlay — nothing was changed there."); + } + + // ── The one manual step ────────────────────────────────────────────────── + // Last, and after the record, because it is the only thing left for the operator to do. The + // token goes to the terminal and nowhere else (PLAN.md §6). + // + // An `update` prints none of it. The token has not changed, the website already holds it, and + // reprinting a secret that nobody has to act on puts it in one more scrollback for no reason. + // What an update *can* change is the protocol number the website is configured with, and + // `update::closing` says so when it moved. + match (mode, &sidecar) { + (Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify), + (Mode::Install, Some(sidecar)) => { + let host = resolve_host(cli); + println!( + "{}", + sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref()) + ); + if !sidecar.service.registered() { + ui::warn( + "No service was registered, so nothing is listening yet — the values above \ + describe the sidecar\n once you start it. See the steps printed above.", + ); + } + } + (Mode::Install, None) => {} + } + Ok(()) +} + +/// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection +/// (confirmed), else a prompt. +/// +/// An update never prompts and never guesses. The tree it is updating is the one `install.json` +/// names — detection could plausibly find a *different* shard on a host that has two, and moving a +/// deployment to another tree is not something an `update` should be able to do by accident. +fn resolve_root(cli: &Cli, mode: Mode, prior: Option<&InstallRecord>) -> Result { + if let Some(path) = &cli.servuo { + return servuo::open_stopped(&PathBuf::from(path)); + } + if mode.is_update() { + if let Some(prior) = prior { + return servuo::open_stopped(&PathBuf::from(&prior.servuo.path)); + } + } + + if let Some(detected) = servuo::detect() { + let question = format!("Use the ServUO installation at {}?", detected.display()); + if ui::confirm(&question, true, cli.assume_yes)? { + return servuo::open_stopped(&detected); + } + } else if cli.assume_yes { + // --yes cannot invent a path, and picking one would be the worst possible guess. + bail!( + "no ServUO installation was found near this binary or the working directory. \ + Pass --servuo ." + ); + } + + let answer = ui::prompt("Path to your ServUO root", None) + .context("a ServUO root is required; pass --servuo for an unattended run")?; + servuo::open_stopped(&PathBuf::from(answer.trim().trim_matches('"'))) +} + +/// The previous run's file map, but only when it describes *this* tree. +/// +/// The map is what distinguishes an operator-edited `Bridge.cfg` from an upstream change, and that +/// judgement is only meaningful about the tree it was recorded for. A host whose record points at a +/// different root — a shard moved or rebuilt beside the old one — is treated as having no prior +/// deployment here, which errs toward keeping the operator's file. +fn prior_overlay_files<'a>( + prior: Option<&'a InstallRecord>, + root: &ServUoRoot, +) -> Option<&'a std::collections::BTreeMap> { + let prior = prior?; + if Path::new(&prior.servuo.path) != root.path { + return None; + } + prior.overlay_files() +} + +/// The sidecar half of a run: binary, config, service. `None` under `--verify`. +struct SidecarOutcome { + record: LinkRecord, + doc: sidecar::ConfigDoc, + service: service::Outcome, +} + +/// Installs the sidecar, provisions its config, and registers its service. +/// +/// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it, +/// **then** `--print-config` (which writes the config the service will be pointed at), then +/// register. Doing the last two the other way round registers a service against a file that does +/// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing +/// mistake. +fn install_sidecar( + cli: &Cli, + bundle: &bundle::Bundle, + asset: &bundle::Asset, + layout: &paths::Layout, + scratch: &Path, + prior: Option<&InstallRecord>, +) -> Result> { + ui::heading("uo-link sidecar"); + + let action = sidecar::decide(asset, &layout.sidecar_bin)?; + ui::row( + "binary", + &format!("{} {}", layout.sidecar_bin.display(), action.label()), + ); + + if cli.verify { + ui::row( + "config", + &format!( + "{} (would be provisioned)", + layout.sidecar_config().display() + ), + ); + ui::row("database", &layout.sidecar_db().display().to_string()); + println!( + "\n VERIFY only. Nothing installed, no service registered, no token read.\n \ + The token handoff needs a provisioned config, so it is only printed by a real run." + ); + return Ok(None); + } + + // Detect the service manager and make sure the account exists *before* a config file is + // written that then has to be owned by it. + let prepared = service::prepare(layout.relocated); + + let binary_sha256 = if action.writes() { + // The binary is locked on Windows while its service runs, and on Linux replacing it under a + // live process leaves the old code serving until something restarts it. + service::stop_for_replacement(&prepared.manager)?; + let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?; + ui::ok(&format!( + "sidecar binary verified sha256 {}…", + &asset.sha256[..8.min(asset.sha256.len())] + )); + sha + } else { + asset.sha256.trim().to_ascii_lowercase() + }; + + std::fs::create_dir_all(&layout.data_dir) + .with_context(|| format!("cannot create {}", layout.data_dir.display()))?; + + let config_path = layout.sidecar_config(); + let db_path = layout.sidecar_db(); + // The database is pinned by environment only where it does not already land beside the config. + // See `crate::paths` for why that is a platform difference rather than an inconsistency. + let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path()); + let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?; + + // The installed binary is the only thing that will actually speak to the website, so its + // protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number + // from source at the release tag; a disagreement means the pair in front of us is not the pair + // CI checked, and the sidecar would answer the website with 409 rather than mis-parsing. + if doc.protocol != bundle.protocol { + bail!( + "the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \ + Refusing to register a service for a pair that was never checked together — the \ + binary is installed but no service has been created.", + doc.protocol, + bundle.bundle, + bundle.protocol + ); + } + if doc.version != bundle.link.version { + ui::warn(&format!( + "the installed binary reports version {} but bundle {} names {}. The checksum matched, \ + so this is a labelling mismatch in the release rather than a wrong download.", + doc.version, bundle.bundle, bundle.link.version + )); + } + + ui::row( + "config", + &format!( + "{} {}", + doc.config_path, + if doc.config_created { + "created" + } else { + "already present" + } + ), + ); + ui::row("database", &doc.store.path); + ui::row( + "listening on", + &format!("shard {} website {}", doc.shard.bind, doc.web.bind), + ); + + // The config holds the auth token, and neither default location protects it on its own. This + // runs before registration so the file is never left readable while the rest of the run + // happens; on Windows the service account does not exist until `sc create` creates it, so the + // grant for it comes after. + service::protect_config( + &config_path, + &layout.data_dir, + prepared.user.as_deref(), + layout.relocated, + )?; + + let outcome = service::register(&prepared, layout, action.writes())?; + service::grant_service_access(&config_path, &layout.data_dir, &outcome)?; + let service_record = match &outcome { + service::Outcome::Registered { + kind, + name, + unit_path, + user, + user_created, + state, + } => { + ui::row("service", &format!("{name} {state}")); + if let Some(user) = user { + ui::row("running as", user); + } + Some(ServiceRecord { + kind: (*kind).to_string(), + name: name.clone(), + unit_path: unit_path.as_ref().map(|p| p.display().to_string()), + user: user.clone(), + user_created: *user_created || created_by_an_earlier_run(prior, user.as_deref()), + }) + } + service::Outcome::Skipped { reason, manual } => { + println!(); + ui::warn(&format!( + "service NOT REGISTERED — {reason}.\n \ + The binary and its config are in place; nothing is running them. Do this by hand:" + )); + print!("{manual}"); + None + } + }; + + Ok(Some(SidecarOutcome { + record: LinkRecord { + repo: bundle.link.repo.clone(), + tag: bundle.link.tag.clone(), + version: doc.version.clone(), + protocol: doc.protocol, + binary: BinaryRef { + path: layout.sidecar_bin.display().to_string(), + sha256: binary_sha256, + }, + config_path: doc.config_path.clone(), + db_path: doc.store.path.clone(), + service: service_record, + }, + doc, + service: outcome, + })) +} + +/// Whether an earlier run of this installer created the service account. +/// +/// **`user_created` has to be sticky, and this is why.** `service::prepare` answers "did *this run* +/// create the account", which is `false` on every run after the first — the account exists by then. +/// Recording that verbatim makes the field describe the run instead of the state, with two +/// consequences: `install.json` changes on an otherwise-identical second run (breaking Phase 1's +/// "a second run writes nothing"), and `uninstall` — which removes only an account it created — +/// silently leaves behind the very account this tool added. Both were caught on the first real +/// systemd host the installer ever ran on, and neither is visible on Windows, where the SCM's +/// virtual account is never "created" by us at all. +/// +/// Matched on the account *name*: a record naming a different user describes a different account, +/// and inheriting `true` from it would authorize deleting one this installer never made. +fn created_by_an_earlier_run(prior: Option<&InstallRecord>, user: Option<&str>) -> bool { + let (Some(prior), Some(user)) = (prior, user) else { + return false; + }; + prior + .link_record() + .and_then(|link| link.service) + .is_some_and(|service| service.user.as_deref() == Some(user) && service.user_created) +} + +/// Fails the run before it writes anything if this process cannot write where it must. +/// +/// `create_dir_all` succeeding is not the same question as "can this process write here" — it +/// returns `Ok` for a directory that already exists and is read-only to us — so each location is +/// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree +/// that has already been deployed into. +fn preflight_writable(layout: &paths::Layout) -> Result<()> { + let bin_dir = layout + .sidecar_bin + .parent() + .unwrap_or(&layout.sidecar_bin) + .to_path_buf(); + for dir in [&layout.state_dir, &layout.data_dir, &bin_dir] { + std::fs::create_dir_all(dir) + .and_then(|_| { + let probe = dir.join(".runicgateway-write-test"); + std::fs::write(&probe, b"")?; + std::fs::remove_file(&probe) + }) + .with_context(|| { + format!( + "cannot write to {}.\n \ + Run this installer as {}, or set {} to a writable directory for a test run \ + (no service is registered then).", + dir.display(), + if cfg!(windows) { + "Administrator, from an elevated PowerShell" + } else { + "root (sudo)" + }, + paths::STATE_DIR_ENV + ) + })?; + } + Ok(()) +} + +/// The hostname the website should use to reach this machine. +/// +/// Only ever printed, never connected to — which is why a non-interactive run falls back to the +/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard; +/// aborting a completed install over an unanswerable prompt costs them the whole run. +fn resolve_host(cli: &Cli) -> String { + if let Some(host) = &cli.host { + return host.clone(); + } + let detected = sysinfo::System::host_name() + .filter(|h| !h.trim().is_empty()) + .unwrap_or_else(|| "this-host".to_string()); + if cli.assume_yes { + return detected; + } + let answer = ui::prompt( + "Hostname your website should use to reach this machine", + Some(&detected), + ); + answer.unwrap_or(detected) +} + +/// Everything one run produced, gathered so the record can be built from a single value. +/// +/// The three halves are assembled at different points and the record needs all of them, which is +/// how this grew a parameter per step. A struct keeps the call site readable and, more usefully, +/// makes it obvious at a glance that nothing else feeds `install.json`. +struct Deployment<'a> { + bundle: &'a bundle::Bundle, + bundle_url: &'a str, + root: &'a ServUoRoot, + manifest: &'a overlay::Manifest, + planned: &'a [overlay::PlannedFile], + sidecar: Option<&'a SidecarOutcome>, + tier: &'a tier::Outcome, + /// A dry run reports everything and records nothing, so every "carry the previous value + /// through" branch below turns on it. + verify: bool, +} + +fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord { + let Deployment { + bundle, + bundle_url, + root, + manifest, + planned, + sidecar, + tier, + verify, + } = *run; + + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + }, + updated: now_rfc3339(), + bundle: BundleRef { + tag: bundle.bundle.clone(), + protocol: bundle.protocol, + url: bundle_url.to_string(), + }, + servuo: ServUoRef { + path: root.path.to_string_lossy().to_string(), + version: root.version.clone(), + }, + overlay: Some(OverlayRecord { + repo: manifest.repo.clone(), + tag: bundle.overlay.tag.clone(), + version: manifest.version.clone(), + commit: manifest.commit.clone(), + protocol: manifest.protocol, + files: overlay::file_records(planned), + }), + // A `--verify` run installs no sidecar and must not erase the record of one that is + // already there; the same reasoning keeps the patch section and any field a + // newer installer wrote intact. A record that forgot a running service would make + // `doctor` and `uninstall` forget it too. + link: match sidecar { + Some(outcome) => serde_json::to_value(&outcome.record).ok(), + None => prior.and_then(|p| p.link.clone()), + }, + // The tier's own records replace the section only when it actually ran and wrote. A + // declined tier, a refused one, and a `--verify` dry run all leave the previous record + // exactly as it was — an install where the operator said "not this time" must not erase + // the evidence of patches applied on an earlier one. + patches: match (tier.ran && !verify, prior) { + (true, _) => tier + .records + .iter() + .filter_map(|r| serde_json::to_value(r).ok()) + .collect(), + (false, Some(previous)) => previous.patches.clone(), + (false, None) => Vec::new(), + }, + extra: prior.map(|p| p.extra.clone()).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{FileRecord, ServUoRef}; + use std::collections::BTreeMap; + + fn record_for(path: &str) -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: now_rfc3339(), + bundle: BundleRef { + tag: "2026.08.04".into(), + protocol: 3, + url: "https://example/current.json".into(), + }, + servuo: ServUoRef { + path: path.into(), + version: Some("57.4".into()), + }, + overlay: Some(OverlayRecord { + repo: "RunicGateway/servuo-plugins".into(), + tag: "v0.1.1".into(), + version: "0.1.1".into(), + commit: "3a52abb".into(), + protocol: 3, + files: BTreeMap::from([( + "Config/Bridge.cfg".to_string(), + FileRecord { + overlay_sha256: "aa".into(), + on_disk_sha256: "bb".into(), + state: "kept-operator-modified".into(), + }, + )]), + }), + link: None, + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + fn root_at(path: &str) -> ServUoRoot { + ServUoRoot { + path: PathBuf::from(path), + version: Some("57.4".into()), + } + } + + #[test] + fn an_account_this_installer_created_stays_recorded_as_created() { + // Caught on the first real systemd host: `prepare` reports "did THIS run create it", which + // is false from the second run on. Recording that verbatim rewrote install.json on an + // identical re-run and, worse, left `uninstall` believing the account was somebody else's. + let mut record = record_for("/opt/ServUO"); + let service = |user: &str, created: bool| ServiceRecord { + kind: "systemd".into(), + name: "runicgateway-link.service".into(), + unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()), + user: Some(user.into()), + user_created: created, + }; + let with_service = |record: &mut InstallRecord, service: ServiceRecord| { + record.link = serde_json::to_value(LinkRecord { + repo: "RunicGateway/link".into(), + tag: "v1.1.0".into(), + version: "1.1.0".into(), + protocol: 3, + binary: BinaryRef { + path: "/usr/bin/runicgateway-link".into(), + sha256: "aa".into(), + }, + config_path: "/etc/runicgateway/sidecar.toml".into(), + db_path: "/var/lib/runicgateway/uo-link.db".into(), + service: Some(service), + }) + .ok(); + }; + + with_service(&mut record, service("runicgateway", true)); + assert!(created_by_an_earlier_run( + Some(&record), + Some("runicgateway") + )); + + // An account this installer found already there stays somebody else's, forever. + with_service(&mut record, service("runicgateway", false)); + assert!(!created_by_an_earlier_run( + Some(&record), + Some("runicgateway") + )); + + // A record naming a different account says nothing about this one — inheriting `true` + // there would authorize deleting a user this installer never made. + with_service(&mut record, service("someone-else", true)); + assert!(!created_by_an_earlier_run( + Some(&record), + Some("runicgateway") + )); + + assert!(!created_by_an_earlier_run(None, Some("runicgateway"))); + assert!(!created_by_an_earlier_run(Some(&record), None)); + } + + #[test] + fn a_record_for_this_tree_is_used() { + let record = record_for("/opt/ServUO"); + let files = prior_overlay_files(Some(&record), &root_at("/opt/ServUO")); + assert!(files.is_some_and(|f| f.contains_key("Config/Bridge.cfg"))); + } + + #[test] + fn a_record_for_a_different_tree_is_ignored() { + // Otherwise a second shard on the same host would inherit the first's hashes and could + // have its Bridge.cfg overwritten on the strength of a comparison that never applied to it. + let record = record_for("/opt/ServUO-old"); + assert!(prior_overlay_files(Some(&record), &root_at("/opt/ServUO")).is_none()); + assert!(prior_overlay_files(None, &root_at("/opt/ServUO")).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a832142 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,117 @@ +//! 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 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. `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` +//! +//! 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 backup; +pub mod bundle; +pub mod cli; +pub mod diff; +pub mod doctor; +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 uninstall; +pub mod update; +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; + } + }; + + // 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(0) + } + Mode::Version => { + println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION")); + Ok(0) + } + 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), + }; + + 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 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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/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..2049e74 --- /dev/null +++ b/src/net.rs @@ -0,0 +1,128 @@ +//! 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")) +} + +/// 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. +fn agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .user_agent(user_agent()) + .timeout_global(Some(timeout)) + .build() + .into() +} + +/// Fetches a small text document (the bundle manifest). +pub fn get_text(url: &str) -> Result { + 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}"))?; + 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(DEFAULT_TIMEOUT) + .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..83b7d0d --- /dev/null +++ b/src/overlay.rs @@ -0,0 +1,703 @@ +//! 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, + /// 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, +} + +/// 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(), + }, + patch_tier: None, + 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/patch.rs b/src/patch.rs new file mode 100644 index 0000000..4d1c96c --- /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_key(|e| std::cmp::Reverse(e.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_key(|e| std::cmp::Reverse(e.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 new file mode 100644 index 0000000..ed73b5c --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,208 @@ +//! Where the installer's own files live. +//! +//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the +//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its +//! working directory, and a service manager's working directory is not somewhere to put a database. +//! The installer therefore owns the layout and pins the config path into the service definition. +//! +//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The +//! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml` +//! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the +//! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the +//! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries +//! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which +//! every process on the host inherits and which outlives an uninstall. + +use std::env; +use std::path::PathBuf; + +/// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than +/// hidden: an undocumented environment variable that moves where a tool writes is worse than a +/// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote. +pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + /// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`. + pub state_dir: PathBuf, + /// `/var/lib/runicgateway` — the sidecar's SQLite store. + pub data_dir: PathBuf, + /// `/usr/bin/runicgateway-link` — the installed sidecar binary. + pub sidecar_bin: PathBuf, + /// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real + /// deployment. Service registration is skipped when it is set — see [`layout`]. + pub relocated: bool, +} + +impl Layout { + pub fn install_record(&self) -> PathBuf { + self.state_dir.join("install.json") + } + + pub fn sidecar_config(&self) -> PathBuf { + self.state_dir.join("sidecar.toml") + } + + pub fn sidecar_db(&self) -> PathBuf { + self.data_dir.join("uo-link.db") + } + + /// `/etc/runicgateway/patches` — the cached patch set (INSTALL.md §3). + /// + /// Every patch the tier *evaluated* is cached here, not only the ones that applied. `uninstall` + /// needs the applied ones to print the exact hunks to revert long after the release tarball is + /// gone (PLAN.md §5), and a refused one is the file the run just told the operator to apply by + /// hand — pointing them at a path that only exists on success would be the less useful half. + pub fn patches_dir(&self) -> PathBuf { + self.state_dir.join("patches") + } + + /// `/etc/runicgateway/patches/originals` — each patched file exactly as it was before the tier + /// first touched it, mirroring its path in the ServUO tree. + /// + /// The tier edits files the operator owns, so the pre-image is what turns "here are the hunks + /// we added" into a revert anyone can verify. It lives here rather than beside the file it + /// copies, because an installer-owned file inside the ServUO tree is one `uninstall` has + /// promised never to clean up. + pub fn patch_originals_dir(&self) -> PathBuf { + self.patches_dir().join("originals") + } + + /// `/etc/runicgateway/backups` — one dated directory per run that overwrote something + /// (PLAN.md §5.3). + /// + /// Beside the cached patch set rather than inside it: both survive an uninstall and both go + /// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy + /// of what the *release* shipped. + pub fn backups_dir(&self) -> PathBuf { + self.state_dir.join("backups") + } + + /// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated + /// layout, where no service is registered at all. + pub fn systemd_unit(&self) -> PathBuf { + PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT) + } +} + +/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`]. +/// +/// The override moves **everything the installer would write**: state, data, and the sidecar +/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that +/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the +/// half-in-the-real-system accident this variable exists to avoid. +/// +/// A relocated layout also **suppresses service registration** (see `service::ensure`). There is +/// no such thing as a relocated systemd unit or a relocated Windows service — both are +/// system-global — so the honest behaviour is to install the files, say plainly that no service was +/// registered, and print what a real run would have done. +pub fn layout() -> Layout { + let mut layout = platform_layout(); + if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) { + let root = PathBuf::from(dir); + // The file name is kept so a relocated run installs the same binary name a real one would, + // which is what makes `--print-config` and `--version` output comparable between the two. + let bin_name = layout + .sidecar_bin + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("uo-link-sidecar")); + layout.data_dir = root.join("data"); + layout.sidecar_bin = root.join("bin").join(bin_name); + layout.state_dir = root; + layout.relocated = true; + } + layout +} + +#[cfg(windows)] +fn platform_layout() -> Layout { + // %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to + // C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic. + let program_data = env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + let program_files = env::var_os("ProgramFiles") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")); + + // Data lives under ProgramData, never under ProgramFiles: a service writing beneath + // C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3). + Layout { + state_dir: program_data.join("RunicGateway"), + data_dir: program_data.join("RunicGateway"), + sidecar_bin: program_files + .join("RunicGateway") + .join("uo-link-sidecar.exe"), + relocated: false, + } +} + +#[cfg(not(windows))] +fn platform_layout() -> Layout { + Layout { + state_dir: PathBuf::from("/etc/runicgateway"), + data_dir: PathBuf::from("/var/lib/runicgateway"), + sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), + relocated: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_installers_own_files_sit_in_the_state_dir() { + // Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to + // clean and `doctor` has one place to read. The cached patch set and the pre-image + // copies of every patched file live there too. + let l = platform_layout(); + assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path())); + assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path())); + } + + #[test] + fn the_default_layout_is_absolute() { + // A relative state directory would reintroduce exactly the working-directory trap this + // layout exists to close. + let l = platform_layout(); + assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir); + assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir); + assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin); + assert!(!l.relocated); + } + + #[test] + fn the_windows_database_lands_beside_its_config_by_default() { + // The Windows service pins only the config path; the database follows because the sidecar + // anchors a relative [store].path to the config's directory. That only holds while these + // two directories are the same one, so it is asserted rather than assumed. + #[cfg(windows)] + { + let l = platform_layout(); + assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent()); + } + } + + #[test] + fn 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 new file mode 100644 index 0000000..b7bc42b --- /dev/null +++ b/src/record.rs @@ -0,0 +1,387 @@ +//! `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, + /// 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, + /// The patch tier: one entry per feature actually in place, with the rung that applied each of + /// 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`]. + /// 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. + #[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, +} + +/// 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. + /// + /// 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) + } + + /// 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() + } + + /// 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 { + 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 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(); + assert!(now.ends_with('Z'), "{now}"); + assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}"); + } +} diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..75f072a --- /dev/null +++ b/src/service.rs @@ -0,0 +1,1169 @@ +//! 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::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}; + +/// 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"; +/// 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)] +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]).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 + // 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()) +} + +/// 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); + 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!(windows_start_failure( + start.status.code().unwrap_or(-1), + binary, + config + )); + } + + 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(()), + } +} + +/// 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 +/// 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 error_1053_is_diagnosed_as_a_handshake_not_a_bad_config() { + // The regression this guards: 1053 used to be reported as "a service that exits immediately + // usually cannot read its config", which is the one thing it almost never is. A reader who + // follows that sentence goes and stares at a config file that is fine. + let msg = windows_start_failure( + 1053, + Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"), + Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"), + ); + assert!(msg.contains("1053"), "{msg}"); + assert!(msg.contains("handshake"), "{msg}"); + assert!(!msg.contains("cannot read its config"), "{msg}"); + // It has to name the two things that actually resolve it: check the version, and prove the + // binary is healthy by running it in the foreground. + assert!(msg.contains("--version"), "{msg}"); + assert!(msg.contains(MIN_SERVICE_SIDECAR), "{msg}"); + assert!(msg.contains("uo-link-sidecar.exe"), "{msg}"); + assert!(msg.contains("sidecar.toml"), "{msg}"); + } + + #[test] + fn a_logon_failure_points_at_policy_not_a_password() { + let msg = windows_start_failure(1069, Path::new("bin.exe"), Path::new("c.toml")); + assert!(msg.contains("NT SERVICE\\RunicGatewayLink"), "{msg}"); + assert!(msg.contains("policy"), "{msg}"); + } + + #[test] + fn an_unrecognized_code_still_says_how_to_see_the_real_error() { + // The fallback must not pretend to know the cause; it must hand over the two places the + // cause is actually written down. + let msg = windows_start_failure(5, Path::new("bin.exe"), Path::new("c.toml")); + assert!(msg.contains("exit code 5"), "{msg}"); + assert!(msg.contains("event log"), "{msg}"); + assert!(msg.contains("sc query RunicGatewayLink"), "{msg}"); + assert!(msg.contains("--config"), "{msg}"); + } + + #[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}"); + } + #[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 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}"); + } + } + + #[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/servuo.rs b/src/servuo.rs new file mode 100644 index 0000000..1b7ea64 --- /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 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 is what gates the patch tier. + 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/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/tier.rs b/src/tier.rs new file mode 100644 index 0000000..00523fa --- /dev/null +++ b/src/tier.rs @@ -0,0 +1,1072 @@ +//! 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`]). +/// +/// ## 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], + backup: &mut crate::backup::Session, +) -> Result { + let declared_tier = Tier::resolve(declared); + if declared_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(); + + // 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, backup); + } + + 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, backup) +} + +/// 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. + 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, + backup: &mut crate::backup::Session, +) -> 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, backup)?; + } + 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(), + )); + } + + // 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( + "Patch tier", + &format!( + "{applied_patches} of {} {}", + tier.patch_count(), + if cli.verify { + "would be applied [--verify]" + } else { + "applied" + } + ), + ); + 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!(); + 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, + 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) + .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); + // 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()))?; + } + 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 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 + // 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/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/uninstall.rs b/src/uninstall.rs new file mode 100644 index 0000000..e34951e --- /dev/null +++ b/src/uninstall.rs @@ -0,0 +1,707 @@ +//! 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); + remove_dir(&layout.backups_dir(), &mut done, &mut problems); + } else { + if layout.patches_dir().exists() { + done.push(format!( + "kept {} — the cached patches and the pre-patch originals you need to revert by hand", + layout.patches_dir().display() + )); + } + // Same rule and the same reason as the patch cache: a backup is the only copy of what this + // host had before an upgrade replaced it, and it outlives the deployment that took it. + let backups = crate::backup::list(&layout); + if !backups.is_empty() { + done.push(format!( + "kept {} — {} backup(s) of files earlier runs replaced", + layout.backups_dir().display(), + backups.len() + )); + } + } + remove_file(&record_path, &mut done, &mut problems); + + for line in &done { + println!(" · {line}"); + } + for problem in &problems { + println!(); + ui::warn(problem); + } + + // ── What only the operator can do ──────────────────────────────────────── + print!("{report}"); + match &report_path { + Ok(path) => println!("This report is also saved at:\n {}\n", path.display()), + Err(error) => ui::warn(&format!( + "The report above could not be saved to a file ({error}) — copy it out of this \ + terminal before you lose it." + )), + } + + // A step that could not be carried out is worth an exit code, for the same reason `doctor` has + // one: the run itself succeeded, and only the shell knows whether anybody is reading the + // output. Everything that *could* be removed still was. + Ok(if problems.is_empty() { 0 } else { 1 }) +} + +/// Says exactly what will happen, before asking. Nothing here touches the disk. +fn print_intent( + record: &InstallRecord, + link: Option<&LinkRecord>, + layout: &paths::Layout, + purge: bool, +) { + println!(); + ui::heading("This will remove"); + match link { + Some(link) => { + if let Some(service) = &link.service { + println!(" · the {} service", service.name); + if let (Some(user), true) = (service.user.as_deref(), service.user_created) { + println!(" · the {user} account, which the installer created"); + } + } + println!(" · {}", link.binary.path); + if purge { + println!(" · {} [--purge]", link.config_path); + println!(" · {} [--purge]", link.db_path); + } + } + None => println!(" · (no sidecar is recorded on this host)"), + } + println!(" · {}", layout.install_record().display()); + if purge { + println!(" · {} [--purge]", layout.patches_dir().display()); + println!(" · {} [--purge]", layout.backups_dir().display()); + } + + println!(); + ui::heading("This will NOT touch"); + println!(" · your ServUO tree — every deployed file is listed for you to delete"); + println!( + " · any patched stock file — the hunks to revert are printed with the rung each landed at" + ); + println!(" · your shard, which is neither stopped nor started"); + if !purge { + if let Some(link) = link { + println!(" · {} (the auth token)", link.config_path); + println!(" · {} (event history)", link.db_path); + } + println!( + " · {} (cached patches and pre-patch originals)", + layout.patches_dir().display() + ); + let backups = crate::backup::list(layout); + if !backups.is_empty() { + println!( + " · {} ({} backup(s) of files earlier runs replaced)", + layout.backups_dir().display(), + backups.len() + ); + } + } + let _ = record; + println!(); +} + +/// The report: everything the operator has to finish by hand. +fn render_report( + record: &InstallRecord, + link: Option<&LinkRecord>, + layout: &paths::Layout, + purge: bool, +) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n", + "", + now_rfc3339(), + env!("CARGO_PKG_VERSION"), + "" + ); + let _ = writeln!( + out, + "ServUO tree {}{}", + record.servuo.path, + record + .servuo + .version + .as_ref() + .map(|v| format!(" ({v})")) + .unwrap_or_default() + ); + if let Some(link) = link { + // Stated flatly, with no claim about what this run managed to remove: the report is + // rendered from the record and is about what is *left* to do. A line asserting "(removed)" + // is a line that can be wrong — a binary locked by a still-running process is exactly the + // case where it would be. + let _ = writeln!(out, "uo-link {}", link.version); + } + + render_overlay_section(&mut out, record); + render_patch_section(&mut out, record, layout, purge); + render_backup_section(&mut out, layout, purge); + + let _ = writeln!( + out, + "The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\ + what you have changed in those files since they were deployed. Both lists above are\n\ + yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n" + ); + out +} + +/// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed. +/// +/// The flag is the point: an operator deleting this list file by file must not lose their own +/// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those +/// are. +fn render_overlay_section(out: &mut String, record: &InstallRecord) { + let Some(overlay) = &record.overlay else { + return; + }; + let root = Path::new(&record.servuo.path); + let _ = writeln!( + out, + "\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\ + {} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n", + overlay.files.len(), + overlay.version + ); + + for (rel, file) in &overlay.files { + let path = patch::join(root, rel); + let note = match util::sha256_file(&path) { + Err(_) => " (already gone)", + Ok(actual) if actual == file.on_disk_sha256 => "", + Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting", + }; + let _ = writeln!(out, " {}{note}", path.display()); + } + let _ = writeln!( + out, + "\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n" + ); +} + +/// The exact hunks each applied patch added, rendered from the cached `.patch` files. +/// +/// Rendered rather than referenced: the release tarball is long gone by the time somebody reads +/// this, and "apply the reverse of the patch" is not something an operator can do from a filename. +/// The rung each hunk landed at is printed with it, because a `region-match` apply means the +/// surrounding file was already the operator's and deserves a closer look than a stock-hash one. +fn render_patch_section( + out: &mut String, + record: &InstallRecord, + layout: &paths::Layout, + purge: bool, +) { + let features = record.patch_records(); + if features.is_empty() { + return; + } + + let _ = writeln!( + out, + "\n── Stock ServUO files this installer patched ────────────────────────────────\n" + ); + if features.iter().any(|f| f.unsupported_servuo) { + let _ = writeln!( + out, + " ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n", + features + .iter() + .find_map(|f| f.servuo_version.clone()) + .unwrap_or_else(|| "unknown".into()) + ); + } + + for feature in &features { + let _ = writeln!(out, " Feature: {}", feature.feature); + for applied in &feature.patches { + let _ = writeln!( + out, + "\n {} → {}\n applied by: {}", + applied.name, + patch::join(Path::new(&record.servuo.path), &applied.target).display(), + applied.rung + ); + match render_hunks(layout, &applied.name, &applied.sha256) { + Some(text) => out.push_str(&text), + None => { + let _ = writeln!( + out, + " (the cached copy of this patch could not be read — the hunks it added \ + start\n near line {})", + applied + .hunks + .first() + .map(|h| h.matched_line) + .unwrap_or_default() + ); + } + } + } + if !feature.companions.is_empty() { + let _ = writeln!(out, "\n Companion files added by this feature:"); + for companion in &feature.companions { + let _ = writeln!( + out, + " {}", + patch::join(Path::new(&record.servuo.path), &companion.path).display() + ); + } + } + let _ = writeln!(out); + } + + let originals = layout.patch_originals_dir(); + if purge { + let _ = writeln!( + out, + " The pre-patch copies of these files were removed by --purge, so the lines above are\n\ + the only record of what changed.\n" + ); + } else if originals.exists() { + let _ = writeln!( + out, + " Each of those files as it was BEFORE the tier first touched it is kept here:\n \ + {}\n Diff against it rather than reversing the hunks by eye — after a region-match \ + apply the\n rest of the file was already yours.\n", + originals.display() + ); + } +} + +/// The backups earlier runs took, since this report is the durable record of what was left behind. +/// +/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by +/// the time this report is read the run that took it is long out of the scrollback. +fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) { + let backups = crate::backup::list(layout); + if backups.is_empty() { + return; + } + if purge { + let _ = writeln!( + out, + " +── Backups ────────────────────────────────────────────────────────────────── + + {} backup(s) of files earlier runs replaced were removed by --purge. +", + backups.len() + ); + return; + } + let _ = writeln!( + out, + " +── Backups ────────────────────────────────────────────────────────────────── + + Copies of the files earlier runs replaced, newest first. These are kept: +" + ); + for dir in &backups { + let count = crate::backup::read_manifest(dir) + .map(|m| m.files.len()) + .unwrap_or(0); + let _ = writeln!(out, " {} ({} file(s))", dir.display(), count); + } + let _ = writeln!( + out, + " + Each carries a manifest.json naming where every file came from. Restoring is yours to + do — this tool will not put an old file back over a newer one. `--purge` removes them. +" + ); +} + +/// Renders one cached patch's added and removed lines, indented for the report. +fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option { + 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:?}"); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..abc3ccc --- /dev/null +++ b/src/util.rs @@ -0,0 +1,365 @@ +//! 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::{bail, 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. +/// +/// 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); + 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())) +} + +/// 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 +/// 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(()) +} + +/// 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::*; + + // 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 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()); + 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 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(); + 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()); + } +} 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/fixtures/published-bundle.json b/tests/fixtures/published-bundle.json new file mode 100644 index 0000000..7d99bb5 --- /dev/null +++ b/tests/fixtures/published-bundle.json @@ -0,0 +1,40 @@ +{ + "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/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 + ); + } + } +}