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:
@@ -7,12 +7,12 @@
|
|||||||
# The one structural difference is the crate guard below.
|
# The one structural difference is the crate guard below.
|
||||||
#
|
#
|
||||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||||
# This repo is in the planning phase and has no Cargo project yet (the design of
|
# The gates are conditional on a root Cargo.toml existing: before the crate
|
||||||
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather
|
# landed, this job reported green with a notice so governance/docs PRs were not
|
||||||
# than leave the repo ungated until then — or land a workflow that red-Xes every
|
# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
|
||||||
# governance/docs PR — the gates are conditional on a root Cargo.toml existing.
|
# `edge`, so the gates arm themselves there automatically — and stay dormant on
|
||||||
# Before the crate lands, the job reports green with a notice. The moment
|
# a `main` PR until the cutover merges the crate into it. Nothing here changes at
|
||||||
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change.
|
# that point either.
|
||||||
#
|
#
|
||||||
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
|
||||||
# this repo's sole product is the one installer binary, so there is nothing to
|
# this repo's sole product is the one installer binary, so there is nothing to
|
||||||
@@ -35,7 +35,12 @@ name: PR Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
# `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that
|
||||||
|
# release.yml — which fires on every push to `main` — does not publish an installer binary that
|
||||||
|
# can deploy the overlay but not yet install the sidecar. Ungating the branch where all the
|
||||||
|
# work actually happens would leave the gates running only at the cutover, which is the one
|
||||||
|
# moment a red build is most expensive.
|
||||||
|
branches: [main, edge]
|
||||||
|
|
||||||
# A newer push to the same PR cancels the in-flight run.
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -21,10 +21,14 @@
|
|||||||
# • Artifact names follow docs/installer/PLAN.md §3.
|
# • Artifact names follow docs/installer/PLAN.md §3.
|
||||||
#
|
#
|
||||||
# ── Crate guard ──────────────────────────────────────────────────────────────
|
# ── Crate guard ──────────────────────────────────────────────────────────────
|
||||||
# The repo is in the planning phase. With no Cargo.toml there is nothing to
|
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
|
||||||
# build, so the plan step forces RELEASE=false and the job exits green having
|
# forces RELEASE=false and the job exits green having done nothing.
|
||||||
# done nothing. It starts cutting real releases the moment Phase 1 lands the
|
#
|
||||||
# crate — no edit required here.
|
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
|
||||||
|
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
|
||||||
|
# workflow keeps standing down — an installer binary that syncs the overlay but
|
||||||
|
# cannot install the sidecar is not something to publish to operators. The first
|
||||||
|
# release is cut by the `edge → main` cutover, with no edit required here.
|
||||||
#
|
#
|
||||||
# ── Unsigned releases ────────────────────────────────────────────────────────
|
# ── Unsigned releases ────────────────────────────────────────────────────────
|
||||||
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
|
||||||
|
|||||||
941
Cargo.lock
generated
Normal file
941
Cargo.lock
generated
Normal file
@@ -0,0 +1,941 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"flate2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sysinfo",
|
||||||
|
"tar",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.38.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
59
Cargo.toml
Normal file
59
Cargo.toml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
[package]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Deployment tool for Runic Gateway: syncs the ServUO plugin overlay, installs the uo-link sidecar, and records what it deployed."
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
repository = "https://gitea.whitlocktech.com/RunicGateway/installer"
|
||||||
|
|
||||||
|
# The published binary keeps the name PLAN.md §3 and INSTALL.md give it. The library it is built
|
||||||
|
# from does not share that name on purpose: Windows' UAC installer detection refuses to launch an
|
||||||
|
# unsigned executable whose file name contains "install" (`os error 740`), and Cargo names test
|
||||||
|
# harnesses after their target — so a target called `runicgateway_installer` makes `cargo test`
|
||||||
|
# unrunnable on Windows. `test = false` keeps Cargo from building a harness under the binary's
|
||||||
|
# name; all the code, and all the tests, live in the library. See src/lib.rs.
|
||||||
|
[lib]
|
||||||
|
name = "rgdeploy"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "runicgateway-installer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
test = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Blocking HTTP over a pure-Rust TLS stack (rustls + ring + webpki-roots). The
|
||||||
|
# release cross-compiles to x86_64-pc-windows-gnu through MinGW, where anything
|
||||||
|
# linking OpenSSL turns a one-line build into a toolchain project — and this tool
|
||||||
|
# makes a handful of sequential requests, so an async runtime would be overhead
|
||||||
|
# with nothing to overlap.
|
||||||
|
ureq = "3.3"
|
||||||
|
|
||||||
|
# Overlay releases ship as gzipped tar. flate2's default backend is miniz_oxide
|
||||||
|
# (pure Rust), so it cross-compiles with no C dependency of its own.
|
||||||
|
flate2 = "1"
|
||||||
|
tar = "0.4"
|
||||||
|
|
||||||
|
# SHA256 is the entire trust anchor for these deliberately unsigned artifacts
|
||||||
|
# (PLAN.md §3), which makes this load-bearing rather than a nicety.
|
||||||
|
sha2 = "0.11"
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# RFC 3339 timestamps for install.json. Same feature set link's sidecar uses.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
|
||||||
|
|
||||||
|
# Refusing to deploy under a running shard is a correctness requirement, not a
|
||||||
|
# courtesy: ServUO holds Scripts.dll open and rewrites Saves/ on exit. Only the
|
||||||
|
# `system` feature is wanted — disks, networks and users are not our business.
|
||||||
|
sysinfo = { version = "0.38", default-features = false, features = ["system"] }
|
||||||
|
|
||||||
|
# Error plumbing, as in link's sidecar. Every failure here is read by an operator
|
||||||
|
# rather than matched on, so a chain of `.context()` strings is the whole
|
||||||
|
# requirement — the value is that "failed to write install.json" arrives with the
|
||||||
|
# path and the OS error attached instead of alone.
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
48
README.md
48
README.md
@@ -27,22 +27,34 @@ never restarts the shard.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Planning — no installer code exists yet.**
|
**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.**
|
||||||
|
|
||||||
The design of record is
|
The design of record is
|
||||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||||
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
||||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||||
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||||
|
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
||||||
|
sidecar + overlay combination, recomposed on every component release and nightly
|
||||||
|
(see [`bundles/README.md`](bundles/README.md)).
|
||||||
|
|
||||||
All three Phase 0 prerequisites have now landed, so **what the installer will
|
| Phase | State |
|
||||||
install already exists and is published**, ahead of the binary that installs it:
|
|---|---|
|
||||||
[`bundles/current.json`](bundles/current.json) names the current
|
| 0 — prerequisites in the other repos | ✅ merged |
|
||||||
protocol-checked sidecar + overlay combination, recomposed on every component
|
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||||
release and nightly. See [`bundles/README.md`](bundles/README.md).
|
| 2 — uo-link install + service registration | next |
|
||||||
|
| 3 — the opt-in stock-file patch tier | |
|
||||||
|
| 4 — `doctor`, `update`, `uninstall` | |
|
||||||
|
|
||||||
Besides that, this repo currently holds its governance documents and issue/PR
|
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||||
templates.
|
`main`, and a binary that deploys the overlay but cannot yet install the sidecar
|
||||||
|
is not something to hand an operator. Phases 1 and 2 land on `edge`; the
|
||||||
|
`edge → main` cutover cuts the first release. PRs into `edge` run the same gates
|
||||||
|
as PRs into `main`.
|
||||||
|
|
||||||
|
Until then, the way to install is by hand —
|
||||||
|
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||||
|
is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||||
|
|
||||||
## Related repos
|
## Related repos
|
||||||
|
|
||||||
@@ -82,13 +94,29 @@ templates.
|
|||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
Once the crate exists it will be a standard cargo project:
|
A standard cargo project, with the crate at the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cargo run -- --help
|
cargo run -- --help
|
||||||
|
cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing
|
||||||
|
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway`
|
||||||
|
or `%ProgramData%\RunicGateway`), which is how a run is tested without root.
|
||||||
|
|
||||||
|
Two layout notes that look odd until you know why:
|
||||||
|
|
||||||
|
- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC
|
||||||
|
installer detection refuses to launch an unsigned executable whose file name
|
||||||
|
contains `install` (`os error 740`), and Cargo names test harnesses after their
|
||||||
|
target — so a target under that name makes `cargo test` unrunnable on Windows.
|
||||||
|
The published binary keeps its documented name; `[[bin]] test = false` keeps
|
||||||
|
Cargo from building a harness under it. Expect a UAC prompt when running the
|
||||||
|
built binary on Windows; it needs Administrator anyway.
|
||||||
|
- **`Cargo.lock` is committed**, and CI builds `--locked`.
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local
|
||||||
checks CI will run, and the branch/PR workflow.
|
checks CI will run, and the branch/PR workflow.
|
||||||
|
|
||||||
|
|||||||
228
src/bundle.rs
Normal file
228
src/bundle.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! The bundle manifest — "what to install", resolved at run time.
|
||||||
|
//!
|
||||||
|
//! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of
|
||||||
|
//! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches
|
||||||
|
//! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's
|
||||||
|
//! newest release and hoping the two agree.
|
||||||
|
//!
|
||||||
|
//! Two consequences show up directly in this module:
|
||||||
|
//!
|
||||||
|
//! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and
|
||||||
|
//! cross-checked against the overlay's own `manifest.json` at deploy time.
|
||||||
|
//! - **`schema` is not `protocol`.** It versions the shape of this document and moves
|
||||||
|
//! independently of both components' versions; a bundle from a newer CI is refused rather than
|
||||||
|
//! half-understood.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
|
||||||
|
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
|
||||||
|
const BUNDLE_BASE: &str =
|
||||||
|
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
|
||||||
|
|
||||||
|
/// The only `schema` this build understands.
|
||||||
|
const SUPPORTED_SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Bundle {
|
||||||
|
pub schema: u32,
|
||||||
|
/// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two.
|
||||||
|
pub bundle: String,
|
||||||
|
pub generated: String,
|
||||||
|
/// The wire protocol both halves were checked to agree on.
|
||||||
|
pub protocol: u32,
|
||||||
|
pub link: LinkComponent,
|
||||||
|
pub overlay: OverlayComponent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LinkComponent {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
|
||||||
|
/// the installer runs on both, so a single hash could only ever describe one of them.
|
||||||
|
pub assets: BTreeMap<String, Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct OverlayComponent {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
pub servuo: ServUoCompat,
|
||||||
|
/// One tarball, platform-independent: the overlay is C# source that ServUO compiles at boot.
|
||||||
|
pub asset: Asset,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ServUoCompat {
|
||||||
|
/// The oldest ServUO the *base* overlay is known good on. It only adds files.
|
||||||
|
pub min_version: String,
|
||||||
|
/// The single ServUO version the *patch tier* was written and verified against (§2.2).
|
||||||
|
pub patches_verified_against: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bundle {
|
||||||
|
/// The bundle's own asset for the platform this binary is running on.
|
||||||
|
///
|
||||||
|
/// Installing the sidecar is Phase 2, but the lookup lives here so that a run on a platform the
|
||||||
|
/// bundle has no binary for fails while resolving — before anything has been written into a
|
||||||
|
/// ServUO tree — rather than after the overlay is already deployed.
|
||||||
|
pub fn sidecar_asset(&self) -> Result<&Asset> {
|
||||||
|
let key = platform_key()?;
|
||||||
|
self.link.assets.get(key).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"bundle {} has no uo-link binary for {key} (it has: {})",
|
||||||
|
self.bundle,
|
||||||
|
self.link
|
||||||
|
.assets
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform key used by `link.assets`, matching the names the bundle CI assigns.
|
||||||
|
pub fn platform_key() -> Result<&'static str> {
|
||||||
|
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||||
|
("linux", "x86_64") => Ok("linux-x86_64"),
|
||||||
|
("windows", "x86_64") => Ok("windows-x86_64"),
|
||||||
|
// arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats
|
||||||
|
// failing later with a missing-key error that reads like a corrupt bundle.
|
||||||
|
(os, arch) => bail!(
|
||||||
|
"no Runic Gateway build exists for {os}/{arch}. \
|
||||||
|
The released components target linux-x86_64 and windows-x86_64."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
|
||||||
|
pub fn url_for(tag: Option<&str>) -> String {
|
||||||
|
match tag {
|
||||||
|
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
|
||||||
|
None => format!("{BUNDLE_BASE}/current.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches and validates a bundle.
|
||||||
|
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
|
||||||
|
let url = url_for(tag);
|
||||||
|
let body = crate::net::get_text(&url).with_context(|| match tag {
|
||||||
|
Some(tag) => format!(
|
||||||
|
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
|
||||||
|
against {BUNDLE_BASE}/"
|
||||||
|
),
|
||||||
|
None => "cannot read the current bundle manifest".to_string(),
|
||||||
|
})?;
|
||||||
|
let bundle = parse(&body)?;
|
||||||
|
Ok((bundle, url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
|
||||||
|
pub fn parse(body: &str) -> Result<Bundle> {
|
||||||
|
let bundle: Bundle = serde_json::from_str(body)
|
||||||
|
.context("the bundle manifest is not in the shape this installer understands")?;
|
||||||
|
|
||||||
|
if bundle.schema != SUPPORTED_SCHEMA {
|
||||||
|
bail!(
|
||||||
|
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
|
||||||
|
Update the installer — the bundle format changed.",
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.schema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
|
||||||
|
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
|
||||||
|
// manifest that never went through the compose job.
|
||||||
|
if bundle.link.protocol != bundle.overlay.protocol || bundle.protocol != bundle.link.protocol {
|
||||||
|
bail!(
|
||||||
|
"bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, overlay {}. \
|
||||||
|
A mismatched pair is rejected by the sidecar with 409 rather than mis-parsed, so this \
|
||||||
|
is refused here.",
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.protocol,
|
||||||
|
bundle.link.protocol,
|
||||||
|
bundle.overlay.protocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bundle.overlay.asset.url.is_empty() || bundle.overlay.asset.sha256.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"bundle {} names an overlay asset with no URL or checksum",
|
||||||
|
bundle.bundle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
|
||||||
|
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
|
||||||
|
const CURRENT: &str = include_str!("../bundles/current.json");
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_published_bundle_parses() {
|
||||||
|
let bundle = parse(CURRENT).unwrap();
|
||||||
|
assert_eq!(bundle.schema, 1);
|
||||||
|
assert_eq!(bundle.bundle, "2026.08.04");
|
||||||
|
assert_eq!(bundle.protocol, 3);
|
||||||
|
assert_eq!(bundle.link.version, "1.1.0");
|
||||||
|
assert_eq!(bundle.overlay.version, "0.1.1");
|
||||||
|
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
|
||||||
|
assert_eq!(bundle.link.assets.len(), 2);
|
||||||
|
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_platforms_have_a_sidecar_binary() {
|
||||||
|
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
|
||||||
|
// host's binary would fail an install after the overlay had already been deployed.
|
||||||
|
let bundle = parse(CURRENT).unwrap();
|
||||||
|
let asset = bundle.sidecar_asset().unwrap();
|
||||||
|
assert_eq!(asset.sha256.len(), 64);
|
||||||
|
assert!(asset.url.contains(&bundle.link.tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
||||||
|
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
|
||||||
|
let err = parse(&body).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("schema 2"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_protocol_disagreement_inside_one_bundle_is_refused() {
|
||||||
|
// Exactly what CI's gate 1 exists to prevent; re-checked here for documents that never
|
||||||
|
// went through it.
|
||||||
|
let body = CURRENT.replacen("\"protocol\": 3", "\"protocol\": 4", 2);
|
||||||
|
let err = parse(&body).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("internally inconsistent"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pinned_and_current_urls_differ() {
|
||||||
|
assert!(url_for(None).ends_with("/current.json"));
|
||||||
|
assert!(url_for(Some("2026.08.04")).ends_with("/bundle-2026.08.04.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/cli.rs
Normal file
336
src/cli.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
//! Command-line surface.
|
||||||
|
//!
|
||||||
|
//! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and
|
||||||
|
//! fixes every command and flag an operator can type. This module parses that surface *whole*, even
|
||||||
|
//! though Phase 1 implements only part of it — a parser written once against the published contract
|
||||||
|
//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this
|
||||||
|
//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it
|
||||||
|
//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO
|
||||||
|
//! files were touched when nothing was.
|
||||||
|
//!
|
||||||
|
//! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand
|
||||||
|
//! trees. A parsing crate would be larger than the code it replaced.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// The verb. `Install` is the only one Phase 1 implements; the rest parse so that running them
|
||||||
|
/// reports which phase they arrive in rather than "unrecognized argument", which would read as a
|
||||||
|
/// typo rather than as an unfinished tool.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Command {
|
||||||
|
Install,
|
||||||
|
Doctor,
|
||||||
|
Update,
|
||||||
|
Uninstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
fn parse(token: &str) -> Option<Self> {
|
||||||
|
match token {
|
||||||
|
"install" => Some(Self::Install),
|
||||||
|
"doctor" => Some(Self::Doctor),
|
||||||
|
"update" => Some(Self::Update),
|
||||||
|
"uninstall" => Some(Self::Uninstall),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Command {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Install => "install",
|
||||||
|
Self::Doctor => "doctor",
|
||||||
|
Self::Update => "update",
|
||||||
|
Self::Uninstall => "uninstall",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the patch tier was told to do. Tri-state on purpose: "not mentioned" is a different input
|
||||||
|
/// from "explicitly declined", because only the first one may prompt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PatchChoice {
|
||||||
|
Ask,
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Run(Command),
|
||||||
|
Help,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Cli {
|
||||||
|
pub mode: Mode,
|
||||||
|
/// `--verify`: report every change that would be made, write nothing.
|
||||||
|
pub verify: bool,
|
||||||
|
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
|
||||||
|
pub servuo: Option<String>,
|
||||||
|
/// `--bundle <tag>`: pin a published bundle instead of resolving the current one.
|
||||||
|
pub bundle: Option<String>,
|
||||||
|
pub patches: PatchChoice,
|
||||||
|
/// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree.
|
||||||
|
pub patches_unsupported_servuo: bool,
|
||||||
|
/// `--host <name>`: the hostname to print in the website URLs.
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
|
||||||
|
pub site_url: Option<String>,
|
||||||
|
/// `--yes`: assume the default answer to every prompt.
|
||||||
|
pub assume_yes: bool,
|
||||||
|
/// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`.
|
||||||
|
pub purge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Cli {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: Mode::Help,
|
||||||
|
verify: false,
|
||||||
|
servuo: None,
|
||||||
|
bundle: None,
|
||||||
|
patches: PatchChoice::Ask,
|
||||||
|
patches_unsupported_servuo: false,
|
||||||
|
host: None,
|
||||||
|
site_url: None,
|
||||||
|
assume_yes: false,
|
||||||
|
purge: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const USAGE: &str = "\
|
||||||
|
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
|
||||||
|
|
||||||
|
Usage: runicgateway-installer <COMMAND> [OPTIONS]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Deploy the plugin overlay, install the uo-link sidecar and its
|
||||||
|
service, record what was deployed, and print the values the
|
||||||
|
website needs.
|
||||||
|
doctor Diagnose an existing deployment end to end.
|
||||||
|
update Re-resolve the bundle and move both components to it.
|
||||||
|
uninstall Remove what the installer exclusively owns. Never edits the
|
||||||
|
ServUO tree — it prints what to remove there.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--verify install, update. Dry run: report every
|
||||||
|
change that would be made, write nothing.
|
||||||
|
--servuo <PATH> install, doctor, update. The ServUO root,
|
||||||
|
instead of detecting or prompting for it.
|
||||||
|
--bundle <TAG> install, update. Pin an exact published
|
||||||
|
bundle (e.g. 2026.08.04) instead of the
|
||||||
|
current one.
|
||||||
|
--patches / --no-patches install. Decide the patch tier without
|
||||||
|
being prompted. --patches never loosens
|
||||||
|
the region check.
|
||||||
|
--patches-unsupported-servuo install. Required IN ADDITION TO --patches
|
||||||
|
to run the patch tier on a ServUO that is
|
||||||
|
not 57.4. Unsupported and untested.
|
||||||
|
--host <NAME> install. The hostname to print in the
|
||||||
|
website URLs.
|
||||||
|
--site-url <URL> install. Your site's base URL, for the
|
||||||
|
Admin → Shard link.
|
||||||
|
--yes Assume the default answer to every prompt.
|
||||||
|
--purge uninstall. Also delete sidecar.toml and
|
||||||
|
uo-link.db, which are otherwise kept.
|
||||||
|
-V, --version Print the installer version and exit.
|
||||||
|
-h, --help Print this help and exit.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
RUNICGATEWAY_STATE_DIR Relocates the installer's own state (install.json,
|
||||||
|
the cached patch set) away from /etc/runicgateway or
|
||||||
|
%ProgramData%\\RunicGateway. For testing a run
|
||||||
|
without root; an installed deployment should not
|
||||||
|
set it.
|
||||||
|
|
||||||
|
The installer never contacts your website, never deletes anything from your
|
||||||
|
ServUO tree, and never starts or stops your shard.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Parses arguments **without** the program name.
|
||||||
|
///
|
||||||
|
/// The error string is what the caller prints on stderr before exiting `2`.
|
||||||
|
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||||
|
let mut cli = Cli::default();
|
||||||
|
let mut command: Option<Command> = None;
|
||||||
|
let mut it = args.into_iter().peekable();
|
||||||
|
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
// `--flag=value` is normalized here so each flag below is written once. Splitting on the
|
||||||
|
// first '=' only: a URL or a Windows path may legitimately contain more.
|
||||||
|
let (name, inline) = match arg.split_once('=') {
|
||||||
|
Some((n, v)) if n.starts_with("--") => (n.to_string(), Some(v.to_string())),
|
||||||
|
_ => (arg.clone(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match name.as_str() {
|
||||||
|
"-h" | "--help" => {
|
||||||
|
cli.mode = Mode::Help;
|
||||||
|
return Ok(cli);
|
||||||
|
}
|
||||||
|
"-V" | "--version" => {
|
||||||
|
cli.mode = Mode::Version;
|
||||||
|
return Ok(cli);
|
||||||
|
}
|
||||||
|
"--verify" => cli.verify = true,
|
||||||
|
"--yes" | "-y" => cli.assume_yes = true,
|
||||||
|
"--purge" => cli.purge = true,
|
||||||
|
"--patches" => cli.patches = PatchChoice::Yes,
|
||||||
|
"--no-patches" => cli.patches = PatchChoice::No,
|
||||||
|
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
|
||||||
|
"--servuo" => cli.servuo = Some(take_value(&name, inline, &mut it)?),
|
||||||
|
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
|
||||||
|
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
|
||||||
|
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
|
||||||
|
other if other.starts_with('-') => {
|
||||||
|
return Err(format!("unrecognized argument: {other}"))
|
||||||
|
}
|
||||||
|
other => match Command::parse(other) {
|
||||||
|
Some(c) if command.is_none() => command = Some(c),
|
||||||
|
// Two verbs is ambiguous, and picking the first would run something the operator
|
||||||
|
// did not ask for while looking like it worked.
|
||||||
|
Some(c) => return Err(format!("only one command may be given (saw {c} as well)")),
|
||||||
|
None => return Err(format!("unrecognized command: {other}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Some(c) => cli.mode = Mode::Run(c),
|
||||||
|
// No verb is not an error worth an exit code — it is someone typing the binary's name to
|
||||||
|
// see what it does.
|
||||||
|
None => cli.mode = Mode::Help,
|
||||||
|
}
|
||||||
|
Ok(cli)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls a flag's value, from `--flag=value` or from the next token.
|
||||||
|
///
|
||||||
|
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
|
||||||
|
/// otherwise fall through to auto-detection and deploy into a directory nobody named.
|
||||||
|
fn take_value<I: Iterator<Item = String>>(
|
||||||
|
flag: &str,
|
||||||
|
inline: Option<String>,
|
||||||
|
rest: &mut I,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match inline {
|
||||||
|
Some(v) if v.is_empty() => Err(format!("{flag} requires a value")),
|
||||||
|
Some(v) => Ok(v),
|
||||||
|
None => rest
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("{flag} requires a value")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse_str(args: &[&str]) -> Result<Cli, String> {
|
||||||
|
parse(args.iter().map(|s| s.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_arguments_prints_help() {
|
||||||
|
assert_eq!(parse_str(&[]).unwrap().mode, Mode::Help);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_documented_command_parses() {
|
||||||
|
for (token, want) in [
|
||||||
|
("install", Command::Install),
|
||||||
|
("doctor", Command::Doctor),
|
||||||
|
("update", Command::Update),
|
||||||
|
("uninstall", Command::Uninstall),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_str(&[token]).unwrap().mode, Mode::Run(want));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_accept_both_spellings() {
|
||||||
|
let spaced = parse_str(&["install", "--servuo", "/opt/ServUO"]).unwrap();
|
||||||
|
let equals = parse_str(&["install", "--servuo=/opt/ServUO"]).unwrap();
|
||||||
|
assert_eq!(spaced.servuo.as_deref(), Some("/opt/ServUO"));
|
||||||
|
assert_eq!(spaced, equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_value_containing_equals_survives() {
|
||||||
|
// Site URLs carry query strings and Windows paths carry drive colons; splitting on every
|
||||||
|
// '=' would truncate both.
|
||||||
|
let cli = parse_str(&["install", "--site-url=https://s.example/x?a=b=c"]).unwrap();
|
||||||
|
assert_eq!(cli.site_url.as_deref(), Some("https://s.example/x?a=b=c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flag_without_its_value_is_an_error() {
|
||||||
|
for args in [
|
||||||
|
vec!["install", "--servuo"],
|
||||||
|
vec!["install", "--servuo="],
|
||||||
|
vec!["install", "--bundle"],
|
||||||
|
vec!["install", "--host"],
|
||||||
|
vec!["install", "--site-url"],
|
||||||
|
] {
|
||||||
|
assert!(parse_str(&args).is_err(), "{args:?} should be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_patch_tier_is_tri_state() {
|
||||||
|
// "Not mentioned" must stay distinguishable from "declined": only the first may prompt,
|
||||||
|
// and only an explicit --patches is consent.
|
||||||
|
assert_eq!(parse_str(&["install"]).unwrap().patches, PatchChoice::Ask);
|
||||||
|
assert_eq!(
|
||||||
|
parse_str(&["install", "--patches"]).unwrap().patches,
|
||||||
|
PatchChoice::Yes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_str(&["install", "--no-patches"]).unwrap().patches,
|
||||||
|
PatchChoice::No
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_servuo_consent_is_its_own_flag() {
|
||||||
|
// --patches alone is deliberately not enough on a non-57.4 tree (PLAN.md §2.2.2), so the
|
||||||
|
// two must not collapse into one another.
|
||||||
|
let cli = parse_str(&["install", "--patches", "--patches-unsupported-servuo"]).unwrap();
|
||||||
|
assert_eq!(cli.patches, PatchChoice::Yes);
|
||||||
|
assert!(cli.patches_unsupported_servuo);
|
||||||
|
assert!(
|
||||||
|
!parse_str(&["install", "--patches"])
|
||||||
|
.unwrap()
|
||||||
|
.patches_unsupported_servuo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_and_version_win_immediately() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_str(&["install", "--help", "--bogus"]).unwrap().mode,
|
||||||
|
Mode::Help
|
||||||
|
);
|
||||||
|
assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_tokens_are_rejected() {
|
||||||
|
// A typo'd flag must not start a run that is not the one that was asked for.
|
||||||
|
assert!(parse_str(&["install", "--verfiy"]).is_err());
|
||||||
|
assert!(parse_str(&["instal"]).is_err());
|
||||||
|
assert!(parse_str(&["install", "update"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_does_not_matter() {
|
||||||
|
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();
|
||||||
|
let b = parse_str(&["install", "--yes", "--verify"]).unwrap();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert!(a.verify && a.assume_yes);
|
||||||
|
}
|
||||||
|
}
|
||||||
430
src/install.rs
Normal file
430
src/install.rs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
//! The `install` command.
|
||||||
|
//!
|
||||||
|
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
|
||||||
|
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
|
||||||
|
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
|
||||||
|
//! than ending on a success line that would read as a finished install. An operator who cannot tell
|
||||||
|
//! which half ran is the failure this whole tool exists to remove.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::cli::{Cli, PatchChoice};
|
||||||
|
use crate::record::{
|
||||||
|
now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::servuo::ServUoRoot;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
use crate::{bundle, net, overlay, paths, servuo, ui};
|
||||||
|
|
||||||
|
pub fn run(cli: &Cli) -> Result<()> {
|
||||||
|
let layout = paths::layout();
|
||||||
|
|
||||||
|
// ── What to install ──────────────────────────────────────────────────────
|
||||||
|
// The bundle is resolved first, and its sidecar asset looked up immediately, so a run that
|
||||||
|
// cannot be completed fails here — before a single file has entered the ServUO tree.
|
||||||
|
let (bundle, bundle_url) = bundle::fetch(cli.bundle.as_deref())?;
|
||||||
|
let sidecar_asset = bundle.sidecar_asset()?.clone();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nRunic Gateway installer {} — bundle {} (protocol {}){}",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.protocol,
|
||||||
|
if cli.verify {
|
||||||
|
" [--verify: nothing will be written]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// ── Where to install it ──────────────────────────────────────────────────
|
||||||
|
let root = resolve_root(cli)?;
|
||||||
|
ui::row(
|
||||||
|
"ServUO",
|
||||||
|
&format!("{} ({})", root.path.display(), root.version_display()),
|
||||||
|
);
|
||||||
|
// Reaching this line means `servuo::open_stopped` found no shard running out of this tree; a
|
||||||
|
// running one has already ended the run.
|
||||||
|
ui::row("Shard process", "not running");
|
||||||
|
ui::row(
|
||||||
|
"Overlay",
|
||||||
|
&format!(
|
||||||
|
"{:<24} protocol {}",
|
||||||
|
format!("servuo-plugins {}", bundle.overlay.tag),
|
||||||
|
bundle.overlay.protocol
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui::row(
|
||||||
|
"Sidecar",
|
||||||
|
&format!(
|
||||||
|
"{:<24} protocol {} (Phase 2 — not installed by this build)",
|
||||||
|
format!("uo-link {}", bundle.link.tag),
|
||||||
|
bundle.link.protocol
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if !root.is_supported_version() {
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"This tree reports ServUO {}. {} is the only supported version.\n \
|
||||||
|
The base overlay only adds files and is expected to work broadly, so the install \
|
||||||
|
continues.\n \
|
||||||
|
The patch tier is the part that is version-sensitive — see INSTALL.md §4.",
|
||||||
|
root.version_display(),
|
||||||
|
servuo::SUPPORTED_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch and unpack the overlay ─────────────────────────────────────────
|
||||||
|
let scratch = TempDir::new("runicgateway-installer")?;
|
||||||
|
let tarball = scratch.path().join(&bundle.overlay.asset.name);
|
||||||
|
println!();
|
||||||
|
net::download_verified(
|
||||||
|
&bundle.overlay.asset.url,
|
||||||
|
&tarball,
|
||||||
|
&bundle.overlay.asset.sha256,
|
||||||
|
)?;
|
||||||
|
ui::ok(&format!(
|
||||||
|
"overlay tarball verified sha256 {}…",
|
||||||
|
&bundle.overlay.asset.sha256[..8.min(bundle.overlay.asset.sha256.len())]
|
||||||
|
));
|
||||||
|
|
||||||
|
let unpacked = overlay::extract(&tarball, &scratch.path().join("unpacked"))?;
|
||||||
|
let manifest = overlay::read_manifest(&unpacked)?;
|
||||||
|
overlay::verify_payload(&unpacked, &manifest)?;
|
||||||
|
|
||||||
|
// The bundle and the artifact must agree. They are produced by different repos at different
|
||||||
|
// times, and gate 1 of the compose job (PLAN.md §7.1) is what normally keeps them in step —
|
||||||
|
// this is the same check applied to the artifact actually on disk.
|
||||||
|
if manifest.protocol != bundle.overlay.protocol {
|
||||||
|
bail!(
|
||||||
|
"the overlay release declares protocol {} but bundle {} recorded {}. \
|
||||||
|
Refusing to deploy a pair that was never checked together.",
|
||||||
|
manifest.protocol,
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.overlay.protocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if manifest.version != bundle.overlay.version {
|
||||||
|
bail!(
|
||||||
|
"bundle {} names overlay {} but the downloaded tarball contains {}",
|
||||||
|
bundle.bundle,
|
||||||
|
bundle.overlay.version,
|
||||||
|
manifest.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan the sync ────────────────────────────────────────────────────────
|
||||||
|
let record_path = layout.install_record();
|
||||||
|
let prior = InstallRecord::load(&record_path)?;
|
||||||
|
let prior_files = prior_overlay_files(prior.as_ref(), &root);
|
||||||
|
|
||||||
|
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||||
|
let summary = overlay::summarize(&planned);
|
||||||
|
|
||||||
|
ui::heading("Overlay sync");
|
||||||
|
let lines = overlay::render(&planned);
|
||||||
|
if lines.is_empty() {
|
||||||
|
println!(" (no changes)");
|
||||||
|
}
|
||||||
|
for line in lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.verify {
|
||||||
|
println!(
|
||||||
|
"\n VERIFY only. add={} change={} unchanged={} kept={} (nothing written)",
|
||||||
|
summary.add, summary.change, summary.unchanged, summary.kept
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
overlay::apply(&planned)?;
|
||||||
|
// "deployed" is claimed only when something actually moved. A run that copied nothing
|
||||||
|
// reporting "deployed" would read as a fresh install to anyone skimming the output.
|
||||||
|
println!(
|
||||||
|
"\n {} add={} change={} unchanged={} kept={}",
|
||||||
|
if summary.writes_anything() {
|
||||||
|
"deployed."
|
||||||
|
} else {
|
||||||
|
"unchanged."
|
||||||
|
},
|
||||||
|
summary.add,
|
||||||
|
summary.change,
|
||||||
|
summary.unchanged,
|
||||||
|
summary.kept
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in planned
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.action == overlay::Action::KeptOperatorModified)
|
||||||
|
{
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"{} has local edits — left exactly as it is.\n \
|
||||||
|
The release ships its own copy of this file; if you want the new defaults, compare \
|
||||||
|
yours against\n the one in {}\n and merge by hand. \
|
||||||
|
Every other overlay file is code and is overwritten unconditionally.",
|
||||||
|
file.rel, bundle.overlay.asset.url
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What this build does not do ──────────────────────────────────────────
|
||||||
|
report_patch_tier(cli);
|
||||||
|
report_sidecar(&bundle, &sidecar_asset, &layout);
|
||||||
|
|
||||||
|
// ── Record ───────────────────────────────────────────────────────────────
|
||||||
|
let record = build_record(
|
||||||
|
prior.as_ref(),
|
||||||
|
&bundle,
|
||||||
|
&bundle_url,
|
||||||
|
&root,
|
||||||
|
&manifest,
|
||||||
|
&planned,
|
||||||
|
);
|
||||||
|
|
||||||
|
if cli.verify {
|
||||||
|
println!("\n {} not written (--verify)", record_path.display());
|
||||||
|
} else {
|
||||||
|
match prior.as_ref() {
|
||||||
|
Some(previous) if previous.same_deployment_as(&record) => {
|
||||||
|
println!("\n {} unchanged", record_path.display());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
record.save(&record_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot write {} — run as root/Administrator, or set {} to a writable \
|
||||||
|
directory for a test run",
|
||||||
|
record_path.display(),
|
||||||
|
paths::STATE_DIR_ENV
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
println!("\n Recorded {}", record_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Closing notes ────────────────────────────────────────────────────────
|
||||||
|
println!();
|
||||||
|
if summary.writes_anything() && !cli.verify {
|
||||||
|
println!(
|
||||||
|
"Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\
|
||||||
|
Start your shard when ready; the installer does not start it for you.\n\
|
||||||
|
Note that ServUO ignores the script build's exit code, so a clean boot is not proof \
|
||||||
|
the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \
|
||||||
|
run `[bridge status` in game (INSTALL.md §6)."
|
||||||
|
);
|
||||||
|
} else if cli.verify {
|
||||||
|
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||||
|
} else {
|
||||||
|
println!("Nothing to do — this tree already has this overlay.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.host.is_some() || cli.site_url.is_some() {
|
||||||
|
println!(
|
||||||
|
"\nNote: --host/--site-url are used by the token handoff, which arrives with the \
|
||||||
|
sidecar in Phase 2. They had no effect on this run."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
|
||||||
|
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
|
||||||
|
if let Some(path) = &cli.servuo {
|
||||||
|
return servuo::open_stopped(&PathBuf::from(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(detected) = servuo::detect() {
|
||||||
|
let question = format!("Use the ServUO installation at {}?", detected.display());
|
||||||
|
if ui::confirm(&question, true, cli.assume_yes)? {
|
||||||
|
return servuo::open_stopped(&detected);
|
||||||
|
}
|
||||||
|
} else if cli.assume_yes {
|
||||||
|
// --yes cannot invent a path, and picking one would be the worst possible guess.
|
||||||
|
bail!(
|
||||||
|
"no ServUO installation was found near this binary or the working directory. \
|
||||||
|
Pass --servuo <path>."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let answer = ui::prompt("Path to your ServUO root", None)
|
||||||
|
.context("a ServUO root is required; pass --servuo <path> for an unattended run")?;
|
||||||
|
servuo::open_stopped(&PathBuf::from(answer.trim().trim_matches('"')))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous run's file map, but only when it describes *this* tree.
|
||||||
|
///
|
||||||
|
/// The map is what distinguishes an operator-edited `Bridge.cfg` from an upstream change, and that
|
||||||
|
/// judgement is only meaningful about the tree it was recorded for. A host whose record points at a
|
||||||
|
/// different root — a shard moved or rebuilt beside the old one — is treated as having no prior
|
||||||
|
/// deployment here, which errs toward keeping the operator's file.
|
||||||
|
fn prior_overlay_files<'a>(
|
||||||
|
prior: Option<&'a InstallRecord>,
|
||||||
|
root: &ServUoRoot,
|
||||||
|
) -> Option<&'a std::collections::BTreeMap<String, crate::record::FileRecord>> {
|
||||||
|
let prior = prior?;
|
||||||
|
if Path::new(&prior.servuo.path) != root.path {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
prior.overlay_files()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_patch_tier(cli: &Cli) {
|
||||||
|
println!();
|
||||||
|
match cli.patches {
|
||||||
|
// --patches must never pass silently: an operator who asked for the tier and got a clean
|
||||||
|
// run would reasonably conclude that EventSink.cs had been patched.
|
||||||
|
PatchChoice::Yes => {
|
||||||
|
ui::warn(
|
||||||
|
"Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \
|
||||||
|
build (Phase 3).\n \
|
||||||
|
No stock ServUO file has been touched. Apply the patches by hand if you need \
|
||||||
|
them: INSTALL.md Appendix A2.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PatchChoice::No => {
|
||||||
|
ui::row("Patch tier", "skipped (--no-patches)");
|
||||||
|
}
|
||||||
|
PatchChoice::Ask => {
|
||||||
|
ui::row(
|
||||||
|
"Patch tier",
|
||||||
|
"skipped (not implemented in this build — Phase 3)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) {
|
||||||
|
println!();
|
||||||
|
ui::warn(&format!(
|
||||||
|
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
|
||||||
|
Without it the shard has nothing to dial out to and your website stays offline.\n \
|
||||||
|
Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \
|
||||||
|
binary {}\n \
|
||||||
|
config {}\n \
|
||||||
|
database {}\n \
|
||||||
|
download {}\n \
|
||||||
|
sha256 {}\n \
|
||||||
|
Then provision and read the token back with:\n \
|
||||||
|
<binary> --print-config --config {}\n \
|
||||||
|
The bundle pairs it with overlay {} at protocol {}; keep the two in step.",
|
||||||
|
layout.sidecar_bin.display(),
|
||||||
|
layout.sidecar_config().display(),
|
||||||
|
layout.sidecar_db().display(),
|
||||||
|
asset.url,
|
||||||
|
asset.sha256,
|
||||||
|
layout.sidecar_config().display(),
|
||||||
|
bundle.overlay.tag,
|
||||||
|
bundle.protocol,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_record(
|
||||||
|
prior: Option<&InstallRecord>,
|
||||||
|
bundle: &bundle::Bundle,
|
||||||
|
bundle_url: &str,
|
||||||
|
root: &ServUoRoot,
|
||||||
|
manifest: &overlay::Manifest,
|
||||||
|
planned: &[overlay::PlannedFile],
|
||||||
|
) -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
},
|
||||||
|
updated: now_rfc3339(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: bundle.bundle.clone(),
|
||||||
|
protocol: bundle.protocol,
|
||||||
|
url: bundle_url.to_string(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: root.path.to_string_lossy().to_string(),
|
||||||
|
version: root.version.clone(),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: manifest.repo.clone(),
|
||||||
|
tag: bundle.overlay.tag.clone(),
|
||||||
|
version: manifest.version.clone(),
|
||||||
|
commit: manifest.commit.clone(),
|
||||||
|
protocol: manifest.protocol,
|
||||||
|
files: overlay::file_records(planned),
|
||||||
|
}),
|
||||||
|
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
|
||||||
|
// re-running on a fully installed host cannot make a service or a set of applied patch
|
||||||
|
// hunks disappear from the record that documents them.
|
||||||
|
link: prior.and_then(|p| p.link.clone()),
|
||||||
|
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||||
|
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::record::{FileRecord, ServUoRef};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn record_for(path: &str) -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
},
|
||||||
|
updated: now_rfc3339(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: "2026.08.04".into(),
|
||||||
|
protocol: 3,
|
||||||
|
url: "https://example/current.json".into(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: path.into(),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: "RunicGateway/servuo-plugins".into(),
|
||||||
|
tag: "v0.1.1".into(),
|
||||||
|
version: "0.1.1".into(),
|
||||||
|
commit: "3a52abb".into(),
|
||||||
|
protocol: 3,
|
||||||
|
files: BTreeMap::from([(
|
||||||
|
"Config/Bridge.cfg".to_string(),
|
||||||
|
FileRecord {
|
||||||
|
overlay_sha256: "aa".into(),
|
||||||
|
on_disk_sha256: "bb".into(),
|
||||||
|
state: "kept-operator-modified".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
}),
|
||||||
|
link: None,
|
||||||
|
patches: Vec::new(),
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_at(path: &str) -> ServUoRoot {
|
||||||
|
ServUoRoot {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_for_this_tree_is_used() {
|
||||||
|
let record = record_for("/opt/ServUO");
|
||||||
|
let files = prior_overlay_files(Some(&record), &root_at("/opt/ServUO"));
|
||||||
|
assert!(files.is_some_and(|f| f.contains_key("Config/Bridge.cfg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_for_a_different_tree_is_ignored() {
|
||||||
|
// Otherwise a second shard on the same host would inherit the first's hashes and could
|
||||||
|
// have its Bridge.cfg overwritten on the strength of a comparison that never applied to it.
|
||||||
|
let record = record_for("/opt/ServUO-old");
|
||||||
|
assert!(prior_overlay_files(Some(&record), &root_at("/opt/ServUO")).is_none());
|
||||||
|
assert!(prior_overlay_files(None, &root_at("/opt/ServUO")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/lib.rs
Normal file
127
src/lib.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//! Runic Gateway installer.
|
||||||
|
//!
|
||||||
|
//! Takes a working ServUO installation and connects it to a Runic Gateway website. The design of
|
||||||
|
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||||
|
//! existed, is `docs/installer/INSTALL.md`.
|
||||||
|
//!
|
||||||
|
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
|
||||||
|
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
|
||||||
|
//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||||
|
//! every one of them says so when reached rather than failing as though it were a typo.
|
||||||
|
//!
|
||||||
|
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||||
|
//! convention as the sidecar's CLI.
|
||||||
|
//!
|
||||||
|
//! ## Why the library target is called `rgdeploy`
|
||||||
|
//!
|
||||||
|
//! Windows applies **UAC installer detection** to unsigned executables whose file name contains
|
||||||
|
//! `install`, `setup`, `update` or `patch`: it decides the program is a legacy installer and
|
||||||
|
//! demands elevation before the process starts. That is tolerable for the shipped binary, which
|
||||||
|
//! needs Administrator anyway and is documented as being run from an elevated shell — but Cargo
|
||||||
|
//! names test harnesses after their target, so a target called `runicgateway_installer` produces
|
||||||
|
//! `runicgateway_installer-<hash>.exe`, which Windows refuses to launch (`os error 740`) and
|
||||||
|
//! `cargo test` cannot run at all on a developer's machine.
|
||||||
|
//!
|
||||||
|
//! So the code lives in a neutrally-named library, the binary target keeps the published name from
|
||||||
|
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
|
||||||
|
//! name. Nothing an operator sees changes.
|
||||||
|
|
||||||
|
pub mod bundle;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod install;
|
||||||
|
pub mod net;
|
||||||
|
pub mod overlay;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod record;
|
||||||
|
pub mod servuo;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
use cli::{Command, Mode};
|
||||||
|
|
||||||
|
/// The whole program. Returns the process exit code rather than calling `exit` itself, so the
|
||||||
|
/// entry point stays a one-liner and this stays callable from a test.
|
||||||
|
pub fn run() -> i32 {
|
||||||
|
ui::init_console();
|
||||||
|
|
||||||
|
let parsed = match cli::parse(std::env::args().skip(1)) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("error: {message}\n");
|
||||||
|
eprint!("{}", cli::USAGE);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match parsed.mode {
|
||||||
|
Mode::Help => {
|
||||||
|
print!("{}", cli::USAGE);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Mode::Version => {
|
||||||
|
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Mode::Run(Command::Install) => install::run(&parsed),
|
||||||
|
Mode::Run(command) => Err(not_implemented(command)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
// The chain is printed, not just the outermost message: "cannot write
|
||||||
|
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||||
|
eprintln!("\nerror: {error}");
|
||||||
|
for cause in error.chain().skip(1) {
|
||||||
|
eprintln!(" caused by: {cause}");
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command the contract documents but this phase has not built.
|
||||||
|
///
|
||||||
|
/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished.
|
||||||
|
fn not_implemented(command: Command) -> anyhow::Error {
|
||||||
|
let (phase, workaround) = match command {
|
||||||
|
Command::Doctor => (
|
||||||
|
"Phase 4",
|
||||||
|
"Check the deployment by hand: `[bridge status` in game, and \
|
||||||
|
`curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).",
|
||||||
|
),
|
||||||
|
Command::Update => (
|
||||||
|
"Phase 4",
|
||||||
|
"Re-run `install` to move the overlay to the current bundle; replace the sidecar \
|
||||||
|
binary by hand (INSTALL.md Appendix A6).",
|
||||||
|
),
|
||||||
|
Command::Uninstall => (
|
||||||
|
"Phase 4",
|
||||||
|
"Remove the sidecar service and binary by hand; the overlay files this installer \
|
||||||
|
deployed are listed in install.json.",
|
||||||
|
),
|
||||||
|
Command::Install => unreachable!("install is implemented"),
|
||||||
|
};
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"`{command}` is not implemented in this build — it arrives in {phase} \
|
||||||
|
(see docs/installer/PLAN.md §5).\n{workaround}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfinished_commands_name_their_phase_and_a_way_through() {
|
||||||
|
// An operator who runs `doctor` today must not be left thinking they typed it wrong, and
|
||||||
|
// must not be left with nothing to do either.
|
||||||
|
for command in [Command::Doctor, Command::Update, Command::Uninstall] {
|
||||||
|
let message = not_implemented(command).to_string();
|
||||||
|
assert!(message.contains(&command.to_string()), "{message}");
|
||||||
|
assert!(message.contains("Phase 4"), "{message}");
|
||||||
|
assert!(
|
||||||
|
message.contains("INSTALL.md") || message.contains("install.json"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main.rs
Normal file
6
src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
//! Entry point. Everything lives in the library — see `src/lib.rs`, including why the library
|
||||||
|
//! target is not named after this binary.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
std::process::exit(rgdeploy::run());
|
||||||
|
}
|
||||||
117
src/net.rs
Normal file
117
src/net.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! HTTP fetches and verified downloads.
|
||||||
|
//!
|
||||||
|
//! Everything this module pulls comes from a public Gitea repo over anonymous HTTPS — the shard
|
||||||
|
//! host has no Gitea credentials and needs none (PLAN.md §1). The one rule that matters:
|
||||||
|
//! **nothing downloaded is used before its SHA256 has been checked against the bundle.** The
|
||||||
|
//! artifacts are deliberately unsigned (§3), so the checksum is the entire trust anchor, and a
|
||||||
|
//! download that "mostly worked" is exactly the case that must not proceed.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, BufWriter};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::util::HashingWriter;
|
||||||
|
|
||||||
|
/// Identifies the tool and its version in Gitea's logs — worth having when an operator reports that
|
||||||
|
/// a fetch failed and nobody can tell which build made the request.
|
||||||
|
fn user_agent() -> String {
|
||||||
|
format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
|
||||||
|
///
|
||||||
|
/// The global timeout is generous because the overlay tarball travels over whatever link the shard
|
||||||
|
/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run
|
||||||
|
/// with a message instead of hanging an operator's terminal indefinitely.
|
||||||
|
fn agent() -> ureq::Agent {
|
||||||
|
ureq::Agent::config_builder()
|
||||||
|
.user_agent(user_agent())
|
||||||
|
.timeout_global(Some(Duration::from_secs(300)))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a small text document (the bundle manifest).
|
||||||
|
pub fn get_text(url: &str) -> Result<String> {
|
||||||
|
let mut response = agent()
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("cannot reach {url}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!("{url} returned HTTP {}", status.as_u16());
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.with_context(|| format!("cannot read the response from {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads `url` to `dest`, verifying SHA256 **while writing**.
|
||||||
|
///
|
||||||
|
/// On mismatch the partial file is removed before returning: leaving a wrong-hash artifact on disk
|
||||||
|
/// invites a later step — or a puzzled operator — to use it anyway.
|
||||||
|
pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Result<()> {
|
||||||
|
let expected = expected_sha256.trim().to_ascii_lowercase();
|
||||||
|
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = agent()
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("cannot reach {url}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!("{url} returned HTTP {}", status.as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?;
|
||||||
|
let mut writer = HashingWriter::new(BufWriter::new(file));
|
||||||
|
io::copy(&mut response.body_mut().as_reader(), &mut writer)
|
||||||
|
.with_context(|| format!("download of {url} failed"))?;
|
||||||
|
let actual = writer.finish();
|
||||||
|
|
||||||
|
if actual != expected {
|
||||||
|
let _ = std::fs::remove_file(dest);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"checksum mismatch for {url}\n expected {expected}\n got {actual}\n\
|
||||||
|
These artifacts are unsigned, so the checksum is the only thing vouching for them. \
|
||||||
|
Refusing to use this download."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_user_agent_names_the_build() {
|
||||||
|
let ua = user_agent();
|
||||||
|
assert!(ua.starts_with("runicgateway-installer/"), "{ua}");
|
||||||
|
assert!(ua.len() > "runicgateway-installer/".len(), "{ua}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_expected_hash_is_refused_before_any_request() {
|
||||||
|
// A bundle whose sha256 field is truncated, uppercase-garbled or empty must stop the run
|
||||||
|
// rather than download something that can then only be compared against nonsense. The URL
|
||||||
|
// is unroutable on purpose: reaching the network at all would be the bug.
|
||||||
|
let dir = TempDir::new("rg-test-net").unwrap();
|
||||||
|
let dest = dir.path().join("artifact");
|
||||||
|
for bad in ["", "abc", &"z".repeat(64)] {
|
||||||
|
let err = download_verified("http://127.0.0.1:1/artifact", &dest, bad).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("unusable SHA256"),
|
||||||
|
"expected a pre-flight refusal, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!dest.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
696
src/overlay.rs
Normal file
696
src/overlay.rs
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
//! The plugin overlay: unpack the release, then sync it into the ServUO tree.
|
||||||
|
//!
|
||||||
|
//! The plugin ships as **C# source that ServUO compiles at boot** (PLAN.md §2.1), so deployment is
|
||||||
|
//! a hash-compare file copy rather than a DLL drop. Three rules govern it:
|
||||||
|
//!
|
||||||
|
//! - **Nothing is ever deleted.** `overlay/` mirrors the server root and only adds or overwrites.
|
||||||
|
//! That is `deploy.ps1`'s behaviour and the installer inherits it: the ServUO tree belongs to the
|
||||||
|
//! operator, and a deployment tool that removes files from it is a deployment tool that
|
||||||
|
//! eventually removes the wrong one.
|
||||||
|
//! - **`Config/Bridge.cfg` is reported, not overwritten, once it has been edited** — the single
|
||||||
|
//! deviation from `deploy.ps1` (PLAN.md §5, Phase 1). It is the only file in the overlay that is
|
||||||
|
//! *meant* to be edited in place, and it carries no code, so a stale copy cannot break the build.
|
||||||
|
//! Silently reverting it would throw away `LinkUrl`, `PublicConnectAddress` and every sweep
|
||||||
|
//! interval on an `update`.
|
||||||
|
//! - **A successful copy is not a working bridge.** ServUO ignores the script build's exit code
|
||||||
|
//! and reloads the previous `Scripts.dll` (§2.1), so nothing here may report success in terms
|
||||||
|
//! stronger than "the files are in place".
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::bundle::ServUoCompat;
|
||||||
|
use crate::record::FileRecord;
|
||||||
|
use crate::util::sha256_file;
|
||||||
|
|
||||||
|
/// The tarball's fixed top-level directory. Fixed rather than versioned on purpose: the installer
|
||||||
|
/// looks for `overlay/`, `patches/` and `manifest.json` at known paths instead of parsing the very
|
||||||
|
/// version it is trying to read (PLAN.md §5, Phase 0 item 1).
|
||||||
|
const TOP_LEVEL_DIR: &str = "runicgateway-overlay";
|
||||||
|
|
||||||
|
/// Files the operator owns once deployed. Everything else — every `.cs` file and `Scripts.csproj` —
|
||||||
|
/// is overwritten unconditionally, because it is code and a stale copy breaks the build.
|
||||||
|
const OPERATOR_OWNED: &[&str] = &["Config/Bridge.cfg"];
|
||||||
|
|
||||||
|
/// `manifest.json`, generated by the `servuo-plugins` release workflow (PLAN.md §7.0).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub component: String,
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub repo: String,
|
||||||
|
/// The plugin half of the compatibility contract, declared in `overlay.toml`. Nothing can
|
||||||
|
/// derive it — the plugin announces no version on the wire and none is queryable before ServUO
|
||||||
|
/// boots — which is why it is checked against the bundle before anything is written.
|
||||||
|
pub protocol: u32,
|
||||||
|
pub servuo: ServUoCompat,
|
||||||
|
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
|
||||||
|
pub files: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sync will do to one file.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
Add,
|
||||||
|
Change,
|
||||||
|
Unchanged,
|
||||||
|
/// The operator has edited this file since it was deployed (or it was already there before the
|
||||||
|
/// installer ever ran). Reported, left alone.
|
||||||
|
KeptOperatorModified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
/// The token written into `install.json` — the resulting *state*, not the verb.
|
||||||
|
///
|
||||||
|
/// Add, change and unchanged all leave the release's copy in the tree, so all three record
|
||||||
|
/// `deployed`. Collapsing them is what lets an unchanged re-run compare equal to the previous
|
||||||
|
/// record and write nothing (see [`crate::record::FileRecord::state`]).
|
||||||
|
pub fn state(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Add | Self::Change | Self::Unchanged => "deployed",
|
||||||
|
Self::KeptOperatorModified => "kept-operator-modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Add => "ADD",
|
||||||
|
Self::Change => "CHANGE",
|
||||||
|
Self::Unchanged => "same",
|
||||||
|
Self::KeptOperatorModified => "KEEP",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writes(self) -> bool {
|
||||||
|
matches!(self, Self::Add | Self::Change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Action {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlannedFile {
|
||||||
|
/// ServUO-tree-relative, always `/`-separated so the record is portable between platforms.
|
||||||
|
pub rel: String,
|
||||||
|
pub src: PathBuf,
|
||||||
|
pub dst: PathBuf,
|
||||||
|
pub action: Action,
|
||||||
|
pub overlay_sha256: String,
|
||||||
|
/// What is on disk now — `None` when the file does not exist yet.
|
||||||
|
pub on_disk_sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub add: usize,
|
||||||
|
pub change: usize,
|
||||||
|
pub unchanged: usize,
|
||||||
|
pub kept: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Summary {
|
||||||
|
pub fn writes_anything(&self) -> bool {
|
||||||
|
self.add + self.change > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpacks the release tarball and returns the directory holding `overlay/`, `patches/` and
|
||||||
|
/// `manifest.json`.
|
||||||
|
///
|
||||||
|
/// `tar`'s unpack refuses entries that escape the destination, so a malicious or malformed archive
|
||||||
|
/// cannot write outside the scratch directory — worth stating explicitly, since this is the one
|
||||||
|
/// place the installer expands untrusted-shaped data. The archive itself has already been checked
|
||||||
|
/// against the bundle's SHA256 by the time this runs.
|
||||||
|
pub fn extract(tarball: &Path, into: &Path) -> Result<PathBuf> {
|
||||||
|
let file = File::open(tarball).with_context(|| format!("cannot open {}", tarball.display()))?;
|
||||||
|
let decoder = flate2::read::GzDecoder::new(file);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive
|
||||||
|
.unpack(into)
|
||||||
|
.with_context(|| format!("cannot unpack {}", tarball.display()))?;
|
||||||
|
|
||||||
|
// The fixed prefix is what the release workflow writes; falling back to the extraction root
|
||||||
|
// covers a tarball repackaged without it, which is a plausible operator mistake and a
|
||||||
|
// pointless thing to fail on when the three known paths are right there.
|
||||||
|
let with_prefix = into.join(TOP_LEVEL_DIR);
|
||||||
|
for candidate in [with_prefix, into.to_path_buf()] {
|
||||||
|
if candidate.join("manifest.json").is_file() && candidate.join("overlay").is_dir() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"{} does not contain {TOP_LEVEL_DIR}/manifest.json and {TOP_LEVEL_DIR}/overlay/ — \
|
||||||
|
this is not a Runic Gateway overlay release",
|
||||||
|
tarball.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
|
||||||
|
let path = dir.join("manifest.json");
|
||||||
|
let body =
|
||||||
|
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
serde_json::from_str(&body).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"{} is not a manifest this installer understands",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-hashes every file the manifest names.
|
||||||
|
///
|
||||||
|
/// The tarball's own checksum has already been verified against the bundle, so this is not the
|
||||||
|
/// trust boundary — it is a guard against a truncated extraction, a disk error, or an archive
|
||||||
|
/// repacked by hand between download and deploy. It is also what makes the hashes recorded in
|
||||||
|
/// `install.json` trustworthy, since those come from this manifest rather than from re-reading the
|
||||||
|
/// tree later.
|
||||||
|
pub fn verify_payload(dir: &Path, manifest: &Manifest) -> Result<()> {
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
for (rel, expected) in &manifest.files {
|
||||||
|
let path = dir.join(rel);
|
||||||
|
if !path.is_file() {
|
||||||
|
problems.push(format!(" missing: {rel}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let actual = sha256_file(&path)?;
|
||||||
|
if &actual != expected {
|
||||||
|
problems.push(format!(" modified: {rel}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !problems.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"the extracted overlay does not match its own manifest:\n{}",
|
||||||
|
problems.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides what to do with every file in `overlay/`, without touching anything.
|
||||||
|
///
|
||||||
|
/// `prior` is the previous `install.json` file map. It is what separates "the operator edited
|
||||||
|
/// `Bridge.cfg`" from "the overlay shipped a new `Bridge.cfg`" (PLAN.md §7.0): if what is on disk
|
||||||
|
/// is exactly the copy this installer last *deployed*, the operator has not touched it and an
|
||||||
|
/// upstream change may land. Anything else — including no record at all, i.e. a tree where the
|
||||||
|
/// file was put there by hand per INSTALL.md Appendix A — is treated as the operator's.
|
||||||
|
pub fn plan(
|
||||||
|
overlay_dir: &Path,
|
||||||
|
servuo_root: &Path,
|
||||||
|
prior: Option<&BTreeMap<String, FileRecord>>,
|
||||||
|
) -> Result<Vec<PlannedFile>> {
|
||||||
|
let source = overlay_dir.join("overlay");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect(&source, &source, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut planned = Vec::with_capacity(files.len());
|
||||||
|
for rel in files {
|
||||||
|
let src = source.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
let dst = servuo_root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
let overlay_sha256 = sha256_file(&src)?;
|
||||||
|
let on_disk_sha256 = if dst.is_file() {
|
||||||
|
Some(sha256_file(&dst)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = match &on_disk_sha256 {
|
||||||
|
None => Action::Add,
|
||||||
|
Some(on_disk) if *on_disk == overlay_sha256 => Action::Unchanged,
|
||||||
|
Some(on_disk) if OPERATOR_OWNED.contains(&rel.as_str()) => {
|
||||||
|
match prior.and_then(|p| p.get(&rel)) {
|
||||||
|
// What is on disk is byte-for-byte the copy the installer itself last
|
||||||
|
// deployed, so the operator has not touched it and the release's new default
|
||||||
|
// may land.
|
||||||
|
//
|
||||||
|
// Compared against `overlay_sha256` — the release copy — and NOT against
|
||||||
|
// `on_disk_sha256`: after a file has once been kept, `on_disk_sha256` holds
|
||||||
|
// the *operator's* content, so comparing to it would find a match on the very
|
||||||
|
// next run and overwrite exactly the file this rule exists to protect. A keep
|
||||||
|
// has to stay kept for as long as the operator's edit is there.
|
||||||
|
Some(record) if record.overlay_sha256 == *on_disk => Action::Change,
|
||||||
|
_ => Action::KeptOperatorModified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => Action::Change,
|
||||||
|
};
|
||||||
|
|
||||||
|
planned.push(PlannedFile {
|
||||||
|
rel,
|
||||||
|
src,
|
||||||
|
dst,
|
||||||
|
action,
|
||||||
|
overlay_sha256,
|
||||||
|
on_disk_sha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(planned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies every file the plan writes. Parent directories are created; nothing is removed.
|
||||||
|
pub fn apply(planned: &[PlannedFile]) -> Result<()> {
|
||||||
|
for file in planned.iter().filter(|f| f.action.writes()) {
|
||||||
|
if let Some(parent) = file.dst.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::copy(&file.src, &file.dst).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot write {} — {}",
|
||||||
|
file.dst.display(),
|
||||||
|
"check that the shard is stopped and that you are running as root/Administrator"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize(planned: &[PlannedFile]) -> Summary {
|
||||||
|
let mut summary = Summary::default();
|
||||||
|
for file in planned {
|
||||||
|
match file.action {
|
||||||
|
Action::Add => summary.add += 1,
|
||||||
|
Action::Change => summary.change += 1,
|
||||||
|
Action::Unchanged => summary.unchanged += 1,
|
||||||
|
Action::KeptOperatorModified => summary.kept += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-file record for `install.json`.
|
||||||
|
pub fn file_records(planned: &[PlannedFile]) -> BTreeMap<String, FileRecord> {
|
||||||
|
planned
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
// For everything the installer wrote, what is on disk afterwards *is* the overlay's
|
||||||
|
// copy. Only a kept file keeps its own hash — which is precisely what makes a later
|
||||||
|
// run able to tell that the operator, not the release, owns it.
|
||||||
|
let on_disk = match f.action {
|
||||||
|
Action::KeptOperatorModified => f
|
||||||
|
.on_disk_sha256
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| f.overlay_sha256.clone()),
|
||||||
|
_ => f.overlay_sha256.clone(),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
f.rel.clone(),
|
||||||
|
FileRecord {
|
||||||
|
overlay_sha256: f.overlay_sha256.clone(),
|
||||||
|
on_disk_sha256: on_disk,
|
||||||
|
state: f.action.state().to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the changed files, collapsing a directory full of identically-treated files into one
|
||||||
|
/// line — 22 `ADD` lines for `Scripts/Custom/Bridge/*.cs` push everything else off the screen, and
|
||||||
|
/// what an operator needs to see is that `Scripts.csproj` was overwritten.
|
||||||
|
pub fn render(planned: &[PlannedFile]) -> Vec<String> {
|
||||||
|
const GROUP_AT: usize = 4;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut group: Vec<&PlannedFile> = Vec::new();
|
||||||
|
|
||||||
|
let interesting: Vec<&PlannedFile> = planned
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.action != Action::Unchanged)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let key = |f: &PlannedFile| -> (Action, String, String) {
|
||||||
|
let (dir, name) = match f.rel.rsplit_once('/') {
|
||||||
|
Some((d, n)) => (d.to_string(), n.to_string()),
|
||||||
|
None => (String::new(), f.rel.clone()),
|
||||||
|
};
|
||||||
|
let ext = name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(_, e)| e.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(f.action, dir, ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
let flush = |group: &mut Vec<&PlannedFile>, lines: &mut Vec<String>| {
|
||||||
|
if group.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if group.len() >= GROUP_AT {
|
||||||
|
let (action, dir, ext) = key(group[0]);
|
||||||
|
let glob = if ext.is_empty() {
|
||||||
|
format!("{dir}/*")
|
||||||
|
} else {
|
||||||
|
format!("{dir}/*.{ext}")
|
||||||
|
};
|
||||||
|
lines.push(format!(
|
||||||
|
" {:<7} {:<38} ({} files)",
|
||||||
|
action.label(),
|
||||||
|
glob,
|
||||||
|
group.len()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
for f in group.iter() {
|
||||||
|
lines.push(format!(" {:<7} {}", f.action.label(), f.rel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
for file in interesting {
|
||||||
|
if group.first().map(|g| key(g)) != Some(key(file)) {
|
||||||
|
flush(&mut group, &mut lines);
|
||||||
|
}
|
||||||
|
group.push(file);
|
||||||
|
}
|
||||||
|
flush(&mut group, &mut lines);
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively lists files under `dir` as `/`-separated paths relative to `base`.
|
||||||
|
fn collect(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
|
||||||
|
let entries = fs::read_dir(dir).with_context(|| format!("cannot list {}", dir.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.with_context(|| format!("cannot list {}", dir.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect(base, &path, out)?;
|
||||||
|
} else if path.is_file() {
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(base)
|
||||||
|
.with_context(|| format!("{} is not under {}", path.display(), base.display()))?;
|
||||||
|
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_tmp: TempDir,
|
||||||
|
overlay_dir: PathBuf,
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An overlay release laid out the way the tarball is, and an empty ServUO tree.
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let tmp = TempDir::new("rg-test-overlay").unwrap();
|
||||||
|
let overlay_dir = tmp.path().join("runicgateway-overlay");
|
||||||
|
let root = tmp.path().join("ServUO");
|
||||||
|
|
||||||
|
let bridge = overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Scripts")
|
||||||
|
.join("Custom")
|
||||||
|
.join("Bridge");
|
||||||
|
fs::create_dir_all(&bridge).unwrap();
|
||||||
|
fs::create_dir_all(overlay_dir.join("overlay").join("Config")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Config")
|
||||||
|
.join("Bridge.cfg"),
|
||||||
|
b"LinkUrl=https://yoursite/link\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Scripts")
|
||||||
|
.join("Scripts.csproj"),
|
||||||
|
b"<Project/>\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..5 {
|
||||||
|
fs::write(bridge.join(format!("Bridge{i}.cs")), format!("// {i}\n")).unwrap();
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
Fixture {
|
||||||
|
_tmp: tmp,
|
||||||
|
overlay_dir,
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_of<'a>(planned: &'a [PlannedFile], rel: &str) -> &'a PlannedFile {
|
||||||
|
planned.iter().find(|f| f.rel == rel).expect(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_install_adds_everything() {
|
||||||
|
let fx = fixture();
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
let summary = summarize(&planned);
|
||||||
|
assert_eq!(summary.add, 7);
|
||||||
|
assert_eq!(summary.change + summary.unchanged + summary.kept, 0);
|
||||||
|
|
||||||
|
apply(&planned).unwrap();
|
||||||
|
assert!(fx.root.join("Config").join("Bridge.cfg").is_file());
|
||||||
|
assert!(fx
|
||||||
|
.root
|
||||||
|
.join("Scripts")
|
||||||
|
.join("Custom")
|
||||||
|
.join("Bridge")
|
||||||
|
.join("Bridge0.cs")
|
||||||
|
.is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_run_with_no_upstream_change_writes_nothing() {
|
||||||
|
let fx = fixture();
|
||||||
|
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
apply(&first).unwrap();
|
||||||
|
let records = file_records(&first);
|
||||||
|
|
||||||
|
let second = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
|
||||||
|
let summary = summarize(&second);
|
||||||
|
assert_eq!(summary.unchanged, 7);
|
||||||
|
assert!(!summary.writes_anything());
|
||||||
|
assert!(
|
||||||
|
render(&second).is_empty(),
|
||||||
|
"an unchanged run prints no file lines"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and it must produce a byte-identical record, or install.json would be rewritten on
|
||||||
|
// every run — "reports unchanged and writes nothing" is the requirement, and a file map
|
||||||
|
// that recorded `add` the first time and `unchanged` the second would quietly break it.
|
||||||
|
assert_eq!(records, file_records(&second));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_files_are_always_overwritten() {
|
||||||
|
// A hand-edited .cs file or Scripts.csproj is a stale copy that breaks the build, and
|
||||||
|
// ServUO will not say so — it reloads the previous Scripts.dll and boots clean.
|
||||||
|
let fx = fixture();
|
||||||
|
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
|
||||||
|
let csproj = fx.root.join("Scripts").join("Scripts.csproj");
|
||||||
|
fs::write(&csproj, b"<Project> hand edited </Project>\n").unwrap();
|
||||||
|
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action_of(&planned, "Scripts/Scripts.csproj").action,
|
||||||
|
Action::Change
|
||||||
|
);
|
||||||
|
apply(&planned).unwrap();
|
||||||
|
assert_eq!(fs::read(&csproj).unwrap(), b"<Project/>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edited_bridge_cfg_is_kept_even_when_the_release_moved_on() {
|
||||||
|
// The deviation from deploy.ps1: overwriting here would silently revert LinkUrl,
|
||||||
|
// PublicConnectAddress and every sweep interval on an update.
|
||||||
|
let fx = fixture();
|
||||||
|
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
apply(&first).unwrap();
|
||||||
|
let records = file_records(&first);
|
||||||
|
|
||||||
|
let deployed = fx.root.join("Config").join("Bridge.cfg");
|
||||||
|
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
|
||||||
|
// ...and the release ships a new default too, so this is not merely "no upstream change".
|
||||||
|
fs::write(
|
||||||
|
fx.overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Config")
|
||||||
|
.join("Bridge.cfg"),
|
||||||
|
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
|
||||||
|
let cfg = action_of(&planned, "Config/Bridge.cfg");
|
||||||
|
assert_eq!(cfg.action, Action::KeptOperatorModified);
|
||||||
|
apply(&planned).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&deployed).unwrap(),
|
||||||
|
b"LinkUrl=https://myshard.example/link\n",
|
||||||
|
"the operator's file must survive"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the record keeps the operator's hash, not the release's — otherwise the next run
|
||||||
|
// would conclude the operator had never touched it and overwrite on the run after that.
|
||||||
|
let records = file_records(&planned);
|
||||||
|
let record = &records["Config/Bridge.cfg"];
|
||||||
|
assert_ne!(record.on_disk_sha256, record.overlay_sha256);
|
||||||
|
assert_eq!(record.state, "kept-operator-modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kept_bridge_cfg_stays_kept_run_after_run() {
|
||||||
|
// The rule has to survive its own bookkeeping. Once a file is kept, the record holds the
|
||||||
|
// operator's hash as what is on disk — so a rule that asked "is the tree still what the
|
||||||
|
// record last saw?" would answer yes on the next run and overwrite the very file it had
|
||||||
|
// just protected. Three runs, because the bug only appears from the second one on.
|
||||||
|
let fx = fixture();
|
||||||
|
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
apply(&first).unwrap();
|
||||||
|
let mut records = file_records(&first);
|
||||||
|
|
||||||
|
let deployed = fx.root.join("Config").join("Bridge.cfg");
|
||||||
|
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
fx.overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Config")
|
||||||
|
.join("Bridge.cfg"),
|
||||||
|
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for run in 2..=4 {
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action_of(&planned, "Config/Bridge.cfg").action,
|
||||||
|
Action::KeptOperatorModified,
|
||||||
|
"run {run} must still keep the operator's file"
|
||||||
|
);
|
||||||
|
apply(&planned).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&deployed).unwrap(),
|
||||||
|
b"LinkUrl=https://myshard.example/link\n",
|
||||||
|
"run {run} overwrote the operator's file"
|
||||||
|
);
|
||||||
|
records = file_records(&planned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untouched_bridge_cfg_takes_the_upstream_change() {
|
||||||
|
// The other half of the rule: if what is on disk is exactly what was deployed, the
|
||||||
|
// operator has not edited it and a new default may land.
|
||||||
|
let fx = fixture();
|
||||||
|
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
apply(&first).unwrap();
|
||||||
|
let records = file_records(&first);
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
fx.overlay_dir
|
||||||
|
.join("overlay")
|
||||||
|
.join("Config")
|
||||||
|
.join("Bridge.cfg"),
|
||||||
|
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action_of(&planned, "Config/Bridge.cfg").action,
|
||||||
|
Action::Change
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hand_installed_tree_with_no_record_keeps_its_bridge_cfg() {
|
||||||
|
// INSTALL.md Appendix A tells operators to deploy by hand today. When the installer later
|
||||||
|
// arrives on such a host there is no record to compare against, and the safe reading of an
|
||||||
|
// unknown edit is that it is the operator's.
|
||||||
|
let fx = fixture();
|
||||||
|
fs::create_dir_all(fx.root.join("Config")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
fx.root.join("Config").join("Bridge.cfg"),
|
||||||
|
b"LinkUrl=https://myshard.example/link\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action_of(&planned, "Config/Bridge.cfg").action,
|
||||||
|
Action::KeptOperatorModified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_outside_the_overlay_is_touched() {
|
||||||
|
let fx = fixture();
|
||||||
|
let stranger = fx.root.join("Scripts").join("Custom").join("MyShard.cs");
|
||||||
|
fs::create_dir_all(stranger.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&stranger, b"// mine\n").unwrap();
|
||||||
|
|
||||||
|
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
|
||||||
|
assert_eq!(fs::read(&stranger).unwrap(), b"// mine\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_of_identical_actions_collapses_to_one_line() {
|
||||||
|
let fx = fixture();
|
||||||
|
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
|
||||||
|
let lines = render(&planned);
|
||||||
|
assert!(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("Scripts/Custom/Bridge/*.cs") && l.contains("(5 files)")),
|
||||||
|
"{lines:#?}"
|
||||||
|
);
|
||||||
|
// The single-file entries stay individually visible — Scripts.csproj overwriting a stock
|
||||||
|
// file is exactly what must not get folded away.
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l.contains("Scripts/Scripts.csproj")),
|
||||||
|
"{lines:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_manifest_check_catches_a_tampered_payload() {
|
||||||
|
let fx = fixture();
|
||||||
|
let cfg_rel = "overlay/Config/Bridge.cfg";
|
||||||
|
let manifest = Manifest {
|
||||||
|
component: "servuo-plugins-overlay".into(),
|
||||||
|
version: "0.1.1".into(),
|
||||||
|
commit: "3a52abb".into(),
|
||||||
|
repo: "RunicGateway/servuo-plugins".into(),
|
||||||
|
protocol: 3,
|
||||||
|
servuo: ServUoCompat {
|
||||||
|
min_version: "57.4".into(),
|
||||||
|
patches_verified_against: "57.4".into(),
|
||||||
|
},
|
||||||
|
files: BTreeMap::from([(
|
||||||
|
cfg_rel.to_string(),
|
||||||
|
sha256_file(&fx.overlay_dir.join(cfg_rel)).unwrap(),
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
verify_payload(&fx.overlay_dir, &manifest).unwrap();
|
||||||
|
|
||||||
|
fs::write(fx.overlay_dir.join(cfg_rel), b"tampered\n").unwrap();
|
||||||
|
let err = verify_payload(&fx.overlay_dir, &manifest)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("modified: overlay/Config/Bridge.cfg"), "{err}");
|
||||||
|
|
||||||
|
fs::remove_file(fx.overlay_dir.join(cfg_rel)).unwrap();
|
||||||
|
let err = verify_payload(&fx.overlay_dir, &manifest)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("missing: overlay/Config/Bridge.cfg"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/paths.rs
Normal file
113
src/paths.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//! Where the installer's own files live.
|
||||||
|
//!
|
||||||
|
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
|
||||||
|
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
|
||||||
|
//! working directory, and a service manager's working directory is not somewhere to put a database.
|
||||||
|
//! The installer therefore owns the layout and (from Phase 2) pins `UOLINK_CONFIG` and
|
||||||
|
//! `UOLINK_DB_PATH` into the service definition.
|
||||||
|
//!
|
||||||
|
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
|
||||||
|
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than
|
||||||
|
/// hidden: an undocumented environment variable that moves where a tool writes is worse than a
|
||||||
|
/// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote.
|
||||||
|
pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Layout {
|
||||||
|
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
||||||
|
pub state_dir: PathBuf,
|
||||||
|
/// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2.
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
/// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2.
|
||||||
|
pub sidecar_bin: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn install_record(&self) -> PathBuf {
|
||||||
|
self.state_dir.join("install.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidecar_config(&self) -> PathBuf {
|
||||||
|
self.state_dir.join("sidecar.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidecar_db(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("uo-link.db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||||
|
///
|
||||||
|
/// The override moves the *state* and *data* directories together. Splitting them under an override
|
||||||
|
/// would make a test run write half its files into the real system location, which is exactly the
|
||||||
|
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
|
||||||
|
/// it, and a relocated binary would not be what the service definition names.
|
||||||
|
pub fn layout() -> Layout {
|
||||||
|
let mut layout = platform_layout();
|
||||||
|
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
||||||
|
let root = PathBuf::from(dir);
|
||||||
|
layout.data_dir = root.join("data");
|
||||||
|
layout.state_dir = root;
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn platform_layout() -> Layout {
|
||||||
|
// %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to
|
||||||
|
// C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic.
|
||||||
|
let program_data = env::var_os("ProgramData")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
|
||||||
|
let program_files = env::var_os("ProgramFiles")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
|
||||||
|
|
||||||
|
// Data lives under ProgramData, never under ProgramFiles: a service writing beneath
|
||||||
|
// C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3).
|
||||||
|
Layout {
|
||||||
|
state_dir: program_data.join("RunicGateway"),
|
||||||
|
data_dir: program_data.join("RunicGateway"),
|
||||||
|
sidecar_bin: program_files
|
||||||
|
.join("RunicGateway")
|
||||||
|
.join("uo-link-sidecar.exe"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn platform_layout() -> Layout {
|
||||||
|
Layout {
|
||||||
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||||
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||||
|
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_installers_own_files_sit_in_the_state_dir() {
|
||||||
|
// Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to
|
||||||
|
// clean and `doctor` has one place to read. The cached patch set joins them in Phase 3.
|
||||||
|
let l = platform_layout();
|
||||||
|
assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path()));
|
||||||
|
assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_layout_is_absolute() {
|
||||||
|
// A relative state directory would reintroduce exactly the working-directory trap this
|
||||||
|
// layout exists to close.
|
||||||
|
let l = platform_layout();
|
||||||
|
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
|
||||||
|
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
||||||
|
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/record.rs
Normal file
265
src/record.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//! `install.json` — what this host has deployed.
|
||||||
|
//!
|
||||||
|
//! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later
|
||||||
|
//! command reasons from. Two of its properties are load-bearing rather than informational:
|
||||||
|
//!
|
||||||
|
//! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the
|
||||||
|
//! record and the release manifest means the overlay moved on; differing from the record alone
|
||||||
|
//! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and
|
||||||
|
//! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on.
|
||||||
|
//! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on
|
||||||
|
//! a host where Phase 2 and 3 have written sidecar and patch records must give them back
|
||||||
|
//! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved
|
||||||
|
//! verbatim. A future field that silently vanished on a re-run would be worse than one that was
|
||||||
|
//! never written.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::util::write_atomic;
|
||||||
|
|
||||||
|
/// The shape of this document. Independent of the bundle's `schema` and of any protocol version.
|
||||||
|
pub const SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct InstallRecord {
|
||||||
|
pub schema: u32,
|
||||||
|
pub installer: InstallerInfo,
|
||||||
|
/// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one
|
||||||
|
/// excluded when deciding whether a re-run has anything to record.
|
||||||
|
pub updated: String,
|
||||||
|
pub bundle: BundleRef,
|
||||||
|
pub servuo: ServUoRef,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub overlay: Option<OverlayRecord>,
|
||||||
|
/// Phase 2 (uo-link binary, config and service). Carried through untouched by this build.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub link: Option<serde_json::Value>,
|
||||||
|
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub patches: Vec<serde_json::Value>,
|
||||||
|
/// Anything a newer installer wrote that this one has no name for.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: BTreeMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct InstallerInfo {
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BundleRef {
|
||||||
|
pub tag: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
/// The exact document this install resolved, so a re-install can be reproduced and a support
|
||||||
|
/// question about "which bundle?" is answered by the file rather than by memory.
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ServUoRef {
|
||||||
|
pub path: String,
|
||||||
|
/// The detected version, or `null`. Recorded even when unknown: the patch tier's support story
|
||||||
|
/// follows the install (§2.2.2), and a later `doctor` must be able to show it without
|
||||||
|
/// re-deriving it.
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct OverlayRecord {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub protocol: u32,
|
||||||
|
/// Keyed by ServUO-tree-relative path, always with `/` separators so a record written on
|
||||||
|
/// Windows is readable on Linux and vice versa.
|
||||||
|
pub files: BTreeMap<String, FileRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct FileRecord {
|
||||||
|
/// What the release shipped for this path.
|
||||||
|
pub overlay_sha256: String,
|
||||||
|
/// What is on disk in the ServUO tree after this run. Equal to `overlay_sha256` except for a
|
||||||
|
/// file the installer deliberately left alone (`Config/Bridge.cfg`, once edited).
|
||||||
|
pub on_disk_sha256: String,
|
||||||
|
/// `deployed` (the tree holds the release's copy) or `kept-operator-modified` (it holds the
|
||||||
|
/// operator's).
|
||||||
|
///
|
||||||
|
/// Deliberately a **state, not a verb**: recording `add` on the first run and `unchanged` on
|
||||||
|
/// the next would make every re-run rewrite this file, which is exactly the "a second run
|
||||||
|
/// writes nothing" promise in PLAN.md Phase 1. What later commands need to know is whose copy
|
||||||
|
/// is in the tree, and that does not change just because time passed.
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstallRecord {
|
||||||
|
/// Whether a re-run would record anything new.
|
||||||
|
///
|
||||||
|
/// Everything except `updated` is compared: PLAN.md Phase 1 requires that a second run with no
|
||||||
|
/// upstream change reports "unchanged" and **writes nothing**, and rewriting the file purely to
|
||||||
|
/// move a timestamp would break that promise in the least visible way possible — by touching a
|
||||||
|
/// file whose mtime an operator may be watching.
|
||||||
|
pub fn same_deployment_as(&self, other: &Self) -> bool {
|
||||||
|
let mut a = self.clone();
|
||||||
|
let mut b = other.clone();
|
||||||
|
a.updated.clear();
|
||||||
|
b.updated.clear();
|
||||||
|
a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Option<Self>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let body = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
let record: Self = serde_json::from_str(&body).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"{} exists but is not a record this installer understands. \
|
||||||
|
Move it aside to start over, or install a newer installer.",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
// Pretty-printed with a trailing newline: this file is read by humans during support, and
|
||||||
|
// diffed by anyone who keeps /etc under version control.
|
||||||
|
let mut body =
|
||||||
|
serde_json::to_string_pretty(self).context("cannot serialize install.json")?;
|
||||||
|
body.push('\n');
|
||||||
|
write_atomic(path, body.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files this installer previously deployed, for the `Bridge.cfg` comparison in `overlay::plan`.
|
||||||
|
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
|
||||||
|
self.overlay.as_ref().map(|o| &o.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_rfc3339() -> String {
|
||||||
|
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
fn sample() -> InstallRecord {
|
||||||
|
InstallRecord {
|
||||||
|
schema: SCHEMA,
|
||||||
|
installer: InstallerInfo {
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
},
|
||||||
|
updated: "2026-08-04T18:00:00Z".into(),
|
||||||
|
bundle: BundleRef {
|
||||||
|
tag: "2026.08.04".into(),
|
||||||
|
protocol: 3,
|
||||||
|
url: "https://example/bundles/current.json".into(),
|
||||||
|
},
|
||||||
|
servuo: ServUoRef {
|
||||||
|
path: "/opt/ServUO".into(),
|
||||||
|
version: Some("57.4".into()),
|
||||||
|
},
|
||||||
|
overlay: Some(OverlayRecord {
|
||||||
|
repo: "RunicGateway/servuo-plugins".into(),
|
||||||
|
tag: "v0.1.1".into(),
|
||||||
|
version: "0.1.1".into(),
|
||||||
|
commit: "3a52abb".into(),
|
||||||
|
protocol: 3,
|
||||||
|
files: BTreeMap::from([(
|
||||||
|
"Config/Bridge.cfg".to_string(),
|
||||||
|
FileRecord {
|
||||||
|
overlay_sha256: "aa".into(),
|
||||||
|
on_disk_sha256: "aa".into(),
|
||||||
|
state: "deployed".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
}),
|
||||||
|
link: None,
|
||||||
|
patches: Vec::new(),
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_round_trips() {
|
||||||
|
let dir = TempDir::new("rg-test-record").unwrap();
|
||||||
|
let path = dir.path().join("install.json");
|
||||||
|
let record = sample();
|
||||||
|
record.save(&path).unwrap();
|
||||||
|
assert_eq!(InstallRecord::load(&path).unwrap().unwrap(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_record_is_not_an_error() {
|
||||||
|
let dir = TempDir::new("rg-test-record-missing").unwrap();
|
||||||
|
assert!(InstallRecord::load(&dir.path().join("nope.json"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_phases_survive_a_phase_one_rewrite() {
|
||||||
|
// The scenario: Phase 2 and 3 have written sidecar and patch sections (and some future
|
||||||
|
// field this build has never heard of), then an older installer re-runs. Dropping any of
|
||||||
|
// it would make `doctor` and `uninstall` forget a service and a set of applied hunks.
|
||||||
|
let dir = TempDir::new("rg-test-record-forward").unwrap();
|
||||||
|
let path = dir.path().join("install.json");
|
||||||
|
let body = r#"{
|
||||||
|
"schema": 1,
|
||||||
|
"installer": { "version": "0.9.0" },
|
||||||
|
"updated": "2026-09-01T00:00:00Z",
|
||||||
|
"bundle": { "tag": "2026.09.01", "protocol": 3, "url": "https://example/current.json" },
|
||||||
|
"servuo": { "path": "/opt/ServUO", "version": "57.4" },
|
||||||
|
"overlay": null,
|
||||||
|
"link": { "version": "1.1.0", "service": "runicgateway-link.service" },
|
||||||
|
"patches": [ { "name": "commandlogging-event", "rung": "region-match" } ],
|
||||||
|
"future_section": { "kept": true }
|
||||||
|
}"#;
|
||||||
|
std::fs::write(&path, body).unwrap();
|
||||||
|
|
||||||
|
let loaded = InstallRecord::load(&path).unwrap().unwrap();
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
|
||||||
|
assert!(text.contains("runicgateway-link.service"), "{text}");
|
||||||
|
assert!(text.contains("region-match"), "{text}");
|
||||||
|
assert!(text.contains("future_section"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_timestamp_is_ignored_when_deciding_to_rewrite() {
|
||||||
|
let a = sample();
|
||||||
|
let mut b = a.clone();
|
||||||
|
b.updated = "2027-01-01T00:00:00Z".into();
|
||||||
|
assert!(a.same_deployment_as(&b));
|
||||||
|
|
||||||
|
// Anything that actually describes the deployment must count as a change.
|
||||||
|
let mut c = a.clone();
|
||||||
|
c.bundle.tag = "2026.09.01".into();
|
||||||
|
assert!(!a.same_deployment_as(&c));
|
||||||
|
|
||||||
|
let mut d = a.clone();
|
||||||
|
if let Some(overlay) = d.overlay.as_mut() {
|
||||||
|
overlay.files.get_mut("Config/Bridge.cfg").unwrap().state =
|
||||||
|
"kept-operator-modified".into();
|
||||||
|
}
|
||||||
|
assert!(!a.same_deployment_as(&d));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_are_utc_rfc3339() {
|
||||||
|
let now = now_rfc3339();
|
||||||
|
assert!(now.ends_with('Z'), "{now}");
|
||||||
|
assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}");
|
||||||
|
}
|
||||||
|
}
|
||||||
402
src/servuo.rs
Normal file
402
src/servuo.rs
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
//! Finding, validating and interrogating a ServUO installation.
|
||||||
|
//!
|
||||||
|
//! Three questions, in the order the installer asks them:
|
||||||
|
//!
|
||||||
|
//! 1. **Where is it?** `--servuo`, else detection from where the binary was run, else a prompt.
|
||||||
|
//! 2. **Is it really one?** `ServUO.exe`, `Scripts/` and `Config/` must all be present
|
||||||
|
//! (INSTALL.md §2). Deploying 24 files into a directory that merely looked plausible is a mess
|
||||||
|
//! to unpick by hand.
|
||||||
|
//! 3. **Is it running?** If it is, the run stops. `deploy.ps1` hard-throws here and the installer
|
||||||
|
//! inherits that (PLAN.md §2.5): ServUO holds `Scripts.dll` open and rewrites `Saves/` on exit,
|
||||||
|
//! so deploying underneath it corrupts one or both.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
/// The version everything is designed, built and tested against (PLAN.md §2.2.2).
|
||||||
|
pub const SUPPORTED_VERSION: &str = "57.4";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ServUoRoot {
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// `None` when `Server/AssemblyInfo.cs` is absent or unparseable. Reported as "unknown", which
|
||||||
|
/// is treated exactly like any other non-57.4 answer: the base install proceeds, and the patch
|
||||||
|
/// tier (Phase 3) takes its unsupported path.
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServUoRoot {
|
||||||
|
/// Whether this tree is the one supported version. `None` (unknown) is deliberately **not**
|
||||||
|
/// supported: an unreadable version is not evidence of a good one.
|
||||||
|
pub fn is_supported_version(&self) -> bool {
|
||||||
|
self.version
|
||||||
|
.as_deref()
|
||||||
|
.map(normalize_version)
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| v == SUPPORTED_VERSION)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version_display(&self) -> String {
|
||||||
|
self.version.clone().unwrap_or_else(|| "unknown".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a candidate directory and reads its version.
|
||||||
|
pub fn open(path: &Path) -> Result<ServUoRoot> {
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("no such directory: {}", path.display());
|
||||||
|
}
|
||||||
|
if !path.is_dir() {
|
||||||
|
bail!("not a directory: {}", path.display());
|
||||||
|
}
|
||||||
|
if !looks_like_root(path) {
|
||||||
|
bail!(
|
||||||
|
"{} does not look like a ServUO root — it must contain ServUO.exe, Scripts/ and Config/",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Canonicalized so the path recorded in install.json is stable across runs started from
|
||||||
|
// different working directories. Windows' \\?\ prefix is stripped: it is correct but appears
|
||||||
|
// in every printed line and in the operator's copy-pasted report.
|
||||||
|
let path = fs::canonicalize(path)
|
||||||
|
.map(strip_extended_prefix)
|
||||||
|
.unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
let version = read_version(&path);
|
||||||
|
Ok(ServUoRoot { path, version })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The membership test from INSTALL.md §2 — all three, not any.
|
||||||
|
pub fn looks_like_root(path: &Path) -> bool {
|
||||||
|
path.join("ServUO.exe").is_file()
|
||||||
|
&& path.join("Scripts").is_dir()
|
||||||
|
&& path.join("Config").is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks for a ServUO root around where the installer was run.
|
||||||
|
///
|
||||||
|
/// "Run from inside it or from an obvious sibling" (INSTALL.md §2) means: the working directory or
|
||||||
|
/// one of its parents, then the directory holding the binary or one of its parents — an operator
|
||||||
|
/// who `scp`'d the installer into the server root and ran it there should not be asked where the
|
||||||
|
/// server root is. Parents are walked because `cd Scripts && ../installer` is a normal thing to do.
|
||||||
|
/// Nothing outside those two chains is searched: guessing at unrelated directories on the host is
|
||||||
|
/// how a tool deploys into the wrong shard.
|
||||||
|
pub fn detect() -> Option<PathBuf> {
|
||||||
|
let mut starts: Vec<PathBuf> = Vec::new();
|
||||||
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
|
starts.push(cwd);
|
||||||
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
starts.push(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for start in starts {
|
||||||
|
// Four levels is enough for Scripts/Custom/Bridge and nothing like enough to wander into
|
||||||
|
// an unrelated tree.
|
||||||
|
let mut candidate: &Path = &start;
|
||||||
|
for _ in 0..5 {
|
||||||
|
if looks_like_root(candidate) {
|
||||||
|
return Some(candidate.to_path_buf());
|
||||||
|
}
|
||||||
|
match candidate.parent() {
|
||||||
|
Some(parent) => candidate = parent,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the version from `Server/AssemblyInfo.cs`.
|
||||||
|
///
|
||||||
|
/// The source file rather than `ServUO.exe`'s PE metadata: it is the same *source* tree the patch
|
||||||
|
/// tier diffs against, it works identically on Linux and Windows, and it costs no dependency. The
|
||||||
|
/// exe describes whenever the core was last built, which on a tree mid-upgrade is a different — and
|
||||||
|
/// less relevant — answer.
|
||||||
|
fn read_version(root: &Path) -> Option<String> {
|
||||||
|
let text = fs::read_to_string(root.join("Server").join("AssemblyInfo.cs")).ok()?;
|
||||||
|
parse_assembly_version(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts `57.4` from `[assembly: AssemblyVersion("57.4")]`.
|
||||||
|
///
|
||||||
|
/// Hand-parsed rather than regex'd (no dependency for one pattern), and tolerant of the whitespace
|
||||||
|
/// and attribute-ordering variations that show up across forks. Commented-out lines are skipped:
|
||||||
|
/// ServUO's own file has none, but a fork that left an old declaration behind would otherwise hand
|
||||||
|
/// back a version nobody is running.
|
||||||
|
pub fn parse_assembly_version(source: &str) -> Option<String> {
|
||||||
|
for line in source.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(rest) = line.split_once("AssemblyVersion").map(|(_, r)| r) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(open) = rest.find('"') else { continue };
|
||||||
|
let Some(close) = rest[open + 1..].find('"') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = &rest[open + 1..open + 1 + close];
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops trailing `.0` components so `57.4.0.0` and `57.4` compare equal.
|
||||||
|
///
|
||||||
|
/// ServUO declares `57.4` in source while .NET reports `57.4.0.0`; both name the same release, and
|
||||||
|
/// an operator should not be told their supported tree is unsupported over padding.
|
||||||
|
pub fn normalize_version(raw: &str) -> String {
|
||||||
|
let parts: Vec<&str> = raw.trim().split('.').collect();
|
||||||
|
let mut end = parts.len();
|
||||||
|
while end > 1 && parts[end - 1] == "0" {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
parts[..end].join(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||||
|
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||||
|
Some(stripped) => PathBuf::from(stripped),
|
||||||
|
None => path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ServUO process found running out of the tree being deployed into.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RunningShard {
|
||||||
|
pub pid: u32,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses to proceed if a shard is running out of `root`.
|
||||||
|
///
|
||||||
|
/// Matched by **executable and command-line path**, not by process name. `deploy.ps1` can look for
|
||||||
|
/// a process called `ServUO` because it only ever runs on Windows; on Linux the same shard appears
|
||||||
|
/// as `mono` or `dotnet` with `ServUO.exe` as an argument, and a name match would return "not
|
||||||
|
/// running" for a shard that is very much running — the one wrong answer that corrupts a live
|
||||||
|
/// `Scripts.dll`. Scoping to processes under *this* root also means a second shard on the same host
|
||||||
|
/// does not block a deploy into the first.
|
||||||
|
pub fn find_running(root: &Path) -> Option<RunningShard> {
|
||||||
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||||
|
|
||||||
|
let system = System::new_with_specifics(
|
||||||
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||||
|
);
|
||||||
|
let root_str = normalize_for_match(&root.to_string_lossy());
|
||||||
|
|
||||||
|
for (pid, process) in system.processes() {
|
||||||
|
// Own process first: the installer may well have been copied into the server root, and
|
||||||
|
// matching itself would make every run refuse to start.
|
||||||
|
if pid.as_u32() == std::process::id() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = process
|
||||||
|
.exe()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let args: Vec<String> = process
|
||||||
|
.cmd()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let hay = normalize_for_match(&format!("{exe} {}", args.join(" ")));
|
||||||
|
// Two conditions, both required: something in this process names the tree, AND it names
|
||||||
|
// the ServUO assembly. Either alone over-matches — an editor with the path open, or a
|
||||||
|
// different shard's ServUO.exe.
|
||||||
|
if hay.contains(&root_str) && hay.contains("servuo.exe") {
|
||||||
|
let detail = if exe.is_empty() { args.join(" ") } else { exe };
|
||||||
|
return Some(RunningShard {
|
||||||
|
pid: pid.as_u32(),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-cases and unifies separators so a Windows path compares equal however it was spelled.
|
||||||
|
fn normalize_for_match(s: &str) -> String {
|
||||||
|
s.to_lowercase().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the refusal message. Separate from [`find_running`] so the wording is testable and so
|
||||||
|
/// callers cannot accidentally soften it.
|
||||||
|
pub fn running_error(root: &Path, shard: &RunningShard) -> anyhow::Error {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"ServUO is running from {} (pid {} — {}).\n\
|
||||||
|
Stop the shard before installing. ServUO.exe holds Scripts.dll open and rewrites Saves/ \
|
||||||
|
on exit, so deploying underneath it corrupts one or both. This is not overridable.",
|
||||||
|
root.display(),
|
||||||
|
shard.pid,
|
||||||
|
shard.detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper used by the commands: validate the path, then refuse if it is in use.
|
||||||
|
pub fn open_stopped(path: &Path) -> Result<ServUoRoot> {
|
||||||
|
let root =
|
||||||
|
open(path).with_context(|| format!("cannot use {} as a ServUO root", path.display()))?;
|
||||||
|
if let Some(shard) = find_running(&root.path) {
|
||||||
|
return Err(running_error(&root.path, &shard));
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
fn fake_root(dir: &Path) {
|
||||||
|
fs::create_dir_all(dir.join("Scripts")).unwrap();
|
||||||
|
fs::create_dir_all(dir.join("Config")).unwrap();
|
||||||
|
fs::create_dir_all(dir.join("Server")).unwrap();
|
||||||
|
fs::write(dir.join("ServUO.exe"), b"MZ").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_three_markers_are_required() {
|
||||||
|
let tmp = TempDir::new("rg-test-servuo").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
assert!(looks_like_root(&root));
|
||||||
|
|
||||||
|
for missing in ["ServUO.exe", "Scripts", "Config"] {
|
||||||
|
let partial = tmp.path().join(format!("partial-{missing}"));
|
||||||
|
fake_root(&partial);
|
||||||
|
let victim = partial.join(missing);
|
||||||
|
if victim.is_dir() {
|
||||||
|
fs::remove_dir_all(&victim).unwrap();
|
||||||
|
} else {
|
||||||
|
fs::remove_file(&victim).unwrap();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!looks_like_root(&partial),
|
||||||
|
"a tree without {missing} must not qualify"
|
||||||
|
);
|
||||||
|
assert!(open(&partial).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_version_comes_from_assembly_info() {
|
||||||
|
let tmp = TempDir::new("rg-test-version").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
fs::write(
|
||||||
|
root.join("Server").join("AssemblyInfo.cs"),
|
||||||
|
"using System.Reflection;\n[assembly: AssemblyTitle(\"ServUO\")]\n[assembly: AssemblyVersion(\"57.4\")]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let opened = open(&root).unwrap();
|
||||||
|
assert_eq!(opened.version.as_deref(), Some("57.4"));
|
||||||
|
assert!(opened.is_supported_version());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_version_is_unknown_and_unsupported() {
|
||||||
|
// "Unknown" must not be optimistically treated as 57.4: an unreadable version is not
|
||||||
|
// evidence of a good one, and it gates the patch tier in Phase 3.
|
||||||
|
let tmp = TempDir::new("rg-test-noversion").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
let opened = open(&root).unwrap();
|
||||||
|
assert_eq!(opened.version, None);
|
||||||
|
assert!(!opened.is_supported_version());
|
||||||
|
assert_eq!(opened.version_display(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assembly_version_parsing_handles_real_world_spellings() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[assembly: AssemblyVersion(\"57.4\")]").as_deref(),
|
||||||
|
Some("57.4")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[ assembly : AssemblyVersion ( \"57.4.0.0\" ) ]").as_deref(),
|
||||||
|
Some("57.4.0.0")
|
||||||
|
);
|
||||||
|
// A fork that left an old declaration commented out must not win.
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version(
|
||||||
|
"// [assembly: AssemblyVersion(\"56.0\")]\n[assembly: AssemblyVersion(\"57.4\")]"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("57.4")
|
||||||
|
);
|
||||||
|
assert_eq!(parse_assembly_version("no version here"), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_assembly_version("[assembly: AssemblyVersion(\"\")]"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotnet_padding_does_not_make_a_supported_tree_unsupported() {
|
||||||
|
assert_eq!(normalize_version("57.4.0.0"), "57.4");
|
||||||
|
assert_eq!(normalize_version("57.4"), "57.4");
|
||||||
|
assert_eq!(normalize_version("0.0.0"), "0");
|
||||||
|
// Padding is stripped; a genuinely different version still differs.
|
||||||
|
assert_ne!(normalize_version("57.40"), SUPPORTED_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detection_finds_a_root_from_a_subdirectory() {
|
||||||
|
let tmp = TempDir::new("rg-test-detect").unwrap();
|
||||||
|
let root = tmp.path().join("ServUO");
|
||||||
|
fake_root(&root);
|
||||||
|
let deep = root.join("Scripts").join("Custom");
|
||||||
|
fs::create_dir_all(&deep).unwrap();
|
||||||
|
|
||||||
|
// detect() reads the process's working directory, so exercise the walk directly on the
|
||||||
|
// same chain it uses rather than mutating global state inside a threaded test runner.
|
||||||
|
let mut candidate: &Path = &deep;
|
||||||
|
let mut found = None;
|
||||||
|
for _ in 0..5 {
|
||||||
|
if looks_like_root(candidate) {
|
||||||
|
found = Some(candidate.to_path_buf());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = candidate.parent().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(found.as_deref(), Some(root.as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_refusal_says_why_and_offers_no_override() {
|
||||||
|
let shard = RunningShard {
|
||||||
|
pid: 4242,
|
||||||
|
detail: "/opt/ServUO/ServUO.exe".into(),
|
||||||
|
};
|
||||||
|
let msg = running_error(Path::new("/opt/ServUO"), &shard).to_string();
|
||||||
|
assert!(msg.contains("4242"), "{msg}");
|
||||||
|
assert!(msg.contains("Scripts.dll"), "{msg}");
|
||||||
|
assert!(msg.contains("not overridable"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_is_running_out_of_an_empty_tree() {
|
||||||
|
// Also proves the scan does not match the test binary itself, which is the failure mode
|
||||||
|
// that would make every install refuse to start.
|
||||||
|
let tmp = TempDir::new("rg-test-running").unwrap();
|
||||||
|
let root = tmp.path().join("srv");
|
||||||
|
fake_root(&root);
|
||||||
|
assert!(find_running(&root).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/ui.rs
Normal file
123
src/ui.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//! Terminal output and prompts.
|
||||||
|
//!
|
||||||
|
//! Two rules shape this module:
|
||||||
|
//!
|
||||||
|
//! 1. **A run's output is a support artifact.** `docs/installer/INSTALL.md` shows operators what a
|
||||||
|
//! run looks like, and the first thing anyone asks for in a bug report is a pasted log — so the
|
||||||
|
//! marks and the column layout here match the guide rather than being decided per call site.
|
||||||
|
//! 2. **Nothing here is a secret.** The auth token is printed by the handoff (Phase 2) straight to
|
||||||
|
//! the operator's terminal and never routed through a log file (PLAN.md §6).
|
||||||
|
|
||||||
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
|
/// Enables UTF-8 on the Windows console so the status marks below are not mojibake.
|
||||||
|
///
|
||||||
|
/// The guide's illustrated output uses `✓ ⚠ ✗`, and PowerShell 5.1 on a machine whose console code
|
||||||
|
/// page is still 437/1252 renders those as garbage. `SetConsoleOutputCP` is the one-call fix; it is
|
||||||
|
/// declared inline rather than pulling in a Windows binding crate for a single symbol, and a
|
||||||
|
/// failure is ignored because a wrongly-encoded tick is a cosmetic problem, not a reason to refuse
|
||||||
|
/// to install.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn init_console() {
|
||||||
|
extern "system" {
|
||||||
|
fn SetConsoleOutputCP(code_page: u32) -> i32;
|
||||||
|
}
|
||||||
|
const CP_UTF8: u32 = 65001;
|
||||||
|
unsafe {
|
||||||
|
SetConsoleOutputCP(CP_UTF8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn init_console() {}
|
||||||
|
|
||||||
|
pub fn ok(msg: &str) {
|
||||||
|
println!("✓ {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warn(msg: &str) {
|
||||||
|
println!("⚠ {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn heading(msg: &str) {
|
||||||
|
println!("\n{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A two-column row: ` label value`.
|
||||||
|
pub fn row(label: &str, value: &str) {
|
||||||
|
println!(" {label:<16} {value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks a yes/no question.
|
||||||
|
///
|
||||||
|
/// `assume_yes` (`--yes`) takes the default without asking, which is what makes an unattended run
|
||||||
|
/// expressible. A non-interactive run *without* `--yes` is an error rather than a silent default:
|
||||||
|
/// the questions this asks decide whether stock ServUO files get edited, and a pipe with no
|
||||||
|
/// terminal on the other end cannot consent to that.
|
||||||
|
pub fn confirm(question: &str, default: bool, assume_yes: bool) -> io::Result<bool> {
|
||||||
|
if assume_yes {
|
||||||
|
println!(
|
||||||
|
"{question} [{}] (--yes)",
|
||||||
|
if default { "Y/n" } else { "y/N" }
|
||||||
|
);
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"cannot ask \"{question}\" — stdin is not a terminal. \
|
||||||
|
Pass --yes to take the default, or the matching flag to answer it explicitly."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("{question} [{}] ", if default { "Y/n" } else { "y/N" });
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
// EOF (0 bytes) is not "yes". It means the operator is gone; take the default and move on.
|
||||||
|
if io::stdin().read_line(&mut line)? == 0 {
|
||||||
|
println!();
|
||||||
|
return Ok(default);
|
||||||
|
}
|
||||||
|
match line.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"" => return Ok(default),
|
||||||
|
"y" | "yes" => return Ok(true),
|
||||||
|
"n" | "no" => return Ok(false),
|
||||||
|
_ => println!(" please answer y or n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a line of text. An empty answer keeps `default` when one is offered.
|
||||||
|
pub fn prompt(question: &str, default: Option<&str>) -> io::Result<String> {
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"cannot ask \"{question}\" — stdin is not a terminal. Pass the matching flag."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match default {
|
||||||
|
Some(d) => print!("{question} [{d}]: "),
|
||||||
|
None => print!("{question}: "),
|
||||||
|
}
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
if io::stdin().read_line(&mut line)? == 0 {
|
||||||
|
println!();
|
||||||
|
return match default {
|
||||||
|
Some(d) => Ok(d.to_string()),
|
||||||
|
None => Err(io::Error::new(
|
||||||
|
io::ErrorKind::UnexpectedEof,
|
||||||
|
format!("no answer for \"{question}\""),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let answer = line.trim();
|
||||||
|
if !answer.is_empty() {
|
||||||
|
return Ok(answer.to_string());
|
||||||
|
}
|
||||||
|
if let Some(d) = default {
|
||||||
|
return Ok(d.to_string());
|
||||||
|
}
|
||||||
|
println!(" an answer is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/util.rs
Normal file
205
src/util.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//! Hashing and scratch-directory helpers.
|
||||||
|
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Lower-case hex, written out rather than taken from a crate.
|
||||||
|
///
|
||||||
|
/// Every hash this tool handles is compared against one produced by `sha256sum` or by `jq` in CI,
|
||||||
|
/// both of which emit lower-case hex — so the formatting is part of the contract, not a display
|
||||||
|
/// choice.
|
||||||
|
pub fn hex(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
out.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a
|
||||||
|
/// straight-line hash of the same bytes; the run itself only ever hashes files and streams.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn sha256_bytes(bytes: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
hex(&hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's
|
||||||
|
/// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs
|
||||||
|
/// once per deployed file.
|
||||||
|
pub fn sha256_file(path: &Path) -> Result<String> {
|
||||||
|
let mut file =
|
||||||
|
File::open(path).with_context(|| format!("cannot read {} to hash it", path.display()))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(hex(&hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`Write`] that hashes everything passing through it.
|
||||||
|
///
|
||||||
|
/// Downloads are verified *while* being written rather than by re-reading the finished file: it
|
||||||
|
/// halves the I/O and, more importantly, means the bytes that were hashed are provably the bytes
|
||||||
|
/// that were written.
|
||||||
|
pub struct HashingWriter<W: Write> {
|
||||||
|
inner: W,
|
||||||
|
hasher: Sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> HashingWriter<W> {
|
||||||
|
pub fn new(inner: W) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
hasher: Sha256::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(self) -> String {
|
||||||
|
hex(&self.hasher.finalize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> Write for HashingWriter<W> {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
let n = self.inner.write(buf)?;
|
||||||
|
self.hasher.update(&buf[..n]);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.inner.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scratch directory that deletes itself.
|
||||||
|
///
|
||||||
|
/// Downloads and the extracted overlay land here. Hand-rolled rather than pulled from a crate
|
||||||
|
/// because the requirement is one directory with a unique name and a `Drop` — and because a failed
|
||||||
|
/// cleanup must never fail the run: by the time it matters the install has already succeeded or
|
||||||
|
/// failed on its own merits.
|
||||||
|
pub struct TempDir {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
pub fn new(prefix: &str) -> Result<Self> {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||||
|
fs::create_dir_all(&path)
|
||||||
|
.with_context(|| format!("cannot create scratch directory {}", path.display()))?;
|
||||||
|
Ok(Self { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a file by writing a sibling `.tmp` and renaming over the target.
|
||||||
|
///
|
||||||
|
/// `install.json` is the record every later command reasons from; a half-written one after a
|
||||||
|
/// crash or a full disk would be worse than none at all, because `doctor` and `update` would
|
||||||
|
/// believe it.
|
||||||
|
pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
{
|
||||||
|
let mut file =
|
||||||
|
File::create(&tmp).with_context(|| format!("cannot create {}", tmp.display()))?;
|
||||||
|
file.write_all(contents)
|
||||||
|
.with_context(|| format!("cannot write {}", tmp.display()))?;
|
||||||
|
file.sync_all()
|
||||||
|
.with_context(|| format!("cannot flush {}", tmp.display()))?;
|
||||||
|
}
|
||||||
|
// Windows will not rename onto an existing file, so the old one goes first. The window this
|
||||||
|
// opens is the reason for the .tmp file existing at all: its content is already durable.
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(path).with_context(|| format!("cannot replace {}", path.display()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("cannot move {} into place", tmp.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The canonical empty-input SHA256. If this ever changes, everything else in the trust chain
|
||||||
|
// is meaningless, so it is worth one line.
|
||||||
|
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashing_matches_sha256sum() {
|
||||||
|
assert_eq!(sha256_bytes(b""), EMPTY);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_bytes(b"abc"),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_and_byte_hashing_agree() {
|
||||||
|
let dir = TempDir::new("rg-test-hash").unwrap();
|
||||||
|
let path = dir.path().join("f.bin");
|
||||||
|
// Larger than the 64 KiB read buffer, so the streaming path is actually exercised.
|
||||||
|
let blob: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||||
|
fs::write(&path, &blob).unwrap();
|
||||||
|
assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hashing_writer_sees_what_was_written() {
|
||||||
|
let mut w = HashingWriter::new(Vec::new());
|
||||||
|
w.write_all(b"abc").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
w.finish(),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_temp_dir_removes_itself() {
|
||||||
|
let path = {
|
||||||
|
let dir = TempDir::new("rg-test-drop").unwrap();
|
||||||
|
fs::write(dir.path().join("x"), b"x").unwrap();
|
||||||
|
dir.path().to_path_buf()
|
||||||
|
};
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_replaces_an_existing_file() {
|
||||||
|
let dir = TempDir::new("rg-test-atomic").unwrap();
|
||||||
|
let path = dir.path().join("nested").join("install.json");
|
||||||
|
write_atomic(&path, b"first").unwrap();
|
||||||
|
write_atomic(&path, b"second").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"second");
|
||||||
|
assert!(!path.with_extension("tmp").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user