Merge pull request 'feat(installer): implement Phase 1 — the installer core' (#4) from feat/phase1-installer-core into edge

Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-08-04 20:02:35 +00:00
17 changed files with 4106 additions and 21 deletions

View File

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

View File

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

View File

@@ -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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
//! Entry point. Everything lives in the library — see `src/lib.rs`, including why the library
//! target is not named after this binary.
fn main() {
std::process::exit(rgdeploy::run());
}

117
src/net.rs Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,402 @@
//! Finding, validating and interrogating a ServUO installation.
//!
//! Three questions, in the order the installer asks them:
//!
//! 1. **Where is it?** `--servuo`, else detection from where the binary was run, else a prompt.
//! 2. **Is it really one?** `ServUO.exe`, `Scripts/` and `Config/` must all be present
//! (INSTALL.md §2). Deploying 24 files into a directory that merely looked plausible is a mess
//! to unpick by hand.
//! 3. **Is it running?** If it is, the run stops. `deploy.ps1` hard-throws here and the installer
//! inherits that (PLAN.md §2.5): ServUO holds `Scripts.dll` open and rewrites `Saves/` on exit,
//! so deploying underneath it corrupts one or both.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// The version everything is designed, built and tested against (PLAN.md §2.2.2).
pub const SUPPORTED_VERSION: &str = "57.4";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServUoRoot {
pub path: PathBuf,
/// `None` when `Server/AssemblyInfo.cs` is absent or unparseable. Reported as "unknown", which
/// is treated exactly like any other non-57.4 answer: the base install proceeds, and the patch
/// tier (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
View File

@@ -0,0 +1,123 @@
//! Terminal output and prompts.
//!
//! Two rules shape this module:
//!
//! 1. **A run's output is a support artifact.** `docs/installer/INSTALL.md` shows operators what a
//! run looks like, and the first thing anyone asks for in a bug report is a pasted log — so the
//! marks and the column layout here match the guide rather than being decided per call site.
//! 2. **Nothing here is a secret.** The auth token is printed by the handoff (Phase 2) straight to
//! the operator's terminal and never routed through a log file (PLAN.md §6).
use std::io::{self, IsTerminal, Write};
/// Enables UTF-8 on the Windows console so the status marks below are not mojibake.
///
/// The guide's illustrated output uses `✓ ⚠ ✗`, and PowerShell 5.1 on a machine whose console code
/// page is still 437/1252 renders those as garbage. `SetConsoleOutputCP` is the one-call fix; it is
/// declared inline rather than pulling in a Windows binding crate for a single symbol, and a
/// failure is ignored because a wrongly-encoded tick is a cosmetic problem, not a reason to refuse
/// to install.
#[cfg(windows)]
pub fn init_console() {
extern "system" {
fn SetConsoleOutputCP(code_page: u32) -> i32;
}
const CP_UTF8: u32 = 65001;
unsafe {
SetConsoleOutputCP(CP_UTF8);
}
}
#[cfg(not(windows))]
pub fn init_console() {}
pub fn ok(msg: &str) {
println!("{msg}");
}
pub fn warn(msg: &str) {
println!("{msg}");
}
pub fn heading(msg: &str) {
println!("\n{msg}");
}
/// A two-column row: ` label value`.
pub fn row(label: &str, value: &str) {
println!(" {label:<16} {value}");
}
/// Asks a yes/no question.
///
/// `assume_yes` (`--yes`) takes the default without asking, which is what makes an unattended run
/// expressible. A non-interactive run *without* `--yes` is an error rather than a silent default:
/// the questions this asks decide whether stock ServUO files get edited, and a pipe with no
/// terminal on the other end cannot consent to that.
pub fn confirm(question: &str, default: bool, assume_yes: bool) -> io::Result<bool> {
if assume_yes {
println!(
"{question} [{}] (--yes)",
if default { "Y/n" } else { "y/N" }
);
return Ok(default);
}
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"cannot ask \"{question}\" — stdin is not a terminal. \
Pass --yes to take the default, or the matching flag to answer it explicitly."
)));
}
loop {
print!("{question} [{}] ", if default { "Y/n" } else { "y/N" });
io::stdout().flush()?;
let mut line = String::new();
// EOF (0 bytes) is not "yes". It means the operator is gone; take the default and move on.
if io::stdin().read_line(&mut line)? == 0 {
println!();
return Ok(default);
}
match line.trim().to_ascii_lowercase().as_str() {
"" => return Ok(default),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!(" please answer y or n"),
}
}
}
/// Asks for a line of text. An empty answer keeps `default` when one is offered.
pub fn prompt(question: &str, default: Option<&str>) -> io::Result<String> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"cannot ask \"{question}\" — stdin is not a terminal. Pass the matching flag."
)));
}
loop {
match default {
Some(d) => print!("{question} [{d}]: "),
None => print!("{question}: "),
}
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
return match default {
Some(d) => Ok(d.to_string()),
None => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("no answer for \"{question}\""),
)),
};
}
let answer = line.trim();
if !answer.is_empty() {
return Ok(answer.to_string());
}
if let Some(d) = default {
return Ok(d.to_string());
}
println!(" an answer is required");
}
}

205
src/util.rs Normal file
View 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());
}
}