Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a2cca5f6 | |||
| 10bc2224a6 | |||
| 461192a470 | |||
| c79374ff06 | |||
| 82900da939 | |||
| 6941925fa5 | |||
| 14f65d50a0 | |||
| 060b8815cf | |||
| a0106e2c84 | |||
| 80b1c0da24 | |||
| 0043264482 | |||
| 265911a58f | |||
| 02c5ad9839 | |||
| 52d330167b | |||
| 7122bc6911 | |||
| 7bfb033957 | |||
| 2228e0848b | |||
| a4f6b7b756 | |||
| dff4ad41c9 |
@@ -7,12 +7,12 @@
|
||||
# The one structural difference is the crate guard below.
|
||||
#
|
||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||
# This repo is in the planning phase and has no Cargo project yet (the design of
|
||||
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather
|
||||
# than leave the repo ungated until then — or land a workflow that red-Xes every
|
||||
# governance/docs PR — the gates are conditional on a root Cargo.toml existing.
|
||||
# Before the crate lands, the job reports green with a notice. The moment
|
||||
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change.
|
||||
# The gates are conditional on a root Cargo.toml existing: before the crate
|
||||
# landed, this job reported green with a notice so governance/docs PRs were not
|
||||
# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
|
||||
# `edge`, so the gates arm themselves there automatically — and stay dormant on
|
||||
# a `main` PR until the cutover merges the crate into it. Nothing here changes at
|
||||
# that point either.
|
||||
#
|
||||
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
||||
# this repo's sole product is the one installer binary, so there is nothing to
|
||||
@@ -35,7 +35,12 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that
|
||||
# release.yml — which fires on every push to `main` — does not publish an installer binary that
|
||||
# can deploy the overlay but not yet install the sidecar. Ungating the branch where all the
|
||||
# work actually happens would leave the gates running only at the cutover, which is the one
|
||||
# moment a red build is most expensive.
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
# • Artifact names follow docs/installer/PLAN.md §3.
|
||||
#
|
||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||
# The repo is in the planning phase. With no Cargo.toml there is nothing to
|
||||
# build, so the plan step forces RELEASE=false and the job exits green having
|
||||
# done nothing. It starts cutting real releases the moment Phase 1 lands the
|
||||
# crate — no edit required here.
|
||||
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
|
||||
# forces RELEASE=false and the job exits green having done nothing.
|
||||
#
|
||||
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
|
||||
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
|
||||
# workflow keeps standing down — an installer binary that syncs the overlay but
|
||||
# cannot install the sidecar is not something to publish to operators. The first
|
||||
# release is cut by the `edge → main` cutover, with no edit required here.
|
||||
#
|
||||
# ── Unsigned releases ────────────────────────────────────────────────────────
|
||||
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
||||
@@ -62,6 +66,9 @@ env:
|
||||
BIN: runicgateway-installer
|
||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
||||
# The installer has to run wherever the sidecar it installs can run, and link
|
||||
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
|
||||
ARM64_TARGET: aarch64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -233,7 +240,7 @@ jobs:
|
||||
echo "Release credentials present."
|
||||
|
||||
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
||||
- name: Install Rust toolchain, Windows target, and MinGW linker
|
||||
- name: Install Rust toolchain, cross targets, and their linkers
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -250,6 +257,7 @@ jobs:
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
rustup component add rustfmt
|
||||
rustup target add "${WINDOWS_TARGET}"
|
||||
rustup target add "${ARM64_TARGET}"
|
||||
|
||||
- name: Set the crate version to match the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
@@ -295,8 +303,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
||||
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
|
||||
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
||||
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||
# Every artifact must be listed: `sha256sum -c` passes silently over a
|
||||
# file the sums do not mention, and an operator verifying a download
|
||||
# would get a pass on a binary nobody vouched for.
|
||||
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||
|
||||
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||
@@ -361,7 +373,7 @@ jobs:
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
||||
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
|
||||
953
Cargo.lock
generated
Normal file
953
Cargo.lock
generated
Normal 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
66
Cargo.toml
Normal 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
|
||||
76
README.md
76
README.md
@@ -27,22 +27,52 @@ never restarts the shard.
|
||||
|
||||
## Status
|
||||
|
||||
**Planning — no installer code exists yet.**
|
||||
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
|
||||
|
||||
The binary does everything
|
||||
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
||||
describes: bundle resolution, ServUO detection and validation, the overlay sync,
|
||||
the opt-in patch tier, `install.json`, the uo-link sidecar and its service, the
|
||||
token handoff, and `doctor` / `update` / `uninstall`.
|
||||
|
||||
The design of record is
|
||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
||||
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
||||
sidecar + overlay combination, recomposed on every component release and nightly
|
||||
(see [`bundles/README.md`](bundles/README.md)).
|
||||
|
||||
All three Phase 0 prerequisites have now landed, so **what the installer will
|
||||
install already exists and is published**, ahead of the binary that installs it:
|
||||
[`bundles/current.json`](bundles/current.json) names the current
|
||||
protocol-checked sidecar + overlay combination, recomposed on every component
|
||||
release and nightly. See [`bundles/README.md`](bundles/README.md).
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| 0 — prerequisites in the other repos | ✅ merged |
|
||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||
| 2 — uo-link install + service registration | ✅ on `edge` |
|
||||
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
|
||||
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
|
||||
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
|
||||
|
||||
Besides that, this repo currently holds its governance documents and issue/PR
|
||||
templates.
|
||||
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||
`main`, so nothing lands there until the whole tool is worth handing to an
|
||||
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
|
||||
the same gates as PRs into `main`.
|
||||
|
||||
**What the cutover is waiting on**, per PLAN.md §5:
|
||||
|
||||
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
|
||||
than after it, because it changes the release layout, and shipping first would
|
||||
mean a first release immediately superseded by the next. There is no `.deb`
|
||||
and no MSI: both would give the sidecar binary, its service unit and its
|
||||
service account a second owner beside this tool.
|
||||
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
|
||||
service account, the failure actions and the token-file ACL have never been
|
||||
executed anywhere. Running the *systemd* half for real is what turned up a bug
|
||||
no unit test had, so this is not a formality.
|
||||
|
||||
Until the cutover, the way to install is by hand —
|
||||
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||
is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
|
||||
## Related repos
|
||||
|
||||
@@ -54,7 +84,7 @@ templates.
|
||||
| [RunicGateway/website](https://gitea.whitlocktech.com/RunicGateway/website) | The public site and admin panel. The installer never contacts it — it prints values for Admin → Shard. |
|
||||
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. |
|
||||
|
||||
## Planned commands
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
@@ -78,17 +108,41 @@ templates.
|
||||
- **A successful copy is not a working bridge.** ServUO ignores the script build's
|
||||
exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify
|
||||
post-boot state rather than trusting a clean boot.
|
||||
- **What a run overwrites is copied first.** Every `.cs` file the overlay owns is
|
||||
replaced unconditionally, so an operator's edit to one is saved under
|
||||
`backups/<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.
|
||||
|
||||
## Build & run
|
||||
|
||||
Once the crate exists it will be a standard cargo project:
|
||||
A standard cargo project, with the crate at the repo root:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo run -- --help
|
||||
cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing
|
||||
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||
```
|
||||
|
||||
`RUNICGATEWAY_STATE_DIR` relocates **everything the installer writes** — state,
|
||||
data, and the sidecar binary (normally `/etc/runicgateway`, `/var/lib/runicgateway`
|
||||
and `/usr/bin`, or `%ProgramData%\RunicGateway` and `%ProgramFiles%\RunicGateway`).
|
||||
It also suppresses service registration, since there is no such thing as a
|
||||
relocated systemd unit or Windows service. That is how a full run is tested
|
||||
without root.
|
||||
|
||||
Two layout notes that look odd until you know why:
|
||||
|
||||
- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC
|
||||
installer detection refuses to launch an unsigned executable whose file name
|
||||
contains `install` (`os error 740`), and Cargo names test harnesses after their
|
||||
target — so a target under that name makes `cargo test` unrunnable on Windows.
|
||||
The published binary keeps its documented name; `[[bin]] test = false` keeps
|
||||
Cargo from building a harness under it. Expect a UAC prompt when running the
|
||||
built binary on Windows; it needs Administrator anyway.
|
||||
- **`Cargo.lock` is committed**, and CI builds `--locked`.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
||||
checks CI will run, and the branch/PR workflow.
|
||||
|
||||
|
||||
485
src/backup.rs
Normal file
485
src/backup.rs
Normal 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(©).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();
|
||||
}
|
||||
}
|
||||
275
src/bundle.rs
Normal file
275
src/bundle.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
//! The bundle manifest — "what to install", resolved at run time.
|
||||
//!
|
||||
//! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of
|
||||
//! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches
|
||||
//! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's
|
||||
//! newest release and hoping the two agree.
|
||||
//!
|
||||
//! Two consequences show up directly in this module:
|
||||
//!
|
||||
//! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and
|
||||
//! cross-checked against the overlay's own `manifest.json` at deploy time.
|
||||
//! - **`schema` is not `protocol`.** It versions the shape of this document and moves
|
||||
//! independently of both components' versions; a bundle from a newer CI is refused rather than
|
||||
//! half-understood.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
|
||||
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
|
||||
const BUNDLE_BASE: &str =
|
||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
|
||||
|
||||
/// The only `schema` this build understands.
|
||||
const SUPPORTED_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Bundle {
|
||||
pub schema: u32,
|
||||
/// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two.
|
||||
pub bundle: String,
|
||||
pub generated: String,
|
||||
/// The wire protocol both halves were checked to agree on.
|
||||
pub protocol: u32,
|
||||
pub link: LinkComponent,
|
||||
pub overlay: OverlayComponent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LinkComponent {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
/// Keyed by platform (`linux-x86_64`, `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 from `bundles/current.json`. Using the real document
|
||||
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
|
||||
const CURRENT: &str = include_str!("../bundles/current.json");
|
||||
|
||||
#[test]
|
||||
fn the_published_bundle_parses() {
|
||||
let bundle = parse(CURRENT).unwrap();
|
||||
assert_eq!(bundle.schema, 1);
|
||||
assert_eq!(bundle.bundle, "2026.08.04");
|
||||
assert_eq!(bundle.protocol, 3);
|
||||
assert_eq!(bundle.link.version, "1.1.0");
|
||||
assert_eq!(bundle.overlay.version, "0.1.1");
|
||||
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
|
||||
assert!(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
349
src/cli.rs
Normal 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
591
src/diff.rs
Normal 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
916
src/doctor.rs
Normal 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
912
src/install.rs
Normal 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
117
src/lib.rs
Normal 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
6
src/main.rs
Normal 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
128
src/net.rs
Normal 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
703
src/overlay.rs
Normal 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
890
src/patch.rs
Normal 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
208
src/paths.rs
Normal 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
387
src/record.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
1076
src/service.rs
Normal file
1076
src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
402
src/servuo.rs
Normal file
402
src/servuo.rs
Normal 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
465
src/sidecar.rs
Normal 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
1072
src/tier.rs
Normal file
File diff suppressed because it is too large
Load Diff
123
src/ui.rs
Normal file
123
src/ui.rs
Normal 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
707
src/uninstall.rs
Normal 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
218
src/update.rs
Normal 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
365
src/util.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
33
tests/fixtures/commandlogging-event.patch
vendored
Normal file
33
tests/fixtures/commandlogging-event.patch
vendored
Normal 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
47
tests/fixtures/patch_tier.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
66
tests/fixtures/playervendor-sale-eventsink.patch
vendored
Normal file
66
tests/fixtures/playervendor-sale-eventsink.patch
vendored
Normal 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)
|
||||
15
tests/fixtures/playervendor-sale-gump.patch
vendored
Normal file
15
tests/fixtures/playervendor-sale-gump.patch
vendored
Normal 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.
|
||||
}
|
||||
}
|
||||
317
tests/real_patches.rs
Normal file
317
tests/real_patches.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user