diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index ad34fa1..dcd9678 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -7,12 +7,12 @@ # The one structural difference is the crate guard below. # # ── Crate guard ────────────────────────────────────────────────────────────── -# This repo is in the planning phase and has no Cargo project yet (the design of -# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather -# than leave the repo ungated until then — or land a workflow that red-Xes every -# governance/docs PR — the gates are conditional on a root Cargo.toml existing. -# Before the crate lands, the job reports green with a notice. The moment -# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change. +# The gates are conditional on a root Cargo.toml existing: before the crate +# landed, this job reported green with a notice so governance/docs PRs were not +# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on +# `edge`, so the gates arm themselves there automatically — and stay dormant on +# a `main` PR until the cutover merges the crate into it. Nothing here changes at +# that point either. # # The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar): # this repo's sole product is the one installer binary, so there is nothing to @@ -35,7 +35,12 @@ name: PR Checks on: pull_request: - branches: [main] + # `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that + # release.yml — which fires on every push to `main` — does not publish an installer binary that + # can deploy the overlay but not yet install the sidecar. Ungating the branch where all the + # work actually happens would leave the gates running only at the cutover, which is the one + # moment a red build is most expensive. + branches: [main, edge] # A newer push to the same PR cancels the in-flight run. concurrency: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bd85c6a..fdb3457 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,10 +21,14 @@ # • Artifact names follow docs/installer/PLAN.md §3. # # ── Crate guard ────────────────────────────────────────────────────────────── -# The repo is in the planning phase. With no Cargo.toml there is nothing to -# build, so the plan step forces RELEASE=false and the job exits green having -# done nothing. It starts cutting real releases the moment Phase 1 lands the -# crate — no edit required here. +# With no Cargo.toml at the repo root there is nothing to build, so the plan step +# forces RELEASE=false and the job exits green having done nothing. +# +# That guard is what makes the `edge` branch work. Phase 1 (installer core) and +# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this +# workflow keeps standing down — an installer binary that syncs the overlay but +# cannot install the sidecar is not something to publish to operators. The first +# release is cut by the `edge → main` cutover, with no edit required here. # # ── Unsigned releases ──────────────────────────────────────────────────────── # Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d6b5329 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..25a186a --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index b7a7aa3..89c280c 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,34 @@ never restarts the shard. ## Status -**Planning — no installer code exists yet.** +**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.** The design of record is [`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other repos (a `servuo-plugins` release workflow, a non-interactive config read-back in -`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful. +`link`, and the bundle-manifest CI here), all of which have landed — +[`bundles/current.json`](bundles/current.json) names the current protocol-checked +sidecar + overlay combination, recomposed on every component release and nightly +(see [`bundles/README.md`](bundles/README.md)). -All three Phase 0 prerequisites have now landed, so **what the installer will -install already exists and is published**, ahead of the binary that installs it: -[`bundles/current.json`](bundles/current.json) names the current -protocol-checked sidecar + overlay combination, recomposed on every component -release and nightly. See [`bundles/README.md`](bundles/README.md). +| Phase | State | +|---|---| +| 0 — prerequisites in the other repos | ✅ merged | +| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` | +| 2 — uo-link install + service registration | next | +| 3 — the opt-in stock-file patch tier | | +| 4 — `doctor`, `update`, `uninstall` | | -Besides that, this repo currently holds its governance documents and issue/PR -templates. +**Why `edge`:** `release.yml` publishes an installer binary on every push to +`main`, and a binary that deploys the overlay but cannot yet install the sidecar +is not something to hand an operator. Phases 1 and 2 land on `edge`; the +`edge → main` cutover cuts the first release. PRs into `edge` run the same gates +as PRs into `main`. + +Until then, the way to install is by hand — +[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand) +is the same deployment done with `curl`, `tar` and `systemctl`. ## Related repos @@ -82,13 +94,29 @@ templates. ## Build & run -Once the crate exists it will be a standard cargo project: +A standard cargo project, with the crate at the repo root: ```bash cargo build --release cargo run -- --help +cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothing +cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test ``` +`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway` +or `%ProgramData%\RunicGateway`), which is how a run is tested without root. + +Two layout notes that look odd until you know why: + +- **The library target is `rgdeploy`, not `runicgateway_installer`.** Windows' UAC + installer detection refuses to launch an unsigned executable whose file name + contains `install` (`os error 740`), and Cargo names test harnesses after their + target — so a target under that name makes `cargo test` unrunnable on Windows. + The published binary keeps its documented name; `[[bin]] test = false` keeps + Cargo from building a harness under it. Expect a UAC prompt when running the + built binary on Windows; it needs Administrator anyway. +- **`Cargo.lock` is committed**, and CI builds `--locked`. + See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the local checks CI will run, and the branch/PR workflow. diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..59ff818 --- /dev/null +++ b/src/bundle.rs @@ -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, +} + +#[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::>() + .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 ` 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 { + 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")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..29121b1 --- /dev/null +++ b/src/cli.rs @@ -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 { + 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 `: name the ServUO root instead of detecting or prompting. + pub servuo: Option, + /// `--bundle `: pin a published bundle instead of resolving the current one. + pub bundle: Option, + pub patches: PatchChoice, + /// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree. + pub patches_unsupported_servuo: bool, + /// `--host `: the hostname to print in the website URLs. + pub host: Option, + /// `--site-url `: the site's base URL, for the Admin → Shard link. + pub site_url: Option, + /// `--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 [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 install, doctor, update. The ServUO root, + instead of detecting or prompting for it. + --bundle 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 install. The hostname to print in the + website URLs. + --site-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>(args: I) -> Result { + let mut cli = Cli::default(); + let mut command: Option = 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>( + flag: &str, + inline: Option, + rest: &mut I, +) -> Result { + 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 { + 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); + } +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..fe06903 --- /dev/null +++ b/src/install.rs @@ -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 { + 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 ." + ); + } + + let answer = ui::prompt("Path to your ServUO root", None) + .context("a ServUO root is required; pass --servuo 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> { + 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 \ + --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()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3e641dc --- /dev/null +++ b/src/lib.rs @@ -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-.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}" + ); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..29259fa --- /dev/null +++ b/src/main.rs @@ -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()); +} diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..c407c3d --- /dev/null +++ b/src/net.rs @@ -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 { + 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()); + } +} diff --git a/src/overlay.rs b/src/overlay.rs new file mode 100644 index 0000000..375706a --- /dev/null +++ b/src/overlay.rs @@ -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, +} + +/// 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, +} + +#[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 { + 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 { + 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>, +) -> Result> { + 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 { + 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 { + 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| { + 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) -> 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"\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" hand edited \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"\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}"); + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..5105983 --- /dev/null +++ b/src/paths.rs @@ -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); + } +} diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..61ce773 --- /dev/null +++ b/src/record.rs @@ -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, + /// 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, + /// Phase 3 (applied patches, with the rung that applied each). Carried through untouched. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub patches: Vec, + /// Anything a newer installer wrote that this one has no name for. + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[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, +} + +#[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, +} + +#[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> { + 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> { + 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}"); + } +} diff --git a/src/servuo.rs b/src/servuo.rs new file mode 100644 index 0000000..ba5e2a8 --- /dev/null +++ b/src/servuo.rs @@ -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, +} + +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 { + 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 { + let mut starts: Vec = 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 { + 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 { + 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 { + 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 = 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 { + 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()); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b548b57 --- /dev/null +++ b/src/ui.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..8fde480 --- /dev/null +++ b/src/util.rs @@ -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 { + 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 { + inner: W, + hasher: Sha256, +} + +impl HashingWriter { + pub fn new(inner: W) -> Self { + Self { + inner, + hasher: Sha256::new(), + } + } + + pub fn finish(self) -> String { + hex(&self.hasher.finalize()) + } +} + +impl Write for HashingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + 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 { + 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 = (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()); + } +}