Merge pull request 'Cutover: promote the installer from edge to main' (#17) from edge into main
Some checks failed
Release installer / release (push) Failing after 3m21s
sync-project-tree / sync (push) Successful in -22s

Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-08-07 20:05:42 +00:00
32 changed files with 12130 additions and 25 deletions

View File

@@ -7,12 +7,12 @@
# The one structural difference is the crate guard below. # The one structural difference is the crate guard below.
# #
# ── Crate guard ────────────────────────────────────────────────────────────── # ── Crate guard ──────────────────────────────────────────────────────────────
# This repo is in the planning phase and has no Cargo project yet (the design of # The gates are conditional on a root Cargo.toml existing: before the crate
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather # landed, this job reported green with a notice so governance/docs PRs were not
# than leave the repo ungated until then — or land a workflow that red-Xes every # red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
# governance/docs PR — the gates are conditional on a root Cargo.toml existing. # `edge`, so the gates arm themselves there automatically — and stay dormant on
# Before the crate lands, the job reports green with a notice. The moment # a `main` PR until the cutover merges the crate into it. Nothing here changes at
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change. # that point either.
# #
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar): # 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 # this repo's sole product is the one installer binary, so there is nothing to
@@ -35,7 +35,12 @@ name: PR Checks
on: on:
pull_request: 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. # A newer push to the same PR cancels the in-flight run.
concurrency: concurrency:

View File

@@ -21,10 +21,14 @@
# • Artifact names follow docs/installer/PLAN.md §3. # • Artifact names follow docs/installer/PLAN.md §3.
# #
# ── Crate guard ────────────────────────────────────────────────────────────── # ── Crate guard ──────────────────────────────────────────────────────────────
# The repo is in the planning phase. With no Cargo.toml there is nothing to # With no Cargo.toml at the repo root there is nothing to build, so the plan step
# build, so the plan step forces RELEASE=false and the job exits green having # forces RELEASE=false and the job exits green having done nothing.
# done nothing. It starts cutting real releases the moment Phase 1 lands the #
# crate — no edit required here. # 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 ──────────────────────────────────────────────────────── # ── Unsigned releases ────────────────────────────────────────────────────────
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is # Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
@@ -62,6 +66,9 @@ env:
BIN: runicgateway-installer BIN: runicgateway-installer
LINUX_TARGET: x86_64-unknown-linux-gnu LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-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: jobs:
release: release:
@@ -234,7 +241,7 @@ jobs:
echo "Release credentials present." echo "Release credentials present."
# ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── # ── 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' }} if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -251,6 +258,7 @@ jobs:
export PATH="${HOME}/.cargo/bin:${PATH}" export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}" rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}"
- name: Set the crate version to match the release - name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
@@ -296,8 +304,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" 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" 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 ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── # ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
@@ -374,7 +386,7 @@ jobs:
| jq -r '.id')" | jq -r '.id')"
echo "Created release ${TAG} (id=${REL_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}" \ curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null -F "attachment=@dist/${f}" >/dev/null

953
Cargo.lock generated Normal file
View File

@@ -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"

66
Cargo.toml Normal file
View File

@@ -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 <old>..<new>` 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

View File

@@ -27,22 +27,52 @@ never restarts the shard.
## Status ## 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 The design of record is
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) [`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 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 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 | Phase | State |
install already exists and is published**, ahead of the binary that installs it: |---|---|
[`bundles/current.json`](bundles/current.json) names the current | 0 — prerequisites in the other repos | ✅ merged |
protocol-checked sidecar + overlay combination, recomposed on every component | 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
release and nightly. See [`bundles/README.md`](bundles/README.md). | 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 **Why `edge`:** `release.yml` publishes an installer binary on every push to
templates. `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 ## 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/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. | | [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. |
## Planned commands ## Commands
| Command | What it does | | Command | What it does |
|---|---| |---|---|
@@ -78,17 +108,41 @@ templates.
- **A successful copy is not a working bridge.** ServUO ignores the script build's - **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 exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify
post-boot state rather than trusting a clean boot. 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/<timestamp>/` 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. - **The audience is public** — any ServUO operator, not only shards we run.
## Build & 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 ```bash
cargo build --release cargo build --release
cargo run -- --help 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 See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
checks CI will run, and the branch/PR workflow. checks CI will run, and the branch/PR workflow.

