feat(installer): implement Phase 1 — the installer core #4
@@ -7,12 +7,12 @@
|
||||
# The one structural difference is the crate guard below.
|
||||
#
|
||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||
# This repo is in the planning phase and has no Cargo project yet (the design of
|
||||
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather
|
||||
# than leave the repo ungated until then — or land a workflow that red-Xes every
|
||||
# governance/docs PR — the gates are conditional on a root Cargo.toml existing.
|
||||
# Before the crate lands, the job reports green with a notice. The moment
|
||||
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change.
|
||||
# The gates are conditional on a root Cargo.toml existing: before the crate
|
||||
# landed, this job reported green with a notice so governance/docs PRs were not
|
||||
# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
|
||||
# `edge`, so the gates arm themselves there automatically — and stay dormant on
|
||||
# a `main` PR until the cutover merges the crate into it. Nothing here changes at
|
||||
# that point either.
|
||||
#
|
||||
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
||||
# this repo's sole product is the one installer binary, so there is nothing to
|
||||
@@ -35,7 +35,12 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that
|
||||
# release.yml — which fires on every push to `main` — does not publish an installer binary that
|
||||
# can deploy the overlay but not yet install the sidecar. Ungating the branch where all the
|
||||
# work actually happens would leave the gates running only at the cutover, which is the one
|
||||
# moment a red build is most expensive.
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
# • Artifact names follow docs/installer/PLAN.md §3.
|
||||
#
|
||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||
# The repo is in the planning phase. With no Cargo.toml there is nothing to
|
||||
# build, so the plan step forces RELEASE=false and the job exits green having
|
||||
# done nothing. It starts cutting real releases the moment Phase 1 lands the
|
||||
# crate — no edit required here.
|
||||
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
|
||||
# forces RELEASE=false and the job exits green having done nothing.
|
||||
#
|
||||
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
|
||||
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
|
||||
# workflow keeps standing down — an installer binary that syncs the overlay but
|
||||
# cannot install the sidecar is not something to publish to operators. The first
|
||||
# release is cut by the `edge → main` cutover, with no edit required here.
|
||||
#
|
||||
# ── Unsigned releases ────────────────────────────────────────────────────────
|
||||
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
||||
|
||||
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
|
||||
|
||||
**Planning — no installer code exists yet.**
|
||||
**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.**
|
||||
|
||||
The design of record is
|
||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
||||
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
||||
sidecar + overlay combination, recomposed on every component release and nightly
|
||||
(see [`bundles/README.md`](bundles/README.md)).
|
||||
|
||||
All three Phase 0 prerequisites have now landed, so **what the installer will
|
||||
install already exists and is published**, ahead of the binary that installs it:
|
||||
[`bundles/current.json`](bundles/current.json) names the current
|
||||
protocol-checked sidecar + overlay combination, recomposed on every component
|
||||
release and nightly. See [`bundles/README.md`](bundles/README.md).
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| 0 — prerequisites in the other repos | ✅ merged |
|
||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||
| 2 — uo-link install + service registration | next |
|
||||
| 3 — the opt-in stock-file patch tier | |
|
||||
| 4 — `doctor`, `update`, `uninstall` | |
|
||||
|
||||
Besides that, this repo currently holds its governance documents and issue/PR
|
||||
templates.
|
||||
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||
`main`, and a binary that deploys the overlay but cannot yet install the sidecar
|
||||
is not something to hand an operator. Phases 1 and 2 land on `edge`; the
|
||||
`edge → main` cutover cuts the first release. PRs into `edge` run the same gates
|
||||
as PRs into `main`.
|
||||
|
||||
Until then, the way to install is by hand —
|
||||
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||
is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
|
||||
## Related repos
|
||||
|
||||
@@ -82,13 +94,29 @@ templates.
|
||||
|
||||
## Build & run
|
||||
|
||||
Once the crate exists it will be a standard cargo project:
|
||||
A standard cargo project, with the crate at the repo root:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo run -- --help
|
||||
cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing
|
||||
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||
```
|
||||
|
||||
`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway`
|
||||
or `%ProgramData%\RunicGateway`), which is how a run is tested without root.
|
||||
|
||||
Two layout notes that look odd until you know why:
|
||||
|
||||
- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC
|
||||
installer detection refuses to launch an unsigned executable whose file name
|
||||
contains `install` (`os error 740`), and Cargo names test harnesses after their
|
||||
target — so a target under that name makes `cargo test` unrunnable on Windows.
|
||||
The published binary keeps its documented name; `[[bin]] test = false` keeps
|
||||
Cargo from building a harness under it. Expect a UAC prompt when running the
|
||||
built binary on Windows; it needs Administrator anyway.
|
||||
- **`Cargo.lock` is committed**, and CI builds `--locked`.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
||||
checks CI will run, and the branch/PR workflow.
|
||||
|
||||
|
||||
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