feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
Adds the Rust crate at the repo root and implements `install` end to end for the overlay half of a deployment: resolve the published bundle, find and validate the ServUO root, refuse to deploy under a running shard, sync the plugin overlay, and record what was deployed in install.json. `doctor`, `update` and `uninstall` parse and answer with the phase they arrive in rather than "unrecognized command", and the run states plainly that the uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed — `--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet completion would be read as a patched shard. Landing on `edge` rather than `main`: release.yml publishes a binary on every push to main, and an installer that deploys the overlay but cannot install the sidecar is not something to hand an operator. pr-checks.yml now gates PRs into edge on the same rules, so the branch the work happens on is not the ungated one. Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1: - The code lives in a library called `rgdeploy` with a thin binary that keeps the published name. Windows' UAC installer detection refuses to launch an unsigned executable whose file name contains "install" (os error 740), and Cargo names test harnesses after their target — so a target under that name makes `cargo test` unrunnable on Windows. - The running-shard check matches processes by path, not by process name: on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and a name match would report "not running" for a shard that is running. - install.json records a state (`deployed` / `kept-operator-modified`), not the run's verb, so an unchanged re-run produces an identical record and writes nothing. - The Bridge.cfg keep rule compares against the hash the installer last deployed, not the last hash it saw — otherwise a kept file is overwritten on the very next run. - Downloads are verified against the bundle's SHA256 while being written, then every extracted file is re-hashed against the release's own manifest.json, whose protocol and version are cross-checked against the bundle. Verified against a real ServUO 57.4 tree and end to end into a scratch tree: 24 files deployed, an unchanged re-run that writes nothing, an edited Bridge.cfg kept across repeated runs while code files are overwritten, bundle pinning, and a refusal with a shard running out of the tree. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,12 @@
|
|||||||
# The one structural difference is the crate guard below.
|
# The one structural difference is the crate guard below.
|
||||||
#
|
#
|
||||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||||
# This repo is in the planning phase and has no Cargo project yet (the design of
|
# The gates are conditional on a root Cargo.toml existing: before the crate
|
||||||
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather
|
# landed, this job reported green with a notice so governance/docs PRs were not
|
||||||
# than leave the repo ungated until then — or land a workflow that red-Xes every
|
# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
|
||||||
# governance/docs PR — the gates are conditional on a root Cargo.toml existing.
|
# `edge`, so the gates arm themselves there automatically — and stay dormant on
|
||||||
# Before the crate lands, the job reports green with a notice. The moment
|
# a `main` PR until the cutover merges the crate into it. Nothing here changes at
|
||||||
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change.
|
# that point either.
|
||||||
#
|
#
|
||||||
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
||||||
# this repo's sole product is the one installer binary, so there is nothing to
|
# this repo's sole product is the one installer binary, so there is nothing to
|
||||||
@@ -35,7 +35,12 @@ name: PR Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
# `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that
|
||||||
|
# release.yml — which fires on every push to `main` — does not publish an installer binary that
|
||||||
|
# can deploy the overlay but not yet install the sidecar. Ungating the branch where all the
|
||||||
|
# work actually happens would leave the gates running only at the cutover, which is the one
|
||||||
|
# moment a red build is most expensive.
|
||||||
|
branches: [main, edge]
|
||||||
|
|
||||||
# A newer push to the same PR cancels the in-flight run.
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -21,10 +21,14 @@
|
|||||||
# • Artifact names follow docs/installer/PLAN.md §3.
|
# • Artifact names follow docs/installer/PLAN.md §3.
|
||||||
#
|
#
|
||||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||||
# The repo is in the planning phase. With no Cargo.toml there is nothing to
|
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
|
||||||
# build, so the plan step forces RELEASE=false and the job exits green having
|
# forces RELEASE=false and the job exits green having done nothing.
|
||||||
# done nothing. It starts cutting real releases the moment Phase 1 lands the
|
#
|
||||||
# crate — no edit required here.
|
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
|
||||||
|
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
|
||||||
|
# workflow keeps standing down — an installer binary that syncs the overlay but
|
||||||
|
# cannot install the sidecar is not something to publish to operators. The first
|
||||||
|
# release is cut by the `edge → main` cutover, with no edit required here.
|
||||||
#
|
#
|
||||||
# ── Unsigned releases ────────────────────────────────────────────────────────
|
# ── Unsigned releases ────────────────────────────────────────────────────────
|
||||||
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
||||||
|
|||||||
941
Cargo.lock
generated
Normal file
941
Cargo.lock
generated
Normal file
@@ -0,0 +1,941 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"flate2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sysinfo",
|
||||||
|
"tar",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.38.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
59
Cargo.toml
Normal file
59
Cargo.toml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
[package]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Deployment tool for Runic Gateway: syncs the ServUO plugin overlay, installs the uo-link sidecar, and records what it deployed."
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
repository = "https://gitea.whitlocktech.com/RunicGateway/installer"
|
||||||
|
|
||||||
|
# The published binary keeps the name PLAN.md §3 and INSTALL.md give it. The library it is built
|
||||||
|
# from does not share that name on purpose: Windows' UAC installer detection refuses to launch an
|
||||||
|
# unsigned executable whose file name contains "install" (`os error 740`), and Cargo names test
|
||||||
|
# harnesses after their target — so a target called `runicgateway_installer` makes `cargo test`
|
||||||
|
# unrunnable on Windows. `test = false` keeps Cargo from building a harness under the binary's
|
||||||
|
# name; all the code, and all the tests, live in the library. See src/lib.rs.
|
||||||
|
[lib]
|
||||||
|
name = "rgdeploy"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
test = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Blocking HTTP over a pure-Rust TLS stack (rustls + ring + webpki-roots). The
|
||||||
|
# release cross-compiles to x86_64-pc-windows-gnu through MinGW, where anything
|
||||||
|
# linking OpenSSL turns a one-line build into a toolchain project — and this tool
|
||||||
|
# makes a handful of sequential requests, so an async runtime would be overhead
|
||||||
|
# with nothing to overlap.
|
||||||
|
ureq = "3.3"
|
||||||
|
|
||||||
|
# Overlay releases ship as gzipped tar. flate2's default backend is miniz_oxide
|
||||||
|
# (pure Rust), so it cross-compiles with no C dependency of its own.
|
||||||
|
flate2 = "1"
|
||||||
|
tar = "0.4"
|
||||||
|
|
||||||
|
# SHA256 is the entire trust anchor for these deliberately unsigned artifacts
|
||||||
|
# (PLAN.md §3), which makes this load-bearing rather than a nicety.
|
||||||
|
sha2 = "0.11"
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# RFC 3339 timestamps for install.json. Same feature set link's sidecar uses.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
|
||||||
|
|
||||||
|
# Refusing to deploy under a running shard is a correctness requirement, not a
|
||||||
|
# courtesy: ServUO holds Scripts.dll open and rewrites Saves/ on exit. Only the
|
||||||
|
# `system` feature is wanted — disks, networks and users are not our business.
|
||||||
|
sysinfo = { version = "0.38", default-features = false, features = ["system"] }
|
||||||
|
|
||||||
|
# Error plumbing, as in link's sidecar. Every failure here is read by an operator
|
||||||
|
# rather than matched on, so a chain of `.context()` strings is the whole
|
||||||
|
# requirement — the value is that "failed to write install.json" arrives with the
|
||||||
|
# path and the OS error attached instead of alone.
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
48
README.md
48
README.md
@@ -27,22 +27,34 @@ never restarts the shard.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Planning — no installer code exists yet.**
|
**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.**
|
||||||
|
|
||||||
The design of record is
|
The design of record is
|
||||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||||
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
||||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||||
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||||
|
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
||||||
|
sidecar + overlay combination, recomposed on every component release and nightly
|
||||||
|
(see [`bundles/README.md`](bundles/README.md)).
|
||||||
|
|
||||||
All three Phase 0 prerequisites have now landed, so **what the installer will
|
| Phase | State |
|
||||||
install already exists and is published**, ahead of the binary that installs it:
|
|---|---|
|
||||||
[`bundles/current.json`](bundles/current.json) names the current
|
| 0 — prerequisites in the other repos | ✅ merged |
|
||||||
protocol-checked sidecar + overlay combination, recomposed on every component
|
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||||
release and nightly. See [`bundles/README.md`](bundles/README.md).
|
| 2 — uo-link install + service registration | next |
|
||||||
|
| 3 — the opt-in stock-file patch tier | |
|
||||||
|
| 4 — `doctor`, `update`, `uninstall` | |
|
||||||
|
|
||||||
Besides that, this repo currently holds its governance documents and issue/PR
|
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||||
templates.
|
`main`, and a binary that deploys the overlay but cannot yet install the sidecar
|
||||||
|
is not something to hand an operator. Phases 1 and 2 land on `edge`; the
|
||||||
|
`edge → main` cutover cuts the first release. PRs into `edge` run the same gates
|
||||||
|
as PRs into `main`.
|
||||||
|
|
||||||
|
Until then, the way to install is by hand —
|
||||||
|
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||||
|
is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||||
|
|
||||||
## Related repos
|
## Related repos
|
||||||
|
|
||||||
@@ -82,13 +94,29 @@ templates.
|
|||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
Once the crate exists it will be a standard cargo project:
|
A standard cargo project, with the crate at the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cargo run -- --help
|
cargo run -- --help
|
||||||
|
cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing
|
||||||
|
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway`
|
||||||
|
or `%ProgramData%\RunicGateway`), which is how a run is tested without root.
|
||||||
|
|
||||||
|
Two layout notes that look odd until you know why:
|
||||||
|
|
||||||
|
- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC
|
||||||
|
installer detection refuses to launch an unsigned executable whose file name
|
||||||
|
contains `install` (`os error 740`), and Cargo names test harnesses after their
|
||||||
|
target — so a target under that name makes `cargo test` unrunnable on Windows.
|
||||||
|
The published binary keeps its documented name; `[[bin]] test = false` keeps
|
||||||
|
Cargo from building a harness under it. Expect a UAC prompt when running the
|
||||||
|
built binary on Windows; it needs Administrator anyway.
|
||||||
|
- **`Cargo.lock` is committed**, and CI builds `--locked`.
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
||||||
checks CI will run, and the branch/PR workflow.
|
checks CI will run, and the branch/PR workflow.
|
||||||
|
|
||||||
|
|||||||
228
src/bundle.rs
Normal file
228
src/bundle.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! The bundle manifest — "what to install", resolved at run time.
|
||||||
|
//!
|
||||||
|
//! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of
|
||||||
|
//! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches
|
||||||
|
//! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's
|
||||||
|
//! newest release and hoping the two agree.
|
||||||
|
//!
|
||||||
|
//! Two consequences show up directly in this module:
|
||||||
|
//!
|
||||||
|
//! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and
|
||||||
|
//! cross-checked against the overlay's own `manifest.json` at deploy time.
|
||||||
|
//! - **`schema` is not `protocol`.** It versions the shape of this document and moves
|
||||||
|
//! independently of both components' versions; a bundle from a newer CI is refused rather than
|
||||||
|
//! half-understood.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
|
||||||
|
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
|
||||||
|
const BUNDLE_BASE: &str =
|
||||||
|
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
|
||||||
|
|
||||||
|
/// The only `schema` this build understands.
|
||||||
|
const SUPPORTED_SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Bundle {
|
||||||
|
pub schema: u32,
|
||||||
|
/// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two.
|
||||||
|
pub bundle: String,
|
||||||
|
pub generated: String,
|
||||||
|
/// The wire protocol both halves were checked to agree on.
|
||||||
|
pub protocol: u32,
|
||||||
|
pub link: LinkComponent,
|
||||||
|
pub overlay: OverlayComponent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LinkComponent {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
|
||||||
|
/// the installer runs on both, so a single hash could only ever describe one of them.
|
||||||
|
pub assets: BTreeMap<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: {})",
|
||||||
|
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"),
|
||||||
|
("windows", "x86_64") => Ok("windows-x86_64"),
|
||||||
|
// arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats
|
||||||
|
// failing later with a missing-key error that reads like a corrupt bundle.
|
||||||
|
(os, arch) => bail!(
|
||||||
|
"no Runic Gateway build exists for {os}/{arch}. \
|
||||||
|
The released components target linux-x86_64 and windows-x86_64."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL of the current bundle, or of a specific one when `--bundle <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_eq!(bundle.link.assets.len(), 2);
|
||||||
|
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_platforms_have_a_sidecar_binary() {
|
||||||
|
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
|
||||||
|
// host's binary would fail an install after the overlay had already been deployed.
|
||||||
|
let bundle = parse(CURRENT).unwrap();
|
||||||
|
let asset = bundle.sidecar_asset().unwrap();
|
||||||
|
assert_eq!(asset.sha256.len(), 64);
|
||||||
|
assert!(asset.url.contains(&bundle.link.tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
||||||
|
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
|
||||||
|
let err = parse(&body).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("schema 2"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_protocol_disagreement_inside_one_bundle_is_refused() {
|
||||||
|
// Exactly what CI's gate 1 exists to prevent; re-checked here for documents that never
|
||||||
|
// went through it.
|
||||||
|
let body = CURRENT.replacen("\"protocol\": 3", "\"protocol\": 4", 2);
|
||||||
|
let err = parse(&body).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("internally inconsistent"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pinned_and_current_urls_differ() {
|
||||||
|
assert!(url_for(None).ends_with("/current.json"));
|
||||||
|
assert!(url_for(Some("2026.08.04")).ends_with("/bundle-2026.08.04.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/cli.rs
Normal file
336
src/cli.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
//! Command-line surface.
|
||||||
|
//!
|
||||||
|
//! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and
|
||||||
|
//! fixes every command and flag an operator can type. This module parses that surface *whole*, even
|
||||||
|
//! though Phase 1 implements only part of it — a parser written once against the published contract
|
||||||
|
//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this
|
||||||
|
//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it
|
||||||
|
//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO
|
||||||
|
//! files were touched when nothing was.
|
||||||
|
//!
|
||||||
|
//! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand
|
||||||
|
//! trees. A parsing crate would be larger than the code it replaced.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// The verb. `Install` is the only one Phase 1 implements; the rest parse so that running them
|
||||||
|
/// reports which phase they arrive in rather than "unrecognized argument", which would read as a
|
||||||
|
/// typo rather than as an unfinished tool.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Command {
|
||||||
|
Install,
|
||||||
|
Doctor,
|
||||||
|
Update,
|
||||||
|
Uninstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
fn parse(token: &str) -> Option<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` and `uo-link.db`.
|
||||||
|
pub purge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Cli {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: Mode::Help,
|
||||||
|
verify: false,
|
||||||
|
servuo: None,
|
||||||
|
bundle: None,
|
||||||
|
patches: PatchChoice::Ask,
|
||||||
|
patches_unsupported_servuo: false,
|
||||||
|
host: None,
|
||||||
|
site_url: None,
|
||||||
|
assume_yes: false,
|
||||||
|
purge: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const USAGE: &str = "\
|
||||||
|
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
|
||||||
|
|
||||||
|
Usage: runicgateway-installer <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.
|
||||||
|
--purge uninstall. Also delete sidecar.toml and
|
||||||
|
uo-link.db, which are otherwise kept.
|
||||||
|
-V, --version Print the installer version and exit.
|
||||||
|
-h, --help Print this help and exit.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
RUNICGATEWAY_STATE_DIR Relocates the installer's own state (install.json,
|
||||||
|
the cached patch set) away from /etc/runicgateway or
|
||||||
|
%ProgramData%\\RunicGateway. For testing a run
|
||||||
|
without root; an installed deployment should not
|
||||||
|
set it.
|
||||||
|
|
||||||
|
The installer never contacts your website, never deletes anything from your
|
||||||
|
ServUO tree, and never starts or stops your shard.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Parses arguments **without** the program name.
|
||||||
|
///
|
||||||
|
/// The error string is what the caller prints on stderr before exiting `2`.
|
||||||
|
pub fn parse<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,
|
||||||
|
"--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);
|
||||||
|
}
|
||||||
|
}
|
||||||
430
src/install.rs
Normal file
430
src/install.rs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
//! The `install` command.
|
||||||
|
//!
|
||||||
|
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
|
||||||
|
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
|
||||||
|
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
|
||||||
|
//! than ending on a success line that would read as a finished install. An operator who cannot tell
|
||||||
|
//! which half ran is the failure this whole tool exists to remove.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::cli::{Cli, PatchChoice};
|
||||||
|
use crate::record::{
|
||||||
|
now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::servuo::ServUoRoot;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
use crate::{bundle, net, overlay, paths, servuo, ui};
|
||||||
|
|
||||||
|
pub fn run(cli: &Cli) -> Result<()> {
|
||||||
|
let layout = paths::layout();
|
||||||
|
|
||||||
|
// ── What to install ──────────────────────────────────────────────────────
|
||||||
|
// The bundle is resolved first, and its sidecar asset looked up immediately, so a run that
|
||||||
|
// cannot be completed fails here — before a single file has entered the ServUO tree.
|
||||||
|
let (bundle, bundle_url) = bundle::fetch(cli.bundle.as_deref())?;
|
||||||
|
let sidecar_asset = bundle.sidecar_asset()?.clone();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nRunic Gateway installer {} — bundle {} (protocol {}){}",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.protocol,
|
||||||
|
if cli.verify {
|
||||||
|
" [--verify: nothing will be written]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// ── Where to install it ──────────────────────────────────────────────────
|
||||||
|
let root = resolve_root(cli)?;
|
||||||
|
ui::row(
|
||||||
|
"ServUO",
|
||||||
|
&format!("{} ({})", root.path.display(), root.version_display()),
|
||||||
|
);
|
||||||
|
// Reaching this line means `servuo::open_stopped` found no shard running out of this tree; a
|
||||||
|
// running one has already ended the run.
|
||||||
|
ui::row("Shard process", "not running");
|
||||||
|
ui::row(
|
||||||
|
"Overlay",
|
||||||
|
&format!(
|
||||||
|
"{:<24} protocol {}",
|
||||||
|
format!("servuo-plugins {}", bundle.overlay.tag),
|
||||||
|
bundle.overlay.protocol
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui::row(
|
||||||
|
"Sidecar",
|
||||||
|
&format!(
|
||||||
|
"{:<24} protocol {} (Phase 2 — not installed by this build)",
|
||||||
|
format!("uo-link {}", bundle.link.tag),
|
||||||
|
bundle.link.protocol
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if !root.is_supported_version() {
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"This tree reports ServUO {}. {} is the only supported version.\n \
|
||||||
|
The base overlay only adds files and is expected to work broadly, so the install \
|
||||||
|
continues.\n \
|
||||||
|
The patch tier is the part that is version-sensitive — see INSTALL.md §4.",
|
||||||
|
root.version_display(),
|
||||||
|
servuo::SUPPORTED_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch and unpack the overlay ─────────────────────────────────────────
|
||||||
|
let scratch = TempDir::new("runicgateway-installer")?;
|
||||||
|
let tarball = scratch.path().join(&bundle.overlay.asset.name);
|
||||||
|
println!();
|
||||||
|
net::download_verified(
|
||||||
|
&bundle.overlay.asset.url,
|
||||||
|
&tarball,
|
||||||
|
&bundle.overlay.asset.sha256,
|
||||||
|
)?;
|
||||||
|
ui::ok(&format!(
|
||||||
|
"overlay tarball verified sha256 {}…",
|
||||||
|
&bundle.overlay.asset.sha256[..8.min(bundle.overlay.asset.sha256.len())]
|
||||||
|
));
|
||||||
|
|
||||||
|
let unpacked = overlay::extract(&tarball, &scratch.path().join("unpacked"))?;
|
||||||
|
let manifest = overlay::read_manifest(&unpacked)?;
|
||||||
|
overlay::verify_payload(&unpacked, &manifest)?;
|
||||||
|
|
||||||
|
// The bundle and the artifact must agree. They are produced by different repos at different
|
||||||
|
// times, and gate 1 of the compose job (PLAN.md §7.1) is what normally keeps them in step —
|
||||||
|
// this is the same check applied to the artifact actually on disk.
|
||||||
|
if manifest.protocol != bundle.overlay.protocol {
|
||||||
|
bail!(
|
||||||
|
"the overlay release declares protocol {} but bundle {} recorded {}. \
|
||||||
|
Refusing to deploy a pair that was never checked together.",
|
||||||
|
manifest.protocol,
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.overlay.protocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if manifest.version != bundle.overlay.version {
|
||||||
|
bail!(
|
||||||
|
"bundle {} names overlay {} but the downloaded tarball contains {}",
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.overlay.version,
|
||||||
|
manifest.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan the sync ────────────────────────────────────────────────────────
|
||||||
|
let record_path = layout.install_record();
|
||||||
|
let prior = InstallRecord::load(&record_path)?;
|
||||||
|
let prior_files = prior_overlay_files(prior.as_ref(), &root);
|
||||||
|
|
||||||
|
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||||
|
let summary = overlay::summarize(&planned);
|
||||||
|
|
||||||
|
ui::heading("Overlay sync");
|
||||||
|
let lines = overlay::render(&planned);
|
||||||
|
if lines.is_empty() {
|
||||||
|
println!(" (no changes)");
|
||||||
|
}
|
||||||
|
for line in lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.verify {
|
||||||
|
println!(
|
||||||
|
"\n VERIFY only. add={} change={} unchanged={} kept={} (nothing written)",
|
||||||
|
summary.add, summary.change, summary.unchanged, summary.kept
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
overlay::apply(&planned)?;
|
||||||
|
// "deployed" is claimed only when something actually moved. A run that copied nothing
|
||||||
|
// reporting "deployed" would read as a fresh install to anyone skimming the output.
|
||||||
|
println!(
|
||||||
|
"\n {} add={} change={} unchanged={} kept={}",
|
||||||
|
if summary.writes_anything() {
|
||||||
|
"deployed."
|
||||||
|
} else {
|
||||||
|
"unchanged."
|
||||||
|
},
|
||||||
|
summary.add,
|
||||||
|
summary.change,
|
||||||
|
summary.unchanged,
|
||||||
|
summary.kept
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in planned
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.action == overlay::Action::KeptOperatorModified)
|
||||||
|
{
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"{} has local edits — left exactly as it is.\n \
|
||||||
|
The release ships its own copy of this file; if you want the new defaults, compare \
|
||||||
|
yours against\n the one in {}\n and merge by hand. \
|
||||||
|
Every other overlay file is code and is overwritten unconditionally.",
|
||||||
|
file.rel, bundle.overlay.asset.url
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What this build does not do ──────────────────────────────────────────
|
||||||
|
report_patch_tier(cli);
|
||||||
|
report_sidecar(&bundle, &sidecar_asset, &layout);
|
||||||
|
|
||||||
|
// ── Record ───────────────────────────────────────────────────────────────
|
||||||
|
let record = build_record(
|
||||||
|
prior.as_ref(),
|
||||||
|
&bundle,
|
||||||
|
&bundle_url,
|
||||||
|
&root,
|
||||||
|
&manifest,
|
||||||
|
&planned,
|
||||||
|
);
|
||||||
|
|
||||||
|
if cli.verify {
|
||||||
|
println!("\n {} not written (--verify)", record_path.display());
|
||||||
|
} else {
|
||||||
|
match prior.as_ref() {
|
||||||
|
Some(previous) if previous.same_deployment_as(&record) => {
|
||||||
|
println!("\n {} unchanged", record_path.display());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
record.save(&record_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot write {} — run as root/Administrator, or set {} to a writable \
|
||||||
|
directory for a test run",
|
||||||
|
record_path.display(),
|
||||||
|
paths::STATE_DIR_ENV
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
println!("\n Recorded {}", record_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Closing notes ────────────────────────────────────────────────────────
|
||||||
|
println!();
|
||||||
|
if summary.writes_anything() && !cli.verify {
|
||||||
|
println!(
|
||||||
|
"Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\
|
||||||
|
Start your shard when ready; the installer does not start it for you.\n\
|
||||||
|
Note that ServUO ignores the script build's exit code, so a clean boot is not proof \
|
||||||
|
the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \
|
||||||
|
run `[bridge status` in game (INSTALL.md §6)."
|
||||||
|
);
|
||||||
|
} else if cli.verify {
|
||||||
|
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||||
|
} else {
|
||||||
|
println!("Nothing to do — this tree already has this overlay.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.host.is_some() || cli.site_url.is_some() {
|
||||||
|
println!(
|
||||||
|
"\nNote: --host/--site-url are used by the token handoff, which arrives with the \
|
||||||
|
sidecar in Phase 2. They had no effect on this run."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
|
||||||
|
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
|
||||||
|
if let Some(path) = &cli.servuo {
|
||||||
|
return servuo::open_stopped(&PathBuf::from(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(detected) = servuo::detect() {
|
||||||
|
let question = format!("Use the ServUO installation at {}?", detected.display());
|
||||||
|
if ui::confirm(&question, true, cli.assume_yes)? {
|
||||||
|
return servuo::open_stopped(&detected);
|
||||||
|
}
|
||||||
|
} else if cli.assume_yes {
|
||||||
|
// --yes cannot invent a path, and picking one would be the worst possible guess.
|
||||||
|
bail!(
|
||||||
|
"no ServUO installation was found near this binary or the working directory. \
|
||||||
|
Pass --servuo <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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_patch_tier(cli: &Cli) {
|
||||||
|
println!();
|
||||||
|
match cli.patches {
|
||||||
|
// --patches must never pass silently: an operator who asked for the tier and got a clean
|
||||||
|
// run would reasonably conclude that EventSink.cs had been patched.
|
||||||
|
PatchChoice::Yes => {
|
||||||
|
ui::warn(
|
||||||
|
"Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \
|
||||||
|
build (Phase 3).\n \
|
||||||
|
No stock ServUO file has been touched. Apply the patches by hand if you need \
|
||||||
|
them: INSTALL.md Appendix A2.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PatchChoice::No => {
|
||||||
|
ui::row("Patch tier", "skipped (--no-patches)");
|
||||||
|
}
|
||||||
|
PatchChoice::Ask => {
|
||||||
|
ui::row(
|
||||||
|
"Patch tier",
|
||||||
|
"skipped (not implemented in this build — Phase 3)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) {
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
|
||||||
|
Without it the shard has nothing to dial out to and your website stays offline.\n \
|
||||||
|
Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \
|
||||||
|
binary {}\n \
|
||||||
|
config {}\n \
|
||||||
|
database {}\n \
|
||||||
|
download {}\n \
|
||||||
|
sha256 {}\n \
|
||||||
|
Then provision and read the token back with:\n \
|
||||||
|
<binary> --print-config --config {}\n \
|
||||||
|
The bundle pairs it with overlay {} at protocol {}; keep the two in step.",
|
||||||
|
layout.sidecar_bin.display(),
|
||||||
|
layout.sidecar_config().display(),
|
||||||
|
layout.sidecar_db().display(),
|
||||||
|
asset.url,
|
||||||
|
asset.sha256,
|
||||||
|
layout.sidecar_config().display(),
|
||||||
|
bundle.overlay.tag,
|
||||||
|
bundle.protocol,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_record(
|
||||||
|
prior: Option<&InstallRecord>,
|
||||||
|
bundle: &bundle::Bundle,
|
||||||
|
bundle_url: &str,
|
||||||
|
root: &ServUoRoot,
|
||||||
|
manifest: &overlay::Manifest,
|
||||||
|
planned: &[overlay::PlannedFile],
|
||||||
|
) -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
},
|
||||||
|
updated: now_rfc3339(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: bundle.bundle.clone(),
|
||||||
|
protocol: bundle.protocol,
|
||||||
|
url: bundle_url.to_string(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: root.path.to_string_lossy().to_string(),
|
||||||
|
version: root.version.clone(),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: manifest.repo.clone(),
|
||||||
|
tag: bundle.overlay.tag.clone(),
|
||||||
|
version: manifest.version.clone(),
|
||||||
|
commit: manifest.commit.clone(),
|
||||||
|
protocol: manifest.protocol,
|
||||||
|
files: overlay::file_records(planned),
|
||||||
|
}),
|
||||||
|
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
|
||||||
|
// re-running on a fully installed host cannot make a service or a set of applied patch
|
||||||
|
// hunks disappear from the record that documents them.
|
||||||
|
link: prior.and_then(|p| p.link.clone()),
|
||||||
|
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||||
|
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::record::{FileRecord, ServUoRef};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn record_for(path: &str) -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
},
|
||||||
|
updated: now_rfc3339(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: "2026.08.04".into(),
|
||||||
|
protocol: 3,
|
||||||
|
url: "https://example/current.json".into(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: path.into(),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: "RunicGateway/servuo-plugins".into(),
|
||||||
|
tag: "v0.1.1".into(),
|
||||||
|
version: "0.1.1".into(),
|
||||||
|
commit: "3a52abb".into(),
|
||||||
|
protocol: 3,
|
||||||
|
files: BTreeMap::from([(
|
||||||
|
"Config/Bridge.cfg".to_string(),
|
||||||
|
FileRecord {
|
||||||
|
overlay_sha256: "aa".into(),
|
||||||
|
on_disk_sha256: "bb".into(),
|
||||||
|
state: "kept-operator-modified".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
}),
|
||||||
|
link: None,
|
||||||
|
patches: Vec::new(),
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_at(path: &str) -> ServUoRoot {
|
||||||
|
ServUoRoot {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_for_this_tree_is_used() {
|
||||||
|
let record = record_for("/opt/ServUO");
|
||||||
|
let files = prior_overlay_files(Some(&record), &root_at("/opt/ServUO"));
|
||||||
|
assert!(files.is_some_and(|f| f.contains_key("Config/Bridge.cfg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_for_a_different_tree_is_ignored() {
|
||||||
|
// Otherwise a second shard on the same host would inherit the first's hashes and could
|
||||||
|
// have its Bridge.cfg overwritten on the strength of a comparison that never applied to it.
|
||||||
|
let record = record_for("/opt/ServUO-old");
|
||||||
|
assert!(prior_overlay_files(Some(&record), &root_at("/opt/ServUO")).is_none());
|
||||||
|
assert!(prior_overlay_files(None, &root_at("/opt/ServUO")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/lib.rs
Normal file
127
src/lib.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//! Runic Gateway installer.
|
||||||
|
//!
|
||||||
|
//! Takes a working ServUO installation and connects it to a Runic Gateway website. The design of
|
||||||
|
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||||
|
//! existed, is `docs/installer/INSTALL.md`.
|
||||||
|
//!
|
||||||
|
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
|
||||||
|
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
|
||||||
|
//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||||
|
//! every one of them says so when reached rather than failing as though it were a typo.
|
||||||
|
//!
|
||||||
|
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||||
|
//! convention as the sidecar's CLI.
|
||||||
|
//!
|
||||||
|
//! ## Why the library target is called `rgdeploy`
|
||||||
|
//!
|
||||||
|
//! Windows applies **UAC installer detection** to unsigned executables whose file name contains
|
||||||
|
//! `install`, `setup`, `update` or `patch`: it decides the program is a legacy installer and
|
||||||
|
//! demands elevation before the process starts. That is tolerable for the shipped binary, which
|
||||||
|
//! needs Administrator anyway and is documented as being run from an elevated shell — but Cargo
|
||||||
|
//! names test harnesses after their target, so a target called `runicgateway_installer` produces
|
||||||
|
//! `runicgateway_installer-<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 bundle;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod install;
|
||||||
|
pub mod net;
|
||||||
|
pub mod overlay;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod record;
|
||||||
|
pub mod servuo;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
use cli::{Command, Mode};
|
||||||
|
|
||||||
|
/// The whole program. Returns the process exit code rather than calling `exit` itself, so the
|
||||||
|
/// entry point stays a one-liner and this stays callable from a test.
|
||||||
|
pub fn run() -> i32 {
|
||||||
|
ui::init_console();
|
||||||
|
|
||||||
|
let parsed = match cli::parse(std::env::args().skip(1)) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("error: {message}\n");
|
||||||
|
eprint!("{}", cli::USAGE);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match parsed.mode {
|
||||||
|
Mode::Help => {
|
||||||
|
print!("{}", cli::USAGE);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Mode::Version => {
|
||||||
|
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Mode::Run(Command::Install) => install::run(&parsed),
|
||||||
|
Mode::Run(command) => Err(not_implemented(command)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
// The chain is printed, not just the outermost message: "cannot write
|
||||||
|
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||||
|
eprintln!("\nerror: {error}");
|
||||||
|
for cause in error.chain().skip(1) {
|
||||||
|
eprintln!(" caused by: {cause}");
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command the contract documents but this phase has not built.
|
||||||
|
///
|
||||||
|
/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished.
|
||||||
|
fn not_implemented(command: Command) -> anyhow::Error {
|
||||||
|
let (phase, workaround) = match command {
|
||||||
|
Command::Doctor => (
|
||||||
|
"Phase 4",
|
||||||
|
"Check the deployment by hand: `[bridge status` in game, and \
|
||||||
|
`curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).",
|
||||||
|
),
|
||||||
|
Command::Update => (
|
||||||
|
"Phase 4",
|
||||||
|
"Re-run `install` to move the overlay to the current bundle; replace the sidecar \
|
||||||
|
binary by hand (INSTALL.md Appendix A6).",
|
||||||
|
),
|
||||||
|
Command::Uninstall => (
|
||||||
|
"Phase 4",
|
||||||
|
"Remove the sidecar service and binary by hand; the overlay files this installer \
|
||||||
|
deployed are listed in install.json.",
|
||||||
|
),
|
||||||
|
Command::Install => unreachable!("install is implemented"),
|
||||||
|
};
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"`{command}` is not implemented in this build — it arrives in {phase} \
|
||||||
|
(see docs/installer/PLAN.md §5).\n{workaround}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfinished_commands_name_their_phase_and_a_way_through() {
|
||||||
|
// An operator who runs `doctor` today must not be left thinking they typed it wrong, and
|
||||||
|
// must not be left with nothing to do either.
|
||||||
|
for command in [Command::Doctor, Command::Update, Command::Uninstall] {
|
||||||
|
let message = not_implemented(command).to_string();
|
||||||
|
assert!(message.contains(&command.to_string()), "{message}");
|
||||||
|
assert!(message.contains("Phase 4"), "{message}");
|
||||||
|
assert!(
|
||||||
|
message.contains("INSTALL.md") || message.contains("install.json"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
117
src/net.rs
Normal file
117
src/net.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! HTTP fetches and verified downloads.
|
||||||
|
//!
|
||||||
|
//! Everything this module pulls comes from a public Gitea repo over anonymous HTTPS — the shard
|
||||||
|
//! host has no Gitea credentials and needs none (PLAN.md §1). The one rule that matters:
|
||||||
|
//! **nothing downloaded is used before its SHA256 has been checked against the bundle.** The
|
||||||
|
//! artifacts are deliberately unsigned (§3), so the checksum is the entire trust anchor, and a
|
||||||
|
//! download that "mostly worked" is exactly the case that must not proceed.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, BufWriter};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::util::HashingWriter;
|
||||||
|
|
||||||
|
/// Identifies the tool and its version in Gitea's logs — worth having when an operator reports that
|
||||||
|
/// a fetch failed and nobody can tell which build made the request.
|
||||||
|
fn user_agent() -> String {
|
||||||
|
format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
|
||||||
|
///
|
||||||
|
/// The global timeout is generous because the overlay tarball travels over whatever link the shard
|
||||||
|
/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run
|
||||||
|
/// with a message instead of hanging an operator's terminal indefinitely.
|
||||||
|
fn agent() -> ureq::Agent {
|
||||||
|
ureq::Agent::config_builder()
|
||||||
|
.user_agent(user_agent())
|
||||||
|
.timeout_global(Some(Duration::from_secs(300)))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a small text document (the bundle manifest).
|
||||||
|
pub fn get_text(url: &str) -> Result<String> {
|
||||||
|
let mut response = agent()
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("cannot reach {url}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!("{url} returned HTTP {}", status.as_u16());
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.with_context(|| format!("cannot read the response from {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads `url` to `dest`, verifying SHA256 **while writing**.
|
||||||
|
///
|
||||||
|
/// On mismatch the partial file is removed before returning: leaving a wrong-hash artifact on disk
|
||||||
|
/// invites a later step — or a puzzled operator — to use it anyway.
|
||||||
|
pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Result<()> {
|
||||||
|
let expected = expected_sha256.trim().to_ascii_lowercase();
|
||||||
|
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = agent()
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("cannot reach {url}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!("{url} returned HTTP {}", status.as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?;
|
||||||
|
let mut writer = HashingWriter::new(BufWriter::new(file));
|
||||||
|
io::copy(&mut response.body_mut().as_reader(), &mut writer)
|
||||||
|
.with_context(|| format!("download of {url} failed"))?;
|
||||||
|
let actual = writer.finish();
|
||||||
|
|
||||||
|
if actual != expected {
|
||||||
|
let _ = std::fs::remove_file(dest);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"checksum mismatch for {url}\n expected {expected}\n got {actual}\n\
|
||||||
|
These artifacts are unsigned, so the checksum is the only thing vouching for them. \
|
||||||
|
Refusing to use this download."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_user_agent_names_the_build() {
|
||||||
|
let ua = user_agent();
|
||||||
|
assert!(ua.starts_with("runicgateway-installer/"), "{ua}");
|
||||||
|
assert!(ua.len() > "runicgateway-installer/".len(), "{ua}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_expected_hash_is_refused_before_any_request() {
|
||||||
|
// A bundle whose sha256 field is truncated, uppercase-garbled or empty must stop the run
|
||||||
|
// rather than download something that can then only be compared against nonsense. The URL
|
||||||
|
// is unroutable on purpose: reaching the network at all would be the bug.
|
||||||
|
let dir = TempDir::new("rg-test-net").unwrap();
|
||||||
|
let dest = dir.path().join("artifact");
|
||||||
|
for bad in ["", "abc", &"z".repeat(64)] {
|
||||||
|
let err = download_verified("http://127.0.0.1:1/artifact", &dest, bad).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("unusable SHA256"),
|
||||||
|
"expected a pre-flight refusal, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!dest.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
696
src/overlay.rs
Normal file
696
src/overlay.rs
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
//! The plugin overlay: unpack the release, then sync it into the ServUO tree.
|
||||||
|
//!
|
||||||
|
//! The plugin ships as **C# source that ServUO compiles at boot** (PLAN.md §2.1), so deployment is
|
||||||
|
//! a hash-compare file copy rather than a DLL drop. Three rules govern it:
|
||||||
|
//!
|
||||||
|
//! - **Nothing is ever deleted.** `overlay/` mirrors the server root and only adds or overwrites.
|
||||||
|
//! That is `deploy.ps1`'s behaviour and the installer inherits it: the ServUO tree belongs to the
|
||||||
|
//! operator, and a deployment tool that removes files from it is a deployment tool that
|
||||||
|
//! eventually removes the wrong one.
|
||||||
|
//! - **`Config/Bridge.cfg` is reported, not overwritten, once it has been edited** — the single
|
||||||
|
//! deviation from `deploy.ps1` (PLAN.md §5, Phase 1). It is the only file in the overlay that is
|
||||||
|
//! *meant* to be edited in place, and it carries no code, so a stale copy cannot break the build.
|
||||||
|
//! Silently reverting it would throw away `LinkUrl`, `PublicConnectAddress` and every sweep
|
||||||
|
//! interval on an `update`.
|
||||||
|
//! - **A successful copy is not a working bridge.** ServUO ignores the script build's exit code
|
||||||
|
//! and reloads the previous `Scripts.dll` (§2.1), so nothing here may report success in terms
|
||||||
|
//! stronger than "the files are in place".
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::bundle::ServUoCompat;
|
||||||
|
use crate::record::FileRecord;
|
||||||
|
use crate::util::sha256_file;
|
||||||
|
|
||||||
|
/// The tarball's fixed top-level directory. Fixed rather than versioned on purpose: the installer
|
||||||
|
/// looks for `overlay/`, `patches/` and `manifest.json` at known paths instead of parsing the very
|
||||||
|
/// version it is trying to read (PLAN.md §5, Phase 0 item 1).
|
||||||
|
const TOP_LEVEL_DIR: &str = "runicgateway-overlay";
|
||||||
|
|
||||||
|
/// Files the operator owns once deployed. Everything else — every `.cs` file and `Scripts.csproj` —
|
||||||
|
/// is overwritten unconditionally, because it is code and a stale copy breaks the build.
|
||||||
|
const OPERATOR_OWNED: &[&str] = &["Config/Bridge.cfg"];
|
||||||
|
|
||||||
|
/// `manifest.json`, generated by the `servuo-plugins` release workflow (PLAN.md §7.0).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub component: String,
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub repo: String,
|
||||||
|
/// The plugin half of the compatibility contract, declared in `overlay.toml`. Nothing can
|
||||||
|
/// derive it — the plugin announces no version on the wire and none is queryable before ServUO
|
||||||
|
/// boots — which is why it is checked against the bundle before anything is written.
|
||||||
|
pub protocol: u32,
|
||||||
|
pub servuo: ServUoCompat,
|
||||||
|
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
|
||||||
|
pub files: BTreeMap<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(),
|
||||||
|
},
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/paths.rs
Normal file
113
src/paths.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//! Where the installer's own files live.
|
||||||
|
//!
|
||||||
|
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
|
||||||
|
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
|
||||||
|
//! working directory, and a service manager's working directory is not somewhere to put a database.
|
||||||
|
//! The installer therefore owns the layout and (from Phase 2) pins `UOLINK_CONFIG` and
|
||||||
|
//! `UOLINK_DB_PATH` into the service definition.
|
||||||
|
//!
|
||||||
|
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
|
||||||
|
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than
|
||||||
|
/// hidden: an undocumented environment variable that moves where a tool writes is worse than a
|
||||||
|
/// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote.
|
||||||
|
pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Layout {
|
||||||
|
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
||||||
|
pub state_dir: PathBuf,
|
||||||
|
/// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2.
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
/// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2.
|
||||||
|
pub sidecar_bin: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn install_record(&self) -> PathBuf {
|
||||||
|
self.state_dir.join("install.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidecar_config(&self) -> PathBuf {
|
||||||
|
self.state_dir.join("sidecar.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidecar_db(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("uo-link.db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||||
|
///
|
||||||
|
/// The override moves the *state* and *data* directories together. Splitting them under an override
|
||||||
|
/// would make a test run write half its files into the real system location, which is exactly the
|
||||||
|
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
|
||||||
|
/// it, and a relocated binary would not be what the service definition names.
|
||||||
|
pub fn layout() -> Layout {
|
||||||
|
let mut layout = platform_layout();
|
||||||
|
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
||||||
|
let root = PathBuf::from(dir);
|
||||||
|
layout.data_dir = root.join("data");
|
||||||
|
layout.state_dir = root;
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn platform_layout() -> Layout {
|
||||||
|
// %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to
|
||||||
|
// C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic.
|
||||||
|
let program_data = env::var_os("ProgramData")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
|
||||||
|
let program_files = env::var_os("ProgramFiles")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
|
||||||
|
|
||||||
|
// Data lives under ProgramData, never under ProgramFiles: a service writing beneath
|
||||||
|
// C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3).
|
||||||
|
Layout {
|
||||||
|
state_dir: program_data.join("RunicGateway"),
|
||||||
|
data_dir: program_data.join("RunicGateway"),
|
||||||
|
sidecar_bin: program_files
|
||||||
|
.join("RunicGateway")
|
||||||
|
.join("uo-link-sidecar.exe"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn platform_layout() -> Layout {
|
||||||
|
Layout {
|
||||||
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||||
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||||
|
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_installers_own_files_sit_in_the_state_dir() {
|
||||||
|
// Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to
|
||||||
|
// clean and `doctor` has one place to read. The cached patch set joins them in Phase 3.
|
||||||
|
let l = platform_layout();
|
||||||
|
assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path()));
|
||||||
|
assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_layout_is_absolute() {
|
||||||
|
// A relative state directory would reintroduce exactly the working-directory trap this
|
||||||
|
// layout exists to close.
|
||||||
|
let l = platform_layout();
|
||||||
|
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
|
||||||
|
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
||||||
|
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/record.rs
Normal file
265
src/record.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//! `install.json` — what this host has deployed.
|
||||||
|
//!
|
||||||
|
//! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later
|
||||||
|
//! command reasons from. Two of its properties are load-bearing rather than informational:
|
||||||
|
//!
|
||||||
|
//! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the
|
||||||
|
//! record and the release manifest means the overlay moved on; differing from the record alone
|
||||||
|
//! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and
|
||||||
|
//! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on.
|
||||||
|
//! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on
|
||||||
|
//! a host where Phase 2 and 3 have written sidecar and patch records must give them back
|
||||||
|
//! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved
|
||||||
|
//! verbatim. A future field that silently vanished on a re-run would be worse than one that was
|
||||||
|
//! never written.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::util::write_atomic;
|
||||||
|
|
||||||
|
/// The shape of this document. Independent of the bundle's `schema` and of any protocol version.
|
||||||
|
pub const SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct InstallRecord {
|
||||||
|
pub schema: u32,
|
||||||
|
pub installer: InstallerInfo,
|
||||||
|
/// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one
|
||||||
|
/// excluded when deciding whether a re-run has anything to record.
|
||||||
|
pub updated: String,
|
||||||
|
pub bundle: BundleRef,
|
||||||
|
pub servuo: ServUoRef,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub overlay: Option<OverlayRecord>,
|
||||||
|
/// Phase 2 (uo-link binary, config and service). Carried through untouched by this build.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub link: Option<serde_json::Value>,
|
||||||
|
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_rfc3339() -> String {
|
||||||
|
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
fn sample() -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
},
|
||||||
|
updated: "2026-08-04T18:00:00Z".into(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: "2026.08.04".into(),
|
||||||
|
protocol: 3,
|
||||||
|
url: "https://example/bundles/current.json".into(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: "/opt/ServUO".into(),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: "RunicGateway/servuo-plugins".into(),
|
||||||
|
tag: "v0.1.1".into(),
|
||||||
|
version: "0.1.1".into(),
|
||||||
|
commit: "3a52abb".into(),
|
||||||
|
protocol: 3,
|
||||||
|
files: BTreeMap::from([(
|
||||||
|
"Config/Bridge.cfg".to_string(),
|
||||||
|
FileRecord {
|
||||||
|
overlay_sha256: "aa".into(),
|
||||||
|
on_disk_sha256: "aa".into(),
|
||||||
|
state: "deployed".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
}),
|
||||||
|
link: None,
|
||||||
|
patches: Vec::new(),
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_round_trips() {
|
||||||
|
let dir = TempDir::new("rg-test-record").unwrap();
|
||||||
|
let path = dir.path().join("install.json");
|
||||||
|
let record = sample();
|
||||||
|
record.save(&path).unwrap();
|
||||||
|
assert_eq!(InstallRecord::load(&path).unwrap().unwrap(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_record_is_not_an_error() {
|
||||||
|
let dir = TempDir::new("rg-test-record-missing").unwrap();
|
||||||
|
assert!(InstallRecord::load(&dir.path().join("nope.json"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_phases_survive_a_phase_one_rewrite() {
|
||||||
|
// The scenario: Phase 2 and 3 have written sidecar and patch sections (and some future
|
||||||
|
// field this build has never heard of), then an older installer re-runs. Dropping any of
|
||||||
|
// it would make `doctor` and `uninstall` forget a service and a set of applied hunks.
|
||||||
|
let dir = TempDir::new("rg-test-record-forward").unwrap();
|
||||||
|
let path = dir.path().join("install.json");
|
||||||
|
let body = r#"{
|
||||||
|
"schema": 1,
|
||||||
|
"installer": { "version": "0.9.0" },
|
||||||
|
"updated": "2026-09-01T00:00:00Z",
|
||||||
|
"bundle": { "tag": "2026.09.01", "protocol": 3, "url": "https://example/current.json" },
|
||||||
|
"servuo": { "path": "/opt/ServUO", "version": "57.4" },
|
||||||
|
"overlay": null,
|
||||||
|
"link": { "version": "1.1.0", "service": "runicgateway-link.service" },
|
||||||
|
"patches": [ { "name": "commandlogging-event", "rung": "region-match" } ],
|
||||||
|
"future_section": { "kept": true }
|
||||||
|
}"#;
|
||||||
|
std::fs::write(&path, body).unwrap();
|
||||||
|
|
||||||
|
let loaded = InstallRecord::load(&path).unwrap().unwrap();
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
|
||||||
|
assert!(text.contains("runicgateway-link.service"), "{text}");
|
||||||
|
assert!(text.contains("region-match"), "{text}");
|
||||||
|
assert!(text.contains("future_section"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_timestamp_is_ignored_when_deciding_to_rewrite() {
|
||||||
|
let a = sample();
|
||||||
|
let mut b = a.clone();
|
||||||
|
b.updated = "2027-01-01T00:00:00Z".into();
|
||||||
|
assert!(a.same_deployment_as(&b));
|
||||||
|
|
||||||
|
// Anything that actually describes the deployment must count as a change.
|
||||||
|
let mut c = a.clone();
|
||||||
|
c.bundle.tag = "2026.09.01".into();
|
||||||
|
assert!(!a.same_deployment_as(&c));
|
||||||
|
|
||||||
|
let mut d = a.clone();
|
||||||
|
if let Some(overlay) = d.overlay.as_mut() {
|
||||||
|
overlay.files.get_mut("Config/Bridge.cfg").unwrap().state =
|
||||||
|
"kept-operator-modified".into();
|
||||||
|
}
|
||||||
|
assert!(!a.same_deployment_as(&d));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_are_utc_rfc3339() {
|
||||||
|
let now = now_rfc3339();
|
||||||
|
assert!(now.ends_with('Z'), "{now}");
|
||||||
|
assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}");
|
||||||
|
}
|
||||||
|
}
|
||||||
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 (Phase 3) 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 gates the patch tier in Phase 3.
|
||||||
|
let tmp = TempDir::new("rg-test-noversion").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
let opened = open(&root).unwrap();
|
||||||
|
assert_eq!(opened.version, None);
|
||||||
|
assert!(!opened.is_supported_version());
|
||||||
|
assert_eq!(opened.version_display(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assembly_version_parsing_handles_real_world_spellings() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[assembly: AssemblyVersion(\"57.4\")]").as_deref(),
|
||||||
|
Some("57.4")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[ assembly : AssemblyVersion ( \"57.4.0.0\" ) ]").as_deref(),
|
||||||
|
Some("57.4.0.0")
|
||||||
|
);
|
||||||
|
// A fork that left an old declaration commented out must not win.
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version(
|
||||||
|
"// [assembly: AssemblyVersion(\"56.0\")]\n[assembly: AssemblyVersion(\"57.4\")]"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("57.4")
|
||||||
|
);
|
||||||
|
assert_eq!(parse_assembly_version("no version here"), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[assembly: AssemblyVersion(\"\")]"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotnet_padding_does_not_make_a_supported_tree_unsupported() {
|
||||||
|
assert_eq!(normalize_version("57.4.0.0"), "57.4");
|
||||||
|
assert_eq!(normalize_version("57.4"), "57.4");
|
||||||
|
assert_eq!(normalize_version("0.0.0"), "0");
|
||||||
|
// Padding is stripped; a genuinely different version still differs.
|
||||||
|
assert_ne!(normalize_version("57.40"), SUPPORTED_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detection_finds_a_root_from_a_subdirectory() {
|
||||||
|
let tmp = TempDir::new("rg-test-detect").unwrap();
|
||||||
|
let root = tmp.path().join("ServUO");
|
||||||
|
fake_root(&root);
|
||||||
|
let deep = root.join("Scripts").join("Custom");
|
||||||
|
fs::create_dir_all(&deep).unwrap();
|
||||||
|
|
||||||
|
// detect() reads the process's working directory, so exercise the walk directly on the
|
||||||
|
// same chain it uses rather than mutating global state inside a threaded test runner.
|
||||||
|
let mut candidate: &Path = &deep;
|
||||||
|
let mut found = None;
|
||||||
|
for _ in 0..5 {
|
||||||
|
if looks_like_root(candidate) {
|
||||||
|
found = Some(candidate.to_path_buf());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = candidate.parent().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(found.as_deref(), Some(root.as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_refusal_says_why_and_offers_no_override() {
|
||||||
|
let shard = RunningShard {
|
||||||
|
pid: 4242,
|
||||||
|
detail: "/opt/ServUO/ServUO.exe".into(),
|
||||||
|
};
|
||||||
|
let msg = running_error(Path::new("/opt/ServUO"), &shard).to_string();
|
||||||
|
assert!(msg.contains("4242"), "{msg}");
|
||||||
|
assert!(msg.contains("Scripts.dll"), "{msg}");
|
||||||
|
assert!(msg.contains("not overridable"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_is_running_out_of_an_empty_tree() {
|
||||||
|
// Also proves the scan does not match the test binary itself, which is the failure mode
|
||||||
|
// that would make every install refuse to start.
|
||||||
|
let tmp = TempDir::new("rg-test-running").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
assert!(find_running(&root).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/util.rs
Normal file
205
src/util.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//! Hashing and scratch-directory helpers.
|
||||||
|
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Lower-case hex, written out rather than taken from a crate.
|
||||||
|
///
|
||||||
|
/// Every hash this tool handles is compared against one produced by `sha256sum` or by `jq` in CI,
|
||||||
|
/// both of which emit lower-case hex — so the formatting is part of the contract, not a display
|
||||||
|
/// choice.
|
||||||
|
pub fn hex(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
out.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a
|
||||||
|
/// straight-line hash of the same bytes; the run itself only ever hashes files and streams.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn sha256_bytes(bytes: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
hex(&hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's
|
||||||
|
/// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs
|
||||||
|
/// once per deployed file.
|
||||||
|
pub fn sha256_file(path: &Path) -> Result<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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 the_hashing_writer_sees_what_was_written() {
|
||||||
|
let mut w = HashingWriter::new(Vec::new());
|
||||||
|
w.write_all(b"abc").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
w.finish(),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_temp_dir_removes_itself() {
|
||||||
|
let path = {
|
||||||
|
let dir = TempDir::new("rg-test-drop").unwrap();
|
||||||
|
fs::write(dir.path().join("x"), b"x").unwrap();
|
||||||
|
dir.path().to_path_buf()
|
||||||
|
};
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_replaces_an_existing_file() {
|
||||||
|
let dir = TempDir::new("rg-test-atomic").unwrap();
|
||||||
|
let path = dir.path().join("nested").join("install.json");
|
||||||
|
write_atomic(&path, b"first").unwrap();
|
||||||
|
write_atomic(&path, b"second").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"second");
|
||||||
|
assert!(!path.with_extension("tmp").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user