485
src/backup.rs Normal file
View File

@@ -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<String>,
/// The bundle this run is moving to.
pub bundle_to: String,
pub servuo_root: String,
pub files: Vec<Entry>,
}
#[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<String>,
bundle_to: String,
taken: String,
entries: Vec<Entry>,
}
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<String>,
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<Option<PathBuf>> {
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<PathBuf> {
let mut dirs: Vec<PathBuf> = 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<Manifest> {
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(&copy).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<String> = 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();
}
}

284
src/bundle.rs Normal file
View File

@@ -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<String, Asset>,
}
#[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::<Vec<_>>()
.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 <tag>` 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<Bundle> {
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"));
}
}

349
src/cli.rs Normal file
View File

@@ -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<Self> {
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 <path>`: name the ServUO root instead of detecting or prompting.
pub servuo: Option<String>,
/// `--bundle <tag>`: pin a published bundle instead of resolving the current one.
pub bundle: Option<String>,
pub patches: PatchChoice,
/// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree.
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
pub site_url: Option<String>,
/// `--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 <COMMAND> [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 <PATH> install, doctor, update. The ServUO root,
instead of detecting or prompting for it.
--bundle <TAG> 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 <NAME> install. The hostname to print in the
website URLs.
--site-url <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 <state>/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<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
let mut cli = Cli::default();
let mut command: Option<Command> = 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<I: Iterator<Item = String>>(
flag: &str,
inline: Option<String>,
rest: &mut I,
) -> Result<String, String> {
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<Cli, String> {
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);
}
}

591
src/diff.rs Normal file
View File

@@ -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<u8>),
/// `-` — present in the stock file only.
Removed(Vec<u8>),
/// `+` — present in the patched file only.
Added(Vec<u8>),
}
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<HunkLine>,
/// 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/<path>`, with the `b/` prefix stripped and separators left as `/`.
pub path: String,
/// The abbreviated blob hash of the stock file, from `index <old>..<new>`. `None` when the
/// diff has no `index` line, which makes rung 1 unavailable for this file — see the module
/// docs.
pub pre_blob: Option<String>,
pub post_blob: Option<String>,
pub hunks: Vec<Hunk>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
pub files: Vec<FilePatch>,
}
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::<Vec<_>>()
.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<Patch> {
let lines = split_lines(data);
let mut files: Vec<FilePatch> = 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 <old>..<new>[ <mode>]`. 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<String> {
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<Patch> {
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<Mobile, string> 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::<Vec<_>>()
.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()
);
}
}

916
src/doctor.rs Normal file
View File

@@ -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<String>,
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
detail: detail.into(),
notes: Vec::new(),
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> 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<i32> {
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<ServUoRoot> {
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<String> = 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<crate::diff::FilePatch> {
let by_name = layout.patches_dir().join(format!("{name}.patch"));
let candidates: Vec<PathBuf> = 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<sidecar::ConfigDoc> {
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<Health> {
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<String>,
pub protocol: Option<u32>,
pub plugin_connected: Option<bool>,
pub database: Option<String>,
pub uptime: Option<String>,
pub last_event: Option<String>,
}
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);
}
}

912
src/install.rs Normal file
View File

@@ -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<ServUoRoot> {
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 <path>."
);
}
let answer = ui::prompt("Path to your ServUO root", None)
.context("a ServUO root is required; pass --servuo <path> 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<String, crate::record::FileRecord>> {
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<Option<SidecarOutcome>> {
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());
}
}

117
src/lib.rs Normal file
View File

@@ -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-<hash>.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<i32> = 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}");
}
}
}

6
src/main.rs Normal file
View File

@@ -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());
}

128
src/net.rs Normal file
View File

@@ -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<String> {
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<String> {
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());
}
}

703
src/overlay.rs Normal file
View File

@@ -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<crate::patch::Tier>,
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
pub files: BTreeMap<String, String>,
}
/// 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<String>,
}
#[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<PathBuf> {
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<Manifest> {
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<String, FileRecord>>,
) -> Result<Vec<PlannedFile>> {
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<String, FileRecord> {
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<String> {
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<String>| {
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<String>) -> 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"<Project/>\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"<Project> hand edited </Project>\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"<Project/>\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}");
}
}

890
src/patch.rs Normal file
View File

@@ -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<HunkPlacement> },
/// 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<HunkPlacement>,
edits: Vec<Edit>,
},
/// The patch will not be placed, and why.
Refused(Refusal),
}
impl Resolution {
pub fn rung(&self) -> Option<Rung> {
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<u8>,
}
/// 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<Option<Vec<usize>>> = 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<Vec<usize>> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
let hits: Vec<usize> = (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<u8> {
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<Feature>,
}
#[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<PatchRef>,
pub companions: Vec<Companion>,
}
#[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/<file>.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<u8>, 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<String>,
pub unsupported_servuo: bool,
pub patches: Vec<AppliedPatch>,
pub companions: Vec<CompanionRecord>,
}
#[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<HunkPlacement>,
}
#[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::<BTreeMap<_, _>>()
}
#[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<u8> {
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<u8> {
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"
);
}
}

208
src/paths.rs Normal file
View File

@@ -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());
}
}

387
src/record.rs Normal file
View File

@@ -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<OverlayRecord>,
/// 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<serde_json::Value>,
/// 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<serde_json::Value>,
/// Anything a newer installer wrote that this one has no name for.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[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<String>,
}
#[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<String, FileRecord>,
}
#[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<ServiceRecord>,
}
#[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<String>,
/// The account the service runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// 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<Option<Self>> {
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<String, FileRecord>> {
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<LinkRecord> {
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<crate::patch::FeatureRecord> {
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}");
}
}

1169
src/service.rs Normal file

File diff suppressed because it is too large Load Diff

402
src/servuo.rs Normal file
View File

@@ -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<String>,
}
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<ServUoRoot> {
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<PathBuf> {
let mut starts: Vec<PathBuf> = 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<String> {
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<String> {
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<RunningShard> {
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<String> = 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<ServUoRoot> {
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());
}
}

465
src/sidecar.rs Normal file
View File

@@ -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 <the pinned path>`. 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<BinaryAction> {
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<String> {
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<ConfigDoc> {
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 <ver> (protocol <n>)`.
///
/// 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<String> {
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://<your-site>".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::<ConfigDoc>(&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://<your-site>/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,
}
}
}

1072
src/tier.rs Normal file

File diff suppressed because it is too large Load Diff

123
src/ui.rs Normal file
View File

@@ -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<bool> {
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<String> {
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");
}
}

707
src/uninstall.rs Normal file
View File

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

218
src/update.rs Normal file
View File

@@ -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<String> {
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:?}");
}
}

365
src/util.rs Normal file
View File

@@ -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<String> {
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 <old>..<new>` 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<W: Write> {
inner: W,
hasher: Sha256,
}
impl<W: Write> HashingWriter<W> {
pub fn new(inner: W) -> Self {
Self {
inner,
hasher: Sha256::new(),
}
}
pub fn finish(self) -> String {
hex(&self.hasher.finalize())
}
}
impl<W: Write> Write for HashingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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<Self> {
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<S: AsRef<OsStr>>(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<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
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<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
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<i32>, 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> {
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<u8> = (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());
}
}

View File

@@ -0,0 +1,33 @@
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -75,16 +75,27 @@
return o;
}
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public static event Action<Mobile, string> 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;

47
tests/fixtures/patch_tier.json vendored Normal file
View File

@@ -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"
}
]
}
]
}

View File

@@ -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)

View File

@@ -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.
}
}

40
tests/fixtures/published-bundle.json vendored Normal file
View File

@@ -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"
}
}
}

317
tests/real_patches.rs Normal file
View File

@@ -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<u8> {
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<u8> {
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<Mobile, string> 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<usize> = 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::<serde_json::Value>(&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<String> = tier
.features
.iter()
.flat_map(|f| f.patches.iter())
.map(|p| p.file.replace("patches/", ""))
.collect();
declared.sort();
let mut shipped: Vec<String> = 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
);
}
}
}