19 Commits
main ... edge

Author SHA1 Message Date
07a2cca5f6 Merge pull request 'feat(installer): back up what a run is about to overwrite' (#11) from feat/backup-before-overwrite into edge
Reviewed-on: #11
2026-08-05 17:53:43 +00:00
10bc2224a6 Merge pull request 'feat(installer): build for and install on linux-aarch64' (#10) from ci/installer-linux-aarch64 into edge
Reviewed-on: #10
2026-08-05 17:53:00 +00:00
461192a470 Merge pull request 'docs(readme): describe the tool that exists, not Phase 1' (#12) from docs/readme-and-install-refresh into edge
Reviewed-on: #12
2026-08-05 17:51:31 +00:00
c79374ff06 docs(readme): describe the tool that exists, not Phase 1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m0s
PLAN.md §5.4. The status section still announced Phase 1 as built and
Phase 2 as next, four phases later -- it is the first thing a visitor to
this repo reads, and it has been wrong since Phase 2 merged.

- The phase table now shows 1-4 built on `edge` and 5 in progress, and
  the opening says what the binary actually does.
- "What the cutover is waiting on" is stated, because "nothing is
  released yet" invites the question: Phase 5, and the Windows SCM half
  never having been executed anywhere.
- "Planned commands" is now "Commands". All four are implemented.
- RUNICGATEWAY_STATE_DIR was described as relocating install.json. Since
  Phase 2 it relocates everything the installer writes, including the
  sidecar binary, and suppresses service registration -- an out-of-date
  description of where a tool writes is worse than none.
- A design-constraint bullet for the backup behaviour Phase 5 adds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:52:27 -05:00
82900da939 feat(installer): back up what a run is about to overwrite
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
PLAN.md §5.3. Before anything is written, every file this run will
replace is copied into <state>/backups/<utc-stamp>/ with a manifest
naming where each came from. --no-backup opts out; --verify takes none.

Scoped by what cannot be fetched again. The sidecar binary and the
overlay files are re-downloadable and hash-named in the bundle, and the
database is a cache with a schema -- link's store.rs creates every table
IF NOT EXISTS over shard state the sweeps repopulate. What a run can
destroy for good is an operator's edits to a deployed .cs file, which
Phase 1 overwrites unconditionally and by design, and sidecar.toml,
whose token the website already holds.

Two deviations from §5.3 as written, both found by building it:

- The trigger is "this run is about to overwrite something", not "an
  update, or an install over an existing record". §5.3 justified the
  latter with "a first install overwrites nothing" -- which is not true
  of a tree deployed by hand per INSTALL.md Appendix A2, a documented
  path. There the first install finds .cs files that differ, plans them
  as Change, and overwrites them with no record anywhere. The direct
  test covers that case and still writes nothing for a genuine first
  install, because there is nothing to copy.
- sidecar.toml joins a backup that is already being taken and is never
  the reason for one. Nothing here rewrites it, so making it a trigger
  would put a dated directory on disk after every no-op update; it is
  copied so a restored set of files comes with the token that matches
  them.

The directory is created lazily and the manifest is written last, so a
directory carrying one is a complete backup -- and pruning only
considers those, so a run interrupted mid-copy cannot evict a good
backup by being newer than it. Three are kept. uninstall keeps them and
names them in its report; --purge removes them, alongside the config,
the database and the cached patch set. doctor reports the newest.

Restoring stays printed rather than done, as the uninstall report is:
the installer cannot know what has changed since, and putting an old
.cs file back over a newer overlay eats work rather than saving it.

Verified live against two scratch ServUO trees built from the real 57.4
files: a clean first install leaving no backups directory at all, an
update after editing a deployed .cs (copy holds the edit, tree gets the
release's file, manifest lists both it and sidecar.toml), a no-op update
taking none, --no-backup and --verify each taking none, a fourth backup
pruning the oldest, doctor's row, uninstall keeping three and listing
them, --purge removing them, and a --patches run capturing the
pre-patch Logging.cs while the two rung-0 patches correctly captured
nothing. fmt, clippy -D warnings and 144 tests on both Linux and
Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:47:25 -05:00
6941925fa5 feat(installer): build for and install on linux-aarch64
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
Step 4 of PLAN.md §5.2, and the half that faces the operator: the
release now cross-compiles aarch64-unknown-linux-gnu, and platform_key()
resolves ("linux","aarch64") to the bundle key link publishes under
instead of refusing the host by name.

Same toolchain shape as the Windows step -- a linker plus a CC/AR pair,
because ring (under ureq's rustls) compiles C and assembly. And the same
packaging trap named in the sums comment: an artifact missing from
SHA256SUMS is one `sha256sum -c` passes over silently, so the new binary
is added to both the sums and the upload list.

Two test changes fall out of the asset map growing a key:

- The exact `assets.len() == 2` assertion is replaced by a check that
  each key CI requires is present and well-formed. An exact count would
  fail on the first bundle that adds arm64 -- reporting correct
  behaviour as a regression.
- The host-binary lookup now accepts either outcome, and says why.
  Bundles are kept unchanged forever so `--bundle` stays reproducible,
  which means one published before arm64 existed can never gain that
  key. On such a host the run must fail with the reason rather than
  something that reads like a corrupt document, so sidecar_asset()'s
  error now says so and the test asserts it.

Verified by cross-building this crate for aarch64 in a
rust:1-slim-bookworm container -- ELF 64-bit LSB pie executable, ARM
aarch64 -- and by running fmt, clippy -D warnings and the tests on both
Linux and the Windows host, since only half of service.rs compiles on
either.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:34:08 -05:00
14f65d50a0 Merge pull request 'fix(installer): keep user_created sticky across re-runs' (#8) from fix/service-user-created-sticky into edge
Reviewed-on: #8
2026-08-05 09:30:10 +00:00
060b8815cf fix(installer): keep user_created sticky across re-runs
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m2s
Found on the first real systemd host this installer has ever run on: a
container with systemd as PID 1, installing into /usr/bin, /etc and
/var/lib for real.

`service::prepare` answers "did THIS run create the service account",
which is false on every run after the first — by then the account exists.
Recording that verbatim made the field describe the run rather than the
state, with two consequences:

- `install.json` changed on an otherwise-identical second run, breaking
  the Phase 1 promise that a re-run with nothing new to do writes
  nothing.
- `uninstall` removes only an account it created, so after any second
  `install` it silently left behind the very user this tool had added.
  Reproduced before the fix: "left the runicgateway account alone — this
  installer did not create it", on a host where it plainly had.

The record now inherits `true` from a prior record naming the same
account, and only that account: inheriting across a rename would
authorize deleting a user this installer never made.

Not visible on Windows, where the SCM's virtual account is never created
by us and goes with the service — which is why three phases of Windows
smoke runs never showed it.

Verified after the fix on the same host: fresh install records
user_created true, an identical second run leaves install.json
byte-identical, and uninstall then removes the account, the unit, the
service and the binary — leaving sidecar.toml, the database and all 24
overlay files in the ServUO tree exactly where they were.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 04:00:29 -05:00
a0106e2c84 Merge pull request 'feat(installer): implement Phase 4 — doctor, update and uninstall' (#7) from feat/phase4-diagnostics-updates into edge
Reviewed-on: #7
2026-08-05 08:49:44 +00:00
80b1c0da24 feat(installer): implement Phase 4 — doctor, update and uninstall
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
Completes the command surface INSTALL.md §2 published before the binary
existed. With this, `edge` cuts a binary that does everything that guide
describes.

doctor (src/doctor.rs)
  Reads only. Every row is answered by asking the thing itself — the
  installed binary (--version, --print-config), the service manager, and
  the sidecar's /health — because the record says what `install` did,
  which is a different question from what is true now. --print-config is
  run ONLY when the config already exists: that flag provisions, and a
  diagnosis must not create the state it reports on. It is also run under
  the environment the service pins (UOLINK_DB_PATH), so the config and
  database it names are the ones the service opens, not the ones the
  binary would pick on its own.

  Exit 1 when any row failed, so a monitoring script can read it; a ⚠
  never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have
  not started it" and "it is running and the bridge is dead" are
  different problems and only the second is broken. Offline is a ⚠ too:
  a shard host with no route to Gitea is a supported way to run this.

  The patch row re-resolves each recorded patch against the tree from the
  cached .patch, so a core upgrade or a restored backup that silently
  removed the tier's edits is caught — nothing else here would notice.

update (src/update.rs, install.rs::Mode)
  The same pipeline as install, not a second one: PLAN.md describes it as
  "re-resolve the bundle, then move both components to it", which is what
  an install over an existing deployment already does. Writing it twice
  would give the sync rules and the protocol cross-checks two places to
  disagree. What differs is small and lives in Mode — a prior record is
  required, the tree comes from that record rather than detection, the
  patch tier's scope narrows, and the close is a diff instead of a
  handoff.

  The token is not reprinted: it has not changed and the website has it.
  A changed protocol number IS called out, because a stale value in
  Admin → Shard is answered with 409 and looks like the shard going
  offline.

  Tier scope: features an earlier run recorded are re-resolved without
  asking again (the record is the evidence of consent, including on an
  unsupported ServUO); anything new the release offers is named but not
  applied without --patches. A shard that declined stays declined.

uninstall (src/uninstall.rs, service::remove)
  Removes the binary, the service and install.json; prints the overlay
  files and the exact hunks, rendered from the cached patches with the
  rung each landed at. Files edited since deployment are flagged so
  nobody deletes their own work blind. The report is also written to a
  file in the working directory — it is the only thing still needed after
  the command exits, and it arrives at the end of the longest output this
  tool produces.

  Two deviations from PLAN.md §5, both deliberate:

  - The cached patch set and patches/originals/ SURVIVE. That table put
    them under "removed", but the report tells the operator to diff
    against those originals — advice the same command would have made
    impossible to follow. --purge removes them, with the config and the
    database.
  - --yes means yes here, not "take the default". The prompt defaults to
    no (destructive), but the operator typed the verb; reading --yes as
    "no" would leave an unattended uninstall unable to express itself,
    and a script that appears to succeed while removing nothing is the
    worse failure.

  Exit 1 if a step could not be carried out — everything else still was.

Verified on this machine against a scratch tree built from the real
ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay
file, an edited one and a reverted patch (all three found, exit 1), a
--verify update that wrote nothing, a real update that repaired all three
and left install.json byte-identical, uninstall with and without --purge,
a second uninstall, and doctor/update on a host with no record. Linux
fmt/clippy/tests run in Docker as well as the Windows host.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 02:57:57 -05:00
0043264482 Merge pull request 'feat(installer): implement Phase 3 — the patch tier' (#6) from feat/phase3-patch-tier into edge
Reviewed-on: #6
2026-08-05 01:08:06 +00:00
265911a58f docs(installer): drop phase references that are now this build's behaviour
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m8s
Five comments described the patch tier as work a later phase would do. It is
this phase, so they read as stale the moment the code landed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 20:02:52 -05:00
02c5ad9839 fix(installer): satisfy clippy's unnecessary_sort_by on the CI toolchain
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
The two descending sorts in the applier used an explicit comparator. CI runs
clippy 1.97, where `unnecessary_sort_by` flags that and `-D warnings` turns it
into a build failure; the local toolchain here is 1.94, which does not have the
lint. `sort_by_key` with `Reverse` says the same thing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 20:01:37 -05:00
52d330167b feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they
depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the
unsupported-version path of §2.2.2, and the record and cache Phase 4 will read.

Three decisions were not settled by the plan:

* The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply
  verbatim with git apply", but §1 chose the release tarball specifically so
  there would be no git on the shard host, and rung 2 needs a native applier
  regardless. Rung 1 keeps its distinct, stronger verdict — the whole file
  reproduced the diff's `index` pre-image, computed as a git blob SHA1 in
  process — while the write goes through the same code path as rung 2. On the
  real trees here that is not academic: the shipped .patch files are CRLF in a
  Windows checkout and two of their three targets are LF, so `git apply`
  refuses patches this applies correctly.

* Per-patch metadata is declared by the release, with a built-in fallback.
  Which patches form one all-or-nothing unit, which companion .cs follows
  which, whether a CORE rebuild is needed and what declining costs are not
  derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in
  the current bundle and declares nothing, so a built-in copy stands in for it.
  A checked-in fixture of the release workflow's own jq output asserts the two
  descriptions are identical, so the repos cannot drift quietly.

* Pre-images are cached in the state directory. The tier edits files the
  operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here
  are the hunks we added" into a revert anyone can verify — kept out of the
  ServUO tree, which uninstall has promised never to clean up.

Everything else follows §2.2.1: exact matching with only line-ending and
trailing-whitespace normalization, exactly one occurrence or it fails,
all-or-nothing per patch file and again per feature, and a byte-preserving
splice so nothing outside a hunk can be reformatted.

Verified against the ServUO 57.4 tree on this machine across four scratch
roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the
real EventSink.cs, its blob matching the patch's declared pre-image), a
mixed-rung feature, a tree with edits inside two patched regions (rung 3 —
nothing written, nothing held back applied, no companions copied), and a
non-57.4 tree both with and without the extra consent flag. Three consecutive
runs left install.json byte-identical and the cached pre-image still pre-patch.

Three reporting defects the live runs caught are fixed with tests: a dry run
and a held-back patch both claimed to be "applied", the core-rebuild warning
fired when nothing had been written and named a Scripts file as core, and a
declined tier announced the loss of features install.json showed as applied.
Refused patches are now cached too, since the refusal message names that path.

Refs: docs/installer/PLAN.md §2.2, §5 Phase 3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:54:36 -05:00
7122bc6911 Merge pull request 'feat(installer): implement Phase 2 — uo-link install and service' (#5) from feat/phase2-sidecar-service into edge
Reviewed-on: #5
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 21:02:00 +00:00
7bfb033957 fix(installer): make the Linux half of Phase 2 compile
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m56s
Three faults in code that only compiles under cfg(unix), none of which the
Windows build could see:

- `run(...).map(...) == Ok(true)` compared two `Result<_, anyhow::Error>`
  values, and anyhow::Error is not PartialEq. Replaced with `is_ok_and`.
- `command_line` is used only by the Windows registration path, so importing it
  unconditionally is an unused-import error under `-D warnings`. Qualified at
  its call site instead.
- A cfg(not(windows)) assertion block had ended up in the wrong test, leaving it
  referencing a binding from its original one.

Caught by running the same gates the CI runner does inside a rust:1-slim
container against this working tree — fmt, clippy --all-targets -D warnings, and
cargo test --locked all pass there now, as they do on Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:52:02 -05:00
2228e0848b feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan already specified; Windows gets a
  virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`,
  which the SCM creates itself and which has no password. Plain `sc create` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--verify` runs no part of the sidecar half. `--print-config` provisions: it
  writes the config and mints a token, so a dry run that called it would create
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 1 read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the
  website.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:40:56 -05:00
a4f6b7b756 Merge pull request 'feat(installer): implement Phase 1 — the installer core' (#4) from feat/phase1-installer-core into edge
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 20:02:35 +00:00
dff4ad41c9 feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
Adds the Rust crate at the repo root and implements `install` end to end for
the overlay half of a deployment: resolve the published bundle, find and
validate the ServUO root, refuse to deploy under a running shard, sync the
plugin overlay, and record what was deployed in install.json.

`doctor`, `update` and `uninstall` parse and answer with the phase they arrive
in rather than "unrecognized command", and the run states plainly that the
uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed —
`--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet
completion would be read as a patched shard.

Landing on `edge` rather than `main`: release.yml publishes a binary on every
push to main, and an installer that deploys the overlay but cannot install the
sidecar is not something to hand an operator. pr-checks.yml now gates PRs into
edge on the same rules, so the branch the work happens on is not the ungated
one.

Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1:

- The code lives in a library called `rgdeploy` with a thin binary that keeps
  the published name. Windows' UAC installer detection refuses to launch an
  unsigned executable whose file name contains "install" (os error 740), and
  Cargo names test harnesses after their target — so a target under that name
  makes `cargo test` unrunnable on Windows.
- The running-shard check matches processes by path, not by process name:
  on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and
  a name match would report "not running" for a shard that is running.
- install.json records a state (`deployed` / `kept-operator-modified`), not the
  run's verb, so an unchanged re-run produces an identical record and writes
  nothing.
- The Bridge.cfg keep rule compares against the hash the installer last
  deployed, not the last hash it saw — otherwise a kept file is overwritten on
  the very next run.
- Downloads are verified against the bundle's SHA256 while being written, then
  every extracted file is re-hashed against the release's own manifest.json,
  whose protocol and version are cross-checked against the bundle.

Verified against a real ServUO 57.4 tree and end to end into a scratch tree:
24 files deployed, an unchanged re-run that writes nothing, an edited
Bridge.cfg kept across repeated runs while code files are overwritten, bundle
pinning, and a refusal with a shard running out of the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 14:58:17 -05:00
31 changed files with 11988 additions and 25 deletions

View File

@@ -7,12 +7,12 @@
# The one structural difference is the crate guard below.
#
# ── Crate guard ──────────────────────────────────────────────────────────────
# This repo is in the planning phase and has no Cargo project yet (the design of
# record is docs/installer/PLAN.md; Phase 1 is what creates the crate). Rather
# than leave the repo ungated until then — or land a workflow that red-Xes every
# governance/docs PR — the gates are conditional on a root Cargo.toml existing.
# Before the crate lands, the job reports green with a notice. The moment
# Phase 1 adds Cargo.toml the gates arm themselves; nothing here has to change.
# The gates are conditional on a root Cargo.toml existing: before the crate
# landed, this job reported green with a notice so governance/docs PRs were not
# red-Xed by a workflow with nothing to build. Phase 1 has now added the crate on
# `edge`, so the gates arm themselves there automatically — and stay dormant on
# a `main` PR until the cutover merges the crate into it. Nothing here changes at
# that point either.
#
# The crate is expected at the REPO ROOT (not a subdirectory like link/sidecar):
# this repo's sole product is the one installer binary, so there is nothing to
@@ -35,7 +35,12 @@ name: PR Checks
on:
pull_request:
branches: [main]
# `edge` is gated as well as `main`. Phase 1 and 2 land there rather than on `main` so that
# release.yml — which fires on every push to `main` — does not publish an installer binary that
# can deploy the overlay but not yet install the sidecar. Ungating the branch where all the
# work actually happens would leave the gates running only at the cutover, which is the one
# moment a red build is most expensive.
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:

View File

@@ -21,10 +21,14 @@
# • Artifact names follow docs/installer/PLAN.md §3.
#
# ── Crate guard ──────────────────────────────────────────────────────────────
# The repo is in the planning phase. With no Cargo.toml there is nothing to
# build, so the plan step forces RELEASE=false and the job exits green having
# done nothing. It starts cutting real releases the moment Phase 1 lands the
# crate — no edit required here.
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
# forces RELEASE=false and the job exits green having done nothing.
#
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
# workflow keeps standing down — an installer binary that syncs the overlay but
# cannot install the sidecar is not something to publish to operators. The first
# release is cut by the `edge → main` cutover, with no edit required here.
#
# ── Unsigned releases ────────────────────────────────────────────────────────
# Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is
@@ -62,6 +66,9 @@ env:
BIN: runicgateway-installer
LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-gnu
# The installer has to run wherever the sidecar it installs can run, and link
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
ARM64_TARGET: aarch64-unknown-linux-gnu
jobs:
release:
@@ -233,7 +240,7 @@ jobs:
echo "Release credentials present."
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker
- name: Install Rust toolchain, cross targets, and their linkers
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
@@ -250,6 +257,7 @@ jobs:
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -295,8 +303,12 @@ jobs:
run: |
set -euo pipefail
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
# Every artifact must be listed: `sha256sum -c` passes silently over a
# file the sums do not mention, and an operator verifying a download
# would get a pass on a binary nobody vouched for.
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
@@ -361,7 +373,7 @@ jobs:
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null

953
Cargo.lock generated Normal file
View File

@@ -0,0 +1,953 @@
# 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",
"sha1",
"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 = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[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"

66
Cargo.toml Normal file
View File

@@ -0,0 +1,66 @@
[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"
# SHA1 is here for one reason only: a patch's `index <old>..<new>` line carries
# git blob hashes, and reproducing one is how the patch tier answers rung 1 —
# "is this whole file still stock?" (PLAN.md §2.2.1). It is never used as a
# security primitive. Computing it natively is what keeps `git` off the shard
# host, which is the whole point of shipping the plugin as a release tarball.
sha1 = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# RFC 3339 timestamps for install.json. Same feature set link's sidecar uses.
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
# Refusing to deploy under a running shard is a correctness requirement, not a
# courtesy: ServUO holds Scripts.dll open and rewrites Saves/ on exit. Only the
# `system` feature is wanted — disks, networks and users are not our business.
sysinfo = { version = "0.38", default-features = false, features = ["system"] }
# Error plumbing, as in link's sidecar. Every failure here is read by an operator
# rather than matched on, so a chain of `.context()` strings is the whole
# requirement — the value is that "failed to write install.json" arrives with the
# path and the OS error attached instead of alone.
anyhow = "1"
[profile.release]
opt-level = 2

View File

@@ -27,22 +27,52 @@ never restarts the shard.
## Status
**Planning — no installer code exists yet.**
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
The binary does everything
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
describes: bundle resolution, ServUO detection and validation, the overlay sync,
the opt-in patch tier, `install.json`, the uo-link sidecar and its service, the
token handoff, and `doctor` / `update` / `uninstall`.
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 | ✅ on `edge` |
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
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`, so nothing lands there until the whole tool is worth handing to an
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
the same gates as PRs into `main`.
**What the cutover is waiting on**, per PLAN.md §5:
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
than after it, because it changes the release layout, and shipping first would
mean a first release immediately superseded by the next. There is no `.deb`
and no MSI: both would give the sidecar binary, its service unit and its
service account a second owner beside this tool.
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
service account, the failure actions and the token-file ACL have never been
executed anywhere. Running the *systemd* half for real is what turned up a bug
no unit test had, so this is not a formality.
Until the cutover, 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
@@ -54,7 +84,7 @@ templates.
| [RunicGateway/website](https://gitea.whitlocktech.com/RunicGateway/website) | The public site and admin panel. The installer never contacts it — it prints values for Admin → Shard. |
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. |
## Planned commands
## Commands
| Command | What it does |
|---|---|
@@ -78,17 +108,41 @@ templates.
- **A successful copy is not a working bridge.** ServUO ignores the script build's
exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify
post-boot state rather than trusting a clean boot.
- **What a run overwrites is copied first.** Every `.cs` file the overlay owns is
replaced unconditionally, so an operator's edit to one is saved under
`backups/<timestamp>/` before it goes. Restoring is theirs to do — this tool
will not put an old file back over a newer release.
- **The audience is public** — any ServUO operator, not only shards we run.
## Build & run
Once the crate exists it will be a standard cargo project:
A standard cargo project, with the crate at the repo root:
```bash
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 **everything the installer writes** — state,
data, and the sidecar binary (normally `/etc/runicgateway`, `/var/lib/runicgateway`
and `/usr/bin`, or `%ProgramData%\RunicGateway` and `%ProgramFiles%\RunicGateway`).
It also suppresses service registration, since there is no such thing as a
relocated systemd unit or Windows service. That is how a full 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.

485
src/backup.rs Normal file
View File

@@ -0,0 +1,485 @@
//! Copies of what a run is about to overwrite (PLAN.md §5.3).
//!
//! ## Scoped by what cannot be fetched again
//!
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
//! free: it would bury the two things that matter.
//!
//! What a run can destroy irrecoverably is short:
//!
//! 1. **The operator's own edits to a file the overlay owns.** `Bridge.cfg` is deliberately kept
//! (PLAN.md §5 Phase 1), but every `.cs` file and `Scripts.csproj` is overwritten
//! *unconditionally and by design* — so the one place this tool knowingly discards work is the
//! one place it should keep a copy first.
//! 2. **A stock ServUO file the patch tier edits.** `patches/originals/` already holds the
//! pre-*tier* copy and is never overwritten, which is the right revert target; it is not a
//! record of what the file looked like *this morning*, after the operator's own later edits.
//! 3. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's
//! saved configuration starts answering `401` with nothing on the sidecar to explain why.
//!
//! ## What decides whether a backup happens
//!
//! **Whether this run is about to overwrite something** — not which verb was typed and not whether
//! a prior record exists. PLAN.md §5.3 framed it as "`update`, and `install` over an existing
//! record", on the reasoning that a first install overwrites nothing. That reasoning does not
//! survive contact with `INSTALL.md` Appendix A2, which documents deploying the overlay **by hand**:
//! a first `install` over such a tree finds `.cs` files that differ, plans them as `Change`, and
//! overwrites them with no record anywhere of what was there. So the test is the direct one, and a
//! genuine first install onto a clean tree still writes nothing because there is nothing to copy.
//!
//! ## Restoring is printed, not done
//!
//! Same rule as the uninstall report, and for the same reason: the installer cannot know what has
//! changed since, and a restore that puts an old `.cs` file back over a newer overlay eats work
//! rather than saving it. The path and the manifest are what this module hands over.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::paths::Layout;
use crate::util;
/// How many backup directories survive. Older ones are pruned as new ones are written.
///
/// An unbounded directory of ServUO source copies on a shard host is its own support problem, and
/// the value of an old backup falls off a cliff: what an operator reaches for is "before this
/// upgrade", occasionally "before the one before". Three is that, plus one.
pub const KEEP: usize = 3;
/// The `schema` written into `manifest.json`, so a future reader can tell shapes apart.
const SCHEMA: u32 = 1;
/// Why a file was copied. Recorded per entry, because "what did this upgrade touch" is answered
/// very differently by an overlay file and by a stock ServUO file the patch tier edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
/// An overlay file whose on-disk content the sync is about to replace.
OverlayChange,
/// A stock ServUO file the patch tier is about to edit.
PatchTarget,
/// A companion `.cs` the tier copies in, which already existed in the tree.
PatchCompanion,
/// `sidecar.toml` — the token the website holds.
SidecarConfig,
}
impl Reason {
fn as_str(self) -> &'static str {
match self {
Self::OverlayChange => "overlay-change",
Self::PatchTarget => "patch-target",
Self::PatchCompanion => "patch-companion",
Self::SidecarConfig => "sidecar-config",
}
}
/// Which sub-directory of the backup the copy lands under.
///
/// The two roots are kept apart because a path is only meaningful relative to one of them, and
/// without the split a state file could collide with a tree file of the same name.
fn root_dir(self) -> &'static str {
match self {
Self::SidecarConfig => "state",
_ => "servuo",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
pub schema: u32,
/// RFC 3339, when the backup was taken.
pub taken: String,
/// `install` or `update` — the verb that displaced these files.
pub command: String,
pub installer: String,
/// The bundle in the record before this run, when there was one.
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_from: Option<String>,
/// The bundle this run is moving to.
pub bundle_to: String,
pub servuo_root: String,
pub files: Vec<Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Entry {
/// Where the copy sits inside the backup directory, `/`-separated.
pub path: String,
/// Where it was copied from, absolute, as it was on this host.
pub source: String,
pub sha256: String,
pub reason: String,
}
/// One run's backup. Created up front and handed to each stage that writes.
///
/// **The directory is created lazily, on the first capture.** A run that overwrites nothing must
/// leave nothing behind — an empty dated directory per run would be indistinguishable from a
/// backup that failed to record anything, and would push real ones out of the retention window.
pub struct Session {
dir: PathBuf,
enabled: bool,
started: bool,
root: PathBuf,
layout: Layout,
command: &'static str,
bundle_from: Option<String>,
bundle_to: String,
taken: String,
entries: Vec<Entry>,
}
impl Session {
/// `enabled` is false for `--verify` (a dry run must not create state, the same rule that keeps
/// it away from `--print-config`) and for `--no-backup`.
pub fn new(
layout: &Layout,
root: &Path,
command: &'static str,
bundle_from: Option<String>,
bundle_to: String,
enabled: bool,
) -> Self {
let taken = chrono::Utc::now();
Self {
// Colons are not legal in a Windows path component, so the stamp is the basic ISO 8601
// form. It still sorts lexicographically, which is what the pruning relies on.
dir: layout
.backups_dir()
.join(taken.format("%Y%m%dT%H%M%SZ").to_string()),
enabled,
started: false,
root: root.to_path_buf(),
layout: layout.clone(),
command,
bundle_from,
bundle_to,
taken: taken.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
entries: Vec::new(),
}
}
/// True once something has actually been copied.
pub fn has_entries(&self) -> bool {
!self.entries.is_empty()
}
/// Copies `source` into this backup, if it exists and backups are enabled.
///
/// A file that does not exist is not an error and not an entry: the caller asks for anything it
/// *may* be about to overwrite, and "there was nothing there" is the common answer on a first
/// install.
pub fn capture(&mut self, source: &Path, reason: Reason) -> Result<()> {
if !self.enabled || !source.exists() {
return Ok(());
}
let rel = self.relative_to_root(source, reason);
let dest = self.dir.join(reason.root_dir()).join(&rel);
if dest.exists() {
// Two stages can name the same file — a patch target that is also a companion path, or
// a re-entrant caller. First copy wins: it is the one taken furthest from any write.
return Ok(());
}
if !self.started {
fs::create_dir_all(&self.dir).with_context(|| {
format!("cannot create the backup directory {}", self.dir.display())
})?;
self.started = true;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
fs::copy(source, &dest).with_context(|| {
format!(
"cannot back up {} before overwriting it. Re-run with --no-backup to proceed \
without a copy",
source.display()
)
})?;
self.entries.push(Entry {
path: format!(
"{}/{}",
reason.root_dir(),
rel.replace(std::path::MAIN_SEPARATOR, "/")
),
source: source.display().to_string(),
sha256: util::sha256_file(source)?,
reason: reason.as_str().to_string(),
});
Ok(())
}
/// Writes `manifest.json` and prunes older backups. Returns the directory when one was written.
///
/// The manifest is written **last**, so a directory carrying one is a complete backup. Pruning
/// only considers directories that have one, for the same reason: a run interrupted mid-copy
/// must not be able to evict a good backup by being newer than it.
pub fn finish(mut self) -> Result<Option<PathBuf>> {
if !self.started {
return Ok(None);
}
self.entries.sort_by(|a, b| a.path.cmp(&b.path));
let manifest = Manifest {
schema: SCHEMA,
taken: self.taken.clone(),
command: self.command.to_string(),
installer: env!("CARGO_PKG_VERSION").to_string(),
bundle_from: self.bundle_from.clone(),
bundle_to: self.bundle_to.clone(),
servuo_root: self.root.display().to_string(),
files: self.entries.clone(),
};
let body = serde_json::to_string_pretty(&manifest)? + "\n";
util::write_atomic(&manifest_path(&self.dir), body.as_bytes())
.context("cannot write the backup manifest")?;
prune(&self.layout, KEEP)?;
Ok(Some(self.dir.clone()))
}
/// The path a captured file takes inside the backup, relative to the root it belongs to.
fn relative_to_root(&self, source: &Path, reason: Reason) -> String {
let rel = match reason.root_dir() {
"servuo" => source.strip_prefix(&self.root).unwrap_or(source),
_ => source
.strip_prefix(&self.layout.state_dir)
.unwrap_or(source),
};
// An absolute path outside the root it was filed under would escape the backup directory
// when joined. Falling back to the file name keeps the copy inside; the manifest still
// records exactly where it came from.
if rel.is_absolute() || rel.as_os_str().is_empty() {
return source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "file".to_string());
}
rel.to_string_lossy().to_string()
}
}
fn manifest_path(dir: &Path) -> PathBuf {
dir.join("manifest.json")
}
/// Every complete backup on this host, newest first.
pub fn list(layout: &Layout) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = match fs::read_dir(layout.backups_dir()) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| manifest_path(p).is_file())
.collect(),
Err(_) => Vec::new(),
};
// The stamp is fixed-width and zero-padded, so lexicographic order is chronological order.
dirs.sort();
dirs.reverse();
dirs
}
/// Reads one backup's manifest.
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
let body = fs::read_to_string(manifest_path(dir))
.with_context(|| format!("cannot read {}", manifest_path(dir).display()))?;
serde_json::from_str(&body)
.with_context(|| format!("{} is not a backup manifest", manifest_path(dir).display()))
}
/// Removes all but the `keep` newest complete backups.
pub fn prune(layout: &Layout, keep: usize) -> Result<()> {
for old in list(layout).into_iter().skip(keep) {
fs::remove_dir_all(&old)
.with_context(|| format!("cannot remove the old backup {}", old.display()))?;
}
Ok(())
}
/// Deletes every backup. Reached only from `uninstall --purge`, alongside the config, the database
/// and the cached patch set — they are all the same kind of thing: the only offline record of what
/// was here before.
pub fn remove_all(layout: &Layout) -> Result<()> {
let dir = layout.backups_dir();
if dir.exists() {
fs::remove_dir_all(&dir).with_context(|| format!("cannot remove {}", dir.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn layout_in(root: &Path) -> Layout {
let mut layout = crate::paths::layout();
layout.state_dir = root.join("state");
layout
}
fn write(path: &Path, body: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
#[test]
fn a_run_that_overwrites_nothing_leaves_nothing_behind() {
let tmp = TempDir::new("backup-empty").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
// Nothing on disk to copy — the common first-install case.
session
.capture(
&root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
Reason::OverlayChange,
)
.unwrap();
assert!(session.finish().unwrap().is_none());
assert!(
!layout.backups_dir().exists(),
"an empty dated directory would be indistinguishable from a failed backup"
);
}
#[test]
fn a_captured_file_is_copied_verbatim_and_recorded() {
let tmp = TempDir::new("backup-capture").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
write(&source, "the operator's own edit\n");
write(&layout.sidecar_config(), "[web]\nauth_token = \"secret\"\n");
let mut session = Session::new(
&layout,
&root,
"update",
Some("2026.08.04".into()),
"2026.08.05".into(),
true,
);
session.capture(&source, Reason::OverlayChange).unwrap();
session
.capture(&layout.sidecar_config(), Reason::SidecarConfig)
.unwrap();
let dir = session.finish().unwrap().expect("a backup was taken");
let copy = dir.join("servuo/Scripts/Custom/Bridge/BridgeLink.cs");
assert_eq!(
fs::read_to_string(&copy).unwrap(),
"the operator's own edit\n"
);
assert!(dir.join("state/sidecar.toml").is_file());
let manifest = read_manifest(&dir).unwrap();
assert_eq!(manifest.command, "update");
assert_eq!(manifest.bundle_from.as_deref(), Some("2026.08.04"));
assert_eq!(manifest.files.len(), 2);
let overlay = manifest
.files
.iter()
.find(|f| f.reason == "overlay-change")
.unwrap();
// The path inside the backup is always `/`-separated, so a manifest written on Windows
// reads the same as one written on Linux.
assert_eq!(overlay.path, "servuo/Scripts/Custom/Bridge/BridgeLink.cs");
assert_eq!(overlay.sha256, util::sha256_file(&source).unwrap());
assert!(overlay.source.contains("BridgeLink.cs"));
}
#[test]
fn the_first_copy_of_a_file_wins() {
// Two stages can name the same path. The earlier capture is the one taken furthest from
// any write, so a later one must not overwrite it with content that has already changed.
let tmp = TempDir::new("backup-twice").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Server/EventSink.cs");
write(&source, "before\n");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
session.capture(&source, Reason::PatchTarget).unwrap();
write(&source, "after\n");
session.capture(&source, Reason::PatchTarget).unwrap();
let dir = session.finish().unwrap().unwrap();
assert_eq!(
fs::read_to_string(dir.join("servuo/Server/EventSink.cs")).unwrap(),
"before\n"
);
assert_eq!(read_manifest(&dir).unwrap().files.len(), 1);
}
#[test]
fn disabled_sessions_write_nothing() {
let tmp = TempDir::new("backup-off").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
write(&source, "content\n");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), false);
session.capture(&source, Reason::OverlayChange).unwrap();
assert!(session.finish().unwrap().is_none());
assert!(!layout.backups_dir().exists());
}
#[test]
fn pruning_keeps_the_newest_and_ignores_incomplete_directories() {
let tmp = TempDir::new("backup-prune").unwrap();
let layout = layout_in(tmp.path());
for stamp in ["20260801T000000Z", "20260802T000000Z", "20260803T000000Z"] {
write(
&layout.backups_dir().join(stamp).join("manifest.json"),
"{\"schema\":1}",
);
}
// A run interrupted before its manifest was written. It must neither be listed nor be able
// to evict a complete backup by being newer.
write(
&layout.backups_dir().join("20260804T000000Z/servuo/x.cs"),
"half a copy\n",
);
assert_eq!(list(&layout).len(), 3);
prune(&layout, 2).unwrap();
let kept: Vec<String> = list(&layout)
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(kept, vec!["20260803T000000Z", "20260802T000000Z"]);
assert!(
layout.backups_dir().join("20260804T000000Z").exists(),
"an incomplete directory is left for a human to look at, not silently deleted"
);
}
#[test]
fn purge_removes_every_backup() {
let tmp = TempDir::new("backup-purge").unwrap();
let layout = layout_in(tmp.path());
write(
&layout.backups_dir().join("20260801T000000Z/manifest.json"),
"{\"schema\":1}",
);
remove_all(&layout).unwrap();
assert!(!layout.backups_dir().exists());
// Removing what is not there is not an error: `uninstall --purge` runs on hosts that never
// took a backup.
remove_all(&layout).unwrap();
}
}

275
src/bundle.rs Normal file
View File

@@ -0,0 +1,275 @@
//! 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`, `linux-aarch64`, `windows-x86_64`) — link publishes a
/// binary per target and the installer runs on each, so a single hash could only ever describe
/// one of them. The set grows over time, so a bundle is not expected to carry every key this
/// binary knows about: an older one pinned with `--bundle` predates arm64 entirely.
pub assets: BTreeMap<String, Asset>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OverlayComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
pub servuo: ServUoCompat,
/// One tarball, platform-independent: the overlay is C# source that ServUO compiles at boot.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServUoCompat {
/// The oldest ServUO the *base* overlay is known good on. It only adds files.
pub min_version: String,
/// The single ServUO version the *patch tier* was written and verified against (§2.2).
pub patches_verified_against: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Asset {
pub name: String,
pub url: String,
pub sha256: String,
}
impl Bundle {
/// The bundle's own asset for the platform this binary is running on.
///
/// Installing the sidecar is Phase 2, but the lookup lives here so that a run on a platform the
/// bundle has no binary for fails while resolving — before anything has been written into a
/// ServUO tree — rather than after the overlay is already deployed.
pub fn sidecar_asset(&self) -> Result<&Asset> {
let key = platform_key()?;
self.link.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"bundle {} has no uo-link binary for {key} (it has: {}).\n\
Bundles published before uo-link built for this platform cannot gain one \
retroactively — they are kept unchanged so `--bundle` stays reproducible. \
Run without `--bundle` to take the current one.",
self.bundle,
self.link
.assets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
/// The platform key used by `link.assets`, matching the names the bundle CI assigns.
pub fn platform_key() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Ok("linux-x86_64"),
// Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the
// sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a
// Windows-on-arm box or a Mac.
("linux", "aarch64") => Ok("linux-aarch64"),
("windows", "x86_64") => Ok("windows-x86_64"),
// Naming the platforms that do exist 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, linux-aarch64 and windows-x86_64."
),
}
}
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
pub fn url_for(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
None => format!("{BUNDLE_BASE}/current.json"),
}
}
/// Fetches and validates a bundle.
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
let url = url_for(tag);
let body = crate::net::get_text(&url).with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
against {BUNDLE_BASE}/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
let bundle = parse(&body)?;
Ok((bundle, url))
}
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
pub fn parse(body: &str) -> Result<Bundle> {
let bundle: Bundle = serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?;
if bundle.schema != SUPPORTED_SCHEMA {
bail!(
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
Update the installer — the bundle format changed.",
bundle.bundle,
bundle.schema
);
}
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
// manifest that never went through the compose job.
if bundle.link.protocol != bundle.overlay.protocol || bundle.protocol != bundle.link.protocol {
bail!(
"bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, overlay {}. \
A mismatched pair is rejected by the sidecar with 409 rather than mis-parsed, so this \
is refused here.",
bundle.bundle,
bundle.protocol,
bundle.link.protocol,
bundle.overlay.protocol
);
}
if bundle.overlay.asset.url.is_empty() || bundle.overlay.asset.sha256.is_empty() {
bail!(
"bundle {} names an overlay asset with no URL or checksum",
bundle.bundle
);
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
const CURRENT: &str = include_str!("../bundles/current.json");
#[test]
fn the_published_bundle_parses() {
let bundle = parse(CURRENT).unwrap();
assert_eq!(bundle.schema, 1);
assert_eq!(bundle.bundle, "2026.08.04");
assert_eq!(bundle.protocol, 3);
assert_eq!(bundle.link.version, "1.1.0");
assert_eq!(bundle.overlay.version, "0.1.1");
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
}
#[test]
fn every_platform_the_bundle_names_is_well_formed() {
// A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release
// (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it
// rather than on anything being wrong.
let bundle = parse(CURRENT).unwrap();
for required in ["linux-x86_64", "windows-x86_64"] {
let asset = bundle
.link
.assets
.get(required)
.unwrap_or_else(|| panic!("bundle carries no {required} binary"));
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
}
#[test]
fn the_hosts_binary_either_resolves_or_says_why_not() {
// On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise
// fail an install after the overlay had already been deployed. On a host whose platform
// postdates the bundle (an arm64 box reading the first published one), it must fail with
// the reason, since every bundle is kept unchanged forever so `--bundle` stays
// reproducible and therefore cannot gain a key retroactively.
let bundle = parse(CURRENT).unwrap();
match bundle.sidecar_asset() {
Ok(asset) => {
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
Err(e) => {
let msg = e.to_string();
assert!(msg.contains(platform_key().unwrap()), "{msg}");
assert!(msg.contains("--bundle"), "{msg}");
}
}
}
#[test]
fn the_host_is_a_platform_the_components_are_built_for() {
// `cargo test` running at all means the host is one the crate compiles on, so a refusal
// here is a build target the release workflows have not caught up with.
let key = platform_key().unwrap();
assert!(
["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key),
"unexpected platform key {key}"
);
}
#[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"));
}
}

349
src/cli.rs Normal file
View File

@@ -0,0 +1,349 @@
//! 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
//! where a later phase implements it — a parser written once against the published contract cannot
//! drift from it, and a flag belonging to an unbuilt phase gets an explicit notice at the point
//! where it would have taken effect (see `install.rs`). The tri-state on `--patches` is the part
//! that carries weight: "not mentioned" has to stay distinguishable from "explicitly declined",
//! because only the first may prompt and only an explicit yes may edit a stock ServUO file.
//!
//! 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 built so far; the rest parse so that running them reports
/// which phase they arrive in rather than "unrecognized argument", which would read as a typo
/// rather than as an unfinished tool.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Install,
Doctor,
Update,
Uninstall,
}
impl Command {
fn parse(token: &str) -> Option<Self> {
match token {
"install" => Some(Self::Install),
"doctor" => Some(Self::Doctor),
"update" => Some(Self::Update),
"uninstall" => Some(Self::Uninstall),
_ => None,
}
}
}
impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Install => "install",
Self::Doctor => "doctor",
Self::Update => "update",
Self::Uninstall => "uninstall",
})
}
}
/// What the patch tier was told to do. Tri-state on purpose: "not mentioned" is a different input
/// from "explicitly declined", because only the first one may prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchChoice {
Ask,
Yes,
No,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Run(Command),
Help,
Version,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cli {
pub mode: Mode,
/// `--verify`: report every change that would be made, write nothing.
pub verify: bool,
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
pub servuo: Option<String>,
/// `--bundle <tag>`: pin a published bundle instead of resolving the current one.
pub bundle: Option<String>,
pub patches: PatchChoice,
/// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree.
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
/// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set
/// and every backup.
pub purge: bool,
/// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3).
pub no_backup: 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,
no_backup: false,
}
}
}
pub const USAGE: &str = "\
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
Usage: runicgateway-installer <COMMAND> [OPTIONS]
Commands:
install Deploy the plugin overlay, install the uo-link sidecar and its
service, record what was deployed, and print the values the
website needs.
doctor Diagnose an existing deployment end to end.
update Re-resolve the bundle and move both components to it.
uninstall Remove what the installer exclusively owns. Never edits the
ServUO tree — it prints what to remove there.
Options:
--verify install, update. Dry run: report every
change that would be made, write nothing.
--servuo <PATH> install, doctor, update. The ServUO root,
instead of detecting or prompting for it.
--bundle <TAG> install, update. Pin an exact published
bundle (e.g. 2026.08.04) instead of the
current one.
--patches / --no-patches install. Decide the patch tier without
being prompted. --patches never loosens
the region check.
--patches-unsupported-servuo install. Required IN ADDITION TO --patches
to run the patch tier on a ServUO that is
not 57.4. Unsupported and untested.
--host <NAME> install. The hostname to print in the
website URLs.
--site-url <URL> install. Your site's base URL, for the
Admin → Shard link.
--yes Assume the default answer to every prompt.
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall
--yes` is not an accident.
--no-backup install, update. Do not copy the files
this run is about to overwrite. They are
otherwise saved under <state>/backups/,
newest 3 kept.
--purge uninstall. Also delete sidecar.toml,
uo-link.db, the cached patch set and every
backup, all of which are otherwise kept.
-V, --version Print the installer version and exit.
-h, --help Print this help and exit.
Environment:
RUNICGATEWAY_STATE_DIR Relocates the installer's own state (install.json,
the cached patch set) away from /etc/runicgateway or
%ProgramData%\\RunicGateway. For testing a run
without root; an installed deployment should not
set it.
The installer never contacts your website, never deletes anything from your
ServUO tree, and never starts or stops your shard.
";
/// Parses arguments **without** the program name.
///
/// The error string is what the caller prints on stderr before exiting `2`.
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
let mut cli = Cli::default();
let mut command: Option<Command> = None;
let mut it = args.into_iter().peekable();
while let Some(arg) = it.next() {
// `--flag=value` is normalized here so each flag below is written once. Splitting on the
// first '=' only: a URL or a Windows path may legitimately contain more.
let (name, inline) = match arg.split_once('=') {
Some((n, v)) if n.starts_with("--") => (n.to_string(), Some(v.to_string())),
_ => (arg.clone(), None),
};
match name.as_str() {
"-h" | "--help" => {
cli.mode = Mode::Help;
return Ok(cli);
}
"-V" | "--version" => {
cli.mode = Mode::Version;
return Ok(cli);
}
"--verify" => cli.verify = true,
"--yes" | "-y" => cli.assume_yes = true,
"--purge" => cli.purge = true,
"--no-backup" => cli.no_backup = true,
"--patches" => cli.patches = PatchChoice::Yes,
"--no-patches" => cli.patches = PatchChoice::No,
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
"--servuo" => cli.servuo = Some(take_value(&name, inline, &mut it)?),
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
other if other.starts_with('-') => {
return Err(format!("unrecognized argument: {other}"))
}
other => match Command::parse(other) {
Some(c) if command.is_none() => command = Some(c),
// Two verbs is ambiguous, and picking the first would run something the operator
// did not ask for while looking like it worked.
Some(c) => return Err(format!("only one command may be given (saw {c} as well)")),
None => return Err(format!("unrecognized command: {other}")),
},
}
}
match command {
Some(c) => cli.mode = Mode::Run(c),
// No verb is not an error worth an exit code — it is someone typing the binary's name to
// see what it does.
None => cli.mode = Mode::Help,
}
Ok(cli)
}
/// Pulls a flag's value, from `--flag=value` or from the next token.
///
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
/// otherwise fall through to auto-detection and deploy into a directory nobody named.
fn take_value<I: Iterator<Item = String>>(
flag: &str,
inline: Option<String>,
rest: &mut I,
) -> Result<String, String> {
match inline {
Some(v) if v.is_empty() => Err(format!("{flag} requires a value")),
Some(v) => Ok(v),
None => rest
.next()
.ok_or_else(|| format!("{flag} requires a value")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(args: &[&str]) -> Result<Cli, String> {
parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn no_arguments_prints_help() {
assert_eq!(parse_str(&[]).unwrap().mode, Mode::Help);
}
#[test]
fn every_documented_command_parses() {
for (token, want) in [
("install", Command::Install),
("doctor", Command::Doctor),
("update", Command::Update),
("uninstall", Command::Uninstall),
] {
assert_eq!(parse_str(&[token]).unwrap().mode, Mode::Run(want));
}
}
#[test]
fn flags_accept_both_spellings() {
let spaced = parse_str(&["install", "--servuo", "/opt/ServUO"]).unwrap();
let equals = parse_str(&["install", "--servuo=/opt/ServUO"]).unwrap();
assert_eq!(spaced.servuo.as_deref(), Some("/opt/ServUO"));
assert_eq!(spaced, equals);
}
#[test]
fn a_value_containing_equals_survives() {
// Site URLs carry query strings and Windows paths carry drive colons; splitting on every
// '=' would truncate both.
let cli = parse_str(&["install", "--site-url=https://s.example/x?a=b=c"]).unwrap();
assert_eq!(cli.site_url.as_deref(), Some("https://s.example/x?a=b=c"));
}
#[test]
fn a_flag_without_its_value_is_an_error() {
for args in [
vec!["install", "--servuo"],
vec!["install", "--servuo="],
vec!["install", "--bundle"],
vec!["install", "--host"],
vec!["install", "--site-url"],
] {
assert!(parse_str(&args).is_err(), "{args:?} should be rejected");
}
}
#[test]
fn the_patch_tier_is_tri_state() {
// "Not mentioned" must stay distinguishable from "declined": only the first may prompt,
// and only an explicit --patches is consent.
assert_eq!(parse_str(&["install"]).unwrap().patches, PatchChoice::Ask);
assert_eq!(
parse_str(&["install", "--patches"]).unwrap().patches,
PatchChoice::Yes
);
assert_eq!(
parse_str(&["install", "--no-patches"]).unwrap().patches,
PatchChoice::No
);
}
#[test]
fn unsupported_servuo_consent_is_its_own_flag() {
// --patches alone is deliberately not enough on a non-57.4 tree (PLAN.md §2.2.2), so the
// two must not collapse into one another.
let cli = parse_str(&["install", "--patches", "--patches-unsupported-servuo"]).unwrap();
assert_eq!(cli.patches, PatchChoice::Yes);
assert!(cli.patches_unsupported_servuo);
assert!(
!parse_str(&["install", "--patches"])
.unwrap()
.patches_unsupported_servuo
);
}
#[test]
fn help_and_version_win_immediately() {
assert_eq!(
parse_str(&["install", "--help", "--bogus"]).unwrap().mode,
Mode::Help
);
assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version);
}
#[test]
fn unknown_tokens_are_rejected() {
// A typo'd flag must not start a run that is not the one that was asked for.
assert!(parse_str(&["install", "--verfiy"]).is_err());
assert!(parse_str(&["instal"]).is_err());
assert!(parse_str(&["install", "update"]).is_err());
}
#[test]
fn order_does_not_matter() {
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();
let b = parse_str(&["install", "--yes", "--verify"]).unwrap();
assert_eq!(a, b);
assert!(a.verify && a.assume_yes);
}
}

591
src/diff.rs Normal file
View File

@@ -0,0 +1,591 @@
//! Unified-diff parsing.
//!
//! The patch tier resolves a file through the rung ladder of `docs/installer/PLAN.md` §2.2.1, and
//! every rung is answered from the diff itself: a unified diff already carries the stock text of
//! each region it edits — the context lines plus the `-` lines **are** the pre-image, and the
//! context lines plus the `+` lines are the post-image. Nothing else has to be shipped alongside
//! the patch for the installer to know what it is looking for.
//!
//! Three properties of this parser are load-bearing rather than tidiness:
//!
//! - **Everything is bytes, never `String`.** The three ServUO files the tier edits are CRLF and
//! are not guaranteed to be UTF-8; a lossy decode would corrupt bytes on write-back, and a strict
//! one would refuse to patch a shard over a stray `0x92` in a comment. Line content is compared
//! after normalization but written back verbatim.
//! - **The `index` line is optional and its absence is not a defect.** `commandlogging-event.patch`
//! is a plain `---`/`+++` diff with no `diff --git` header at all, so rung 1 (whole-file
//! pre-image hash) is structurally unavailable for it. That is fine: rung 2 matches on content
//! and is the stronger guarantee anyway. A parser that required the header would have rejected a
//! patch we ship.
//! - **Hunk line counts are checked against the lines actually present.** A truncated or
//! hand-edited diff whose `@@` header promises more lines than it carries would otherwise
//! reconstruct a short pre-image, which is exactly the sort of thing that then matches somewhere
//! it should not.
use anyhow::{bail, Context, Result};
/// One line inside a hunk body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HunkLine {
/// ` ` — present on both sides.
Context(Vec<u8>),
/// `-` — present in the stock file only.
Removed(Vec<u8>),
/// `+` — present in the patched file only.
Added(Vec<u8>),
}
impl HunkLine {
fn content(&self) -> &[u8] {
match self {
Self::Context(b) | Self::Removed(b) | Self::Added(b) => b,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
/// 1-based line number in the stock file, from the `@@` header. **Advisory only** — the match
/// is made by content, since an insertion anywhere above shifts every number below it. It is
/// used to prefer the nearest candidate when reporting, and nowhere else.
pub old_start: usize,
pub old_count: usize,
pub new_start: usize,
pub new_count: usize,
pub lines: Vec<HunkLine>,
/// The stock file's last line has no trailing newline (`\ No newline at end of file` after a
/// `-` or context line).
pub old_no_newline: bool,
/// Likewise for the patched file.
pub new_no_newline: bool,
}
impl Hunk {
/// The stock text this hunk expects to find: context + removed, in order.
pub fn pre_image(&self) -> Vec<&[u8]> {
self.lines
.iter()
.filter(|l| !matches!(l, HunkLine::Added(_)))
.map(HunkLine::content)
.collect()
}
/// The text this hunk leaves behind: context + added, in order.
pub fn post_image(&self) -> Vec<&[u8]> {
self.lines
.iter()
.filter(|l| !matches!(l, HunkLine::Removed(_)))
.map(HunkLine::content)
.collect()
}
/// Whether this hunk changes anything. A hunk of pure context is a no-op and must not be
/// counted as applied work — nor searched for, since its pre- and post-images are identical
/// and rung 0 could never be distinguished from rung 2.
pub fn is_noop(&self) -> bool {
!self
.lines
.iter()
.any(|l| matches!(l, HunkLine::Added(_) | HunkLine::Removed(_)))
}
}
/// One file section of a patch. Our patches carry one each, but a diff may hold several and
/// silently applying the first would be a quiet way to half-patch a tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilePatch {
/// From `+++ b/<path>`, with the `b/` prefix stripped and separators left as `/`.
pub path: String,
/// The abbreviated blob hash of the stock file, from `index <old>..<new>`. `None` when the
/// diff has no `index` line, which makes rung 1 unavailable for this file — see the module
/// docs.
pub pre_blob: Option<String>,
pub post_blob: Option<String>,
pub hunks: Vec<Hunk>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
pub files: Vec<FilePatch>,
}
impl Patch {
/// The single file this patch edits.
///
/// The tier declares one target per patch (`tier.json`), so a diff that turned out to edit two
/// files would mean the declaration and the artifact disagree — and the installer would have
/// checked only one of them against the rung ladder before writing both.
pub fn single_file(&self) -> Result<&FilePatch> {
match self.files.as_slice() {
[only] => Ok(only),
[] => bail!("this patch contains no file sections"),
many => bail!(
"this patch edits {} files ({}); the tier declares one target per patch",
many.len(),
many.iter()
.map(|f| f.path.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
}
/// Splits a buffer into lines **without** their terminators, tolerating CRLF, LF and a final line
/// with no terminator at all.
///
/// A trailing newline does not produce a final empty line: `b"a\n"` is one line, matching how every
/// diff tool counts them.
pub fn split_lines(data: &[u8]) -> Vec<&[u8]> {
let mut out = Vec::new();
let mut start = 0usize;
for (i, b) in data.iter().enumerate() {
if *b == b'\n' {
let mut end = i;
if end > start && data[end - 1] == b'\r' {
end -= 1;
}
out.push(&data[start..end]);
start = i + 1;
}
}
if start < data.len() {
let mut end = data.len();
if end > start && data[end - 1] == b'\r' {
end -= 1;
}
out.push(&data[start..end]);
}
out
}
/// The comparison form of a line: trailing whitespace removed.
///
/// This is the *whole* of the licence PLAN.md §2.2.1 grants — line-ending and trailing-whitespace
/// normalization, nothing else. There is no fuzz and no context reduction: dropping context to
/// force a match is precisely how a hunk lands in the wrong method. Line endings are already gone
/// by the time this runs, since [`split_lines`] strips them.
pub fn normalize(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && (line[end - 1] == b' ' || line[end - 1] == b'\t' || line[end - 1] == b'\r') {
end -= 1;
}
&line[..end]
}
/// Parses a `.patch` file.
pub fn parse(data: &[u8]) -> Result<Patch> {
let lines = split_lines(data);
let mut files: Vec<FilePatch> = Vec::new();
let mut pending_blobs: Option<(String, String)> = None;
let mut i = 0usize;
while i < lines.len() {
let line = lines[i];
if line.starts_with(b"index ") {
// `index <old>..<new>[ <mode>]`. Abbreviated to 7+ hex characters by git, so rung 1
// compares by prefix rather than for equality.
pending_blobs = parse_index(line);
i += 1;
continue;
}
if line.starts_with(b"--- ") && i + 1 < lines.len() && lines[i + 1].starts_with(b"+++ ") {
let path = header_path(lines[i + 1], b"+++ ")
.or_else(|| header_path(lines[i], b"--- "))
.with_context(|| {
format!(
"cannot read the target path from the diff header at line {}",
i + 2
)
})?;
let (pre_blob, post_blob) = match pending_blobs.take() {
Some((a, b)) => (Some(a), Some(b)),
None => (None, None),
};
i += 2;
let mut hunks = Vec::new();
while i < lines.len() && lines[i].starts_with(b"@@") {
let (hunk, next) = parse_hunk(&lines, i)?;
hunks.push(hunk);
i = next;
}
if hunks.is_empty() {
bail!("the diff section for {path} contains no hunks");
}
files.push(FilePatch {
path,
pre_blob,
post_blob,
hunks,
});
continue;
}
// `diff --git`, `new file mode`, `similarity index`, a covering-letter preamble — anything
// outside a hunk body is skipped. Only `index` is worth keeping.
i += 1;
}
if files.is_empty() {
bail!("no unified-diff sections found — this file is not a patch");
}
Ok(Patch { files })
}
/// `--- a/Scripts/Commands/Logging.cs` → `Scripts/Commands/Logging.cs`.
///
/// The trailing tab-separated timestamp some tools append is dropped, and so is the one-letter
/// prefix directory git uses. `/dev/null` yields `None`, which makes a pure-creation diff fall back
/// to the other header rather than producing a file called `dev/null`.
fn header_path(line: &[u8], marker: &[u8]) -> Option<String> {
let rest = line.strip_prefix(marker)?;
let rest = match rest.iter().position(|b| *b == b'\t') {
Some(tab) => &rest[..tab],
None => rest,
};
let text = String::from_utf8_lossy(rest).trim().replace('\\', "/");
if text.is_empty() || text == "/dev/null" {
return None;
}
// git writes `a/` and `b/`; `-p1` semantics. A path with no prefix (`patch -p0` style) is left
// alone rather than having its first directory eaten.
for prefix in ["a/", "b/", "i/", "w/", "c/", "o/"] {
if let Some(stripped) = text.strip_prefix(prefix) {
return Some(stripped.to_string());
}
}
Some(text)
}
fn parse_index(line: &[u8]) -> Option<(String, String)> {
let rest = String::from_utf8_lossy(line.strip_prefix(b"index ")?).to_string();
let head = rest.split_whitespace().next()?;
let (old, new) = head.split_once("..")?;
let hex = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit());
if !hex(old) || !hex(new) {
return None;
}
Some((old.to_ascii_lowercase(), new.to_ascii_lowercase()))
}
/// Parses one hunk, starting at the `@@` header. Returns the hunk and the index of the line after
/// it.
fn parse_hunk(lines: &[&[u8]], start: usize) -> Result<(Hunk, usize)> {
let header = String::from_utf8_lossy(lines[start]).to_string();
let (old_start, old_count, new_start, new_count) = parse_hunk_header(&header)
.with_context(|| format!("cannot parse hunk header at line {}: {header}", start + 1))?;
let mut body = Vec::new();
let mut old_no_newline = false;
let mut new_no_newline = false;
let mut seen_old = 0usize;
let mut seen_new = 0usize;
let mut i = start + 1;
while i < lines.len() {
let line = lines[i];
// The marker describes the line *above* it, and which side it applies to depends on that
// line's kind: a `-` line means the stock file ended there, a `+` line the patched one, and
// a context line both.
if line.starts_with(b"\\ ") {
match body.last() {
Some(HunkLine::Removed(_)) => old_no_newline = true,
Some(HunkLine::Added(_)) => new_no_newline = true,
Some(HunkLine::Context(_)) => {
old_no_newline = true;
new_no_newline = true;
}
None => {}
}
i += 1;
continue;
}
if seen_old >= old_count && seen_new >= new_count {
break;
}
let (kind, rest) = match line.first() {
Some(b' ') => (0u8, &line[1..]),
Some(b'-') => (1, &line[1..]),
Some(b'+') => (2, &line[1..]),
// git emits a genuinely empty line for an empty context line rather than a lone space,
// and trailing whitespace is routinely stripped in transit. Treating it as context is
// what every patch tool does.
None => (0, line),
// Anything else ends the hunk — the next `@@`, `diff --git`, or trailing prose.
Some(_) => break,
};
match kind {
0 => {
seen_old += 1;
seen_new += 1;
body.push(HunkLine::Context(rest.to_vec()));
}
1 => {
seen_old += 1;
body.push(HunkLine::Removed(rest.to_vec()));
}
_ => {
seen_new += 1;
body.push(HunkLine::Added(rest.to_vec()));
}
}
i += 1;
}
// A header that promises more than the body delivers reconstructs a short pre-image, which is
// then liable to match a place the author never meant. Refusing is the only safe reading.
if seen_old != old_count || seen_new != new_count {
bail!(
"hunk at line {} declares -{old_start},{old_count} +{new_start},{new_count} but \
carries {seen_old} old and {seen_new} new lines — the patch is truncated or malformed",
start + 1
);
}
Ok((
Hunk {
old_start,
old_count,
new_start,
new_count,
lines: body,
old_no_newline,
new_no_newline,
},
i,
))
}
/// `@@ -75,16 +75,27 @@ optional section heading` → `(75, 16, 75, 27)`.
///
/// A count may be omitted, which means 1 (`@@ -75 +75,2 @@`), and a count of 0 is legal for a pure
/// insertion or deletion.
fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
let inner = header.strip_prefix("@@")?;
let end = inner.find("@@")?;
let mut parts = inner[..end].split_whitespace();
let old = parts.next()?.strip_prefix('-')?;
let new = parts.next()?.strip_prefix('+')?;
let range = |s: &str| -> Option<(usize, usize)> {
match s.split_once(',') {
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
None => Some((s.parse().ok()?, 1)),
}
};
let (old_start, old_count) = range(old)?;
let (new_start, new_count) = range(new)?;
Some((old_start, old_count, new_start, new_count))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(text: &str) -> Result<Patch> {
parse(text.as_bytes())
}
const SIMPLE: &str = "\
diff --git a/Scripts/Commands/Logging.cs b/Scripts/Commands/Logging.cs
index 5dd3f54..9ab1c22 100644
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -75,4 +75,6 @@ namespace Server.Commands
return o;
}
+ public static event Action<Mobile, string> OnWrite;
+
public static void WriteLine(Mobile from, string text)
";
#[test]
fn a_git_format_patch_parses_whole() {
let patch = parse_str(SIMPLE).unwrap();
let file = patch.single_file().unwrap();
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
assert_eq!(file.pre_blob.as_deref(), Some("5dd3f54"));
assert_eq!(file.post_blob.as_deref(), Some("9ab1c22"));
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!((hunk.old_start, hunk.old_count), (75, 4));
assert_eq!((hunk.new_start, hunk.new_count), (75, 6));
assert_eq!(hunk.pre_image().len(), 4);
assert_eq!(hunk.post_image().len(), 6);
assert!(!hunk.is_noop());
}
#[test]
fn a_plain_diff_without_a_git_header_parses_and_offers_no_blob() {
// commandlogging-event.patch is exactly this shape. Rung 1 is unavailable for it, which is
// a fact to report — not a parse error, and certainly not a reason to skip the patch.
let text = SIMPLE
.lines()
.filter(|l| !l.starts_with("diff --git") && !l.starts_with("index "))
.collect::<Vec<_>>()
.join("\n");
let patch = parse_str(&text).unwrap();
let file = patch.single_file().unwrap();
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
assert_eq!(file.pre_blob, None);
assert_eq!(file.hunks.len(), 1);
}
#[test]
fn a_crlf_patch_yields_the_same_lines_as_an_lf_one() {
// Every .patch this tier ships is CRLF in a Windows checkout and LF in the tarball CI
// builds. The two must parse identically or a patch would apply on one platform and not
// the other.
let crlf = SIMPLE.replace('\n', "\r\n");
assert_eq!(parse_str(&crlf).unwrap(), parse_str(SIMPLE).unwrap());
}
#[test]
fn pre_and_post_images_are_the_two_sides_of_the_hunk() {
let patch = parse_str(
"\
--- a/x
+++ b/x
@@ -1,3 +1,3 @@
keep
-old
+new
tail
",
)
.unwrap();
let hunk = &patch.single_file().unwrap().hunks[0];
assert_eq!(hunk.pre_image(), vec![&b"keep"[..], b"old", b"tail"]);
assert_eq!(hunk.post_image(), vec![&b"keep"[..], b"new", b"tail"]);
}
#[test]
fn the_no_newline_marker_attaches_to_the_side_it_describes() {
// Three cases, because the marker follows the line it describes and the side depends on
// that line's kind. Getting this wrong writes a spurious trailing newline into a file that
// never had one — a one-byte change that shows up in every future hash comparison.
let old_only = parse_str(
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n",
)
.unwrap();
let hunk = &old_only.single_file().unwrap().hunks[0];
assert!(hunk.old_no_newline && !hunk.new_no_newline);
let new_only = parse_str(
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n+new\n\\ No newline at end of file\n",
)
.unwrap();
let hunk = &new_only.single_file().unwrap().hunks[0];
assert!(!hunk.old_no_newline && hunk.new_no_newline);
let both = parse_str(
"--- a/x\n+++ b/x\n@@ -1,1 +1,2 @@\n+added\n same\n\\ No newline at end of file\n",
)
.unwrap();
let hunk = &both.single_file().unwrap().hunks[0];
assert!(hunk.old_no_newline && hunk.new_no_newline);
}
#[test]
fn several_hunks_and_several_files_are_all_kept() {
let patch = parse_str(
"\
diff --git a/one b/one
index aaaaaaa..bbbbbbb 100644
--- a/one
+++ b/one
@@ -1,1 +1,2 @@
a
+b
@@ -10,1 +11,2 @@
c
+d
diff --git a/two b/two
--- a/two
+++ b/two
@@ -5,1 +5,2 @@
e
+f
",
)
.unwrap();
assert_eq!(patch.files.len(), 2);
assert_eq!(patch.files[0].hunks.len(), 2);
assert_eq!(patch.files[1].hunks.len(), 1);
// The second file has no index line of its own and must not inherit the first's.
assert_eq!(patch.files[1].pre_blob, None);
// And a multi-file patch is refused where the tier expects one target, rather than being
// silently half-applied.
let err = patch.single_file().unwrap_err().to_string();
assert!(err.contains("edits 2 files"), "{err}");
}
#[test]
fn an_omitted_count_means_one() {
let patch = parse_str("--- a/x\n+++ b/x\n@@ -7 +7,2 @@\n a\n+b\n").unwrap();
let hunk = &patch.single_file().unwrap().hunks[0];
assert_eq!((hunk.old_start, hunk.old_count), (7, 1));
assert_eq!((hunk.new_start, hunk.new_count), (7, 2));
}
#[test]
fn a_truncated_hunk_is_rejected() {
// The header promises four old lines; two are present. Reconstructing the short pre-image
// and hunting for it is how a patch lands somewhere nobody intended.
let err = parse_str("--- a/x\n+++ b/x\n@@ -1,4 +1,4 @@\n a\n b\n")
.unwrap_err()
.to_string();
assert!(err.contains("truncated or malformed"), "{err}");
}
#[test]
fn a_file_that_is_not_a_patch_is_rejected() {
assert!(parse_str("# patches\n\nUnified diffs against stock ServUO.\n").is_err());
assert!(parse_str("").is_err());
}
#[test]
fn line_splitting_agrees_with_how_diffs_count_lines() {
assert_eq!(split_lines(b"a\nb\n"), vec![&b"a"[..], b"b"]);
assert_eq!(split_lines(b"a\r\nb"), vec![&b"a"[..], b"b"]);
assert_eq!(split_lines(b""), Vec::<&[u8]>::new());
assert_eq!(split_lines(b"\n"), vec![&b""[..]]);
assert_eq!(split_lines(b"a"), vec![&b"a"[..]]);
}
#[test]
fn normalization_covers_line_endings_and_trailing_space_and_nothing_else() {
assert_eq!(normalize(b"code \t"), b"code");
assert_eq!(normalize(b"code\r"), b"code");
// Leading indentation is content: two methods differing only in nesting are different
// places, and collapsing them is how an anchor becomes ambiguous.
assert_ne!(normalize(b" code"), normalize(b"code"));
assert_eq!(normalize(b" "), b"");
}
#[test]
fn a_pure_context_hunk_is_a_noop() {
let patch = parse_str("--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n b\n").unwrap();
assert!(patch.single_file().unwrap().hunks[0].is_noop());
}
#[test]
fn an_empty_context_line_written_without_its_space_is_still_context() {
// Mailers and editors strip the trailing space off a blank context line routinely, and
// every patch tool tolerates it. Both spellings must produce the same pre-image.
let padded = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n \n b\n+c\n").unwrap();
let bare = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n\n b\n+c\n").unwrap();
assert_eq!(
padded.single_file().unwrap().hunks[0].pre_image(),
bare.single_file().unwrap().hunks[0].pre_image()
);
}
}

916
src/doctor.rs Normal file
View File

@@ -0,0 +1,916 @@
//! The `doctor` command — diagnose an existing deployment end to end.
//!
//! PLAN.md §5 Phase 4 calls this "the command that makes the whole thing supportable", and the row
//! that carries the phase is the last one: **has a shard actually dialed in?** Everything else can
//! be true — files copied, service running, hashes matching — while the bridge does nothing at all,
//! because ServUO shells out to `dotnet build`, prints the output, ignores the exit code and reloads
//! the previous `Scripts.dll` (§2.1). A clean boot is not evidence. `plugin_connected` is.
//!
//! Three rules shape this module:
//!
//! - **It writes nothing, anywhere.** Not to the ServUO tree, not to `install.json`, not to the
//! sidecar's config. That is why `--print-config` is run only when the config file already
//! exists: that flag *provisions* (it writes the file and mints a token when absent), so calling
//! it on a host that has none would have `doctor` create the very state it is reporting on.
//! - **It asks the thing itself, not the record.** The installed binary answers `--version` and
//! `--print-config`; the service manager answers `is-active`; the sidecar answers `/health`. The
//! record says what `install` *did*, which is a different question from what is true now — and
//! the gap between those two is the whole reason to run this.
//! - **A missing answer is a row, not an exception.** A host with no route to Gitea, a sidecar that
//! is down, a config this process cannot read: each degrades to one honest line and the report
//! still prints. The exit code is what a monitoring script reads — `1` if any row failed — and it
//! is deliberately not raised by a `⚠`, which means "worth knowing", not "broken".
//!
//! The token is never printed here. `--print-config` returns it (it is one document), and this
//! module reads the paths, the bind and the protocol out of that document and drops the rest.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Result;
use serde::Deserialize;
use crate::cli::Cli;
use crate::record::{InstallRecord, LinkRecord};
use crate::servuo::ServUoRoot;
use crate::{bundle, net, patch, paths, servuo, sidecar, ui};
/// Both network calls give up quickly. Every row here is context around local state, so a host with
/// no route out must produce its report seconds later rather than appear to hang.
const BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
const HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
/// The verdict on one row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mark {
Ok,
Warn,
Fail,
}
impl Mark {
fn glyph(self) -> &'static str {
match self {
Self::Ok => "",
Self::Warn => "",
Self::Fail => "",
}
}
}
/// One line of the report, plus any detail lines that belong underneath it.
#[derive(Debug, Clone)]
pub struct Row {
pub mark: Mark,
pub label: String,
pub detail: String,
pub notes: Vec<String>,
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
detail: detail.into(),
notes: Vec::new(),
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
self.notes.extend(notes);
self
}
}
/// Runs every check and prints the report. The `i32` is the process exit code.
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.install_record();
println!(
"\nRunic Gateway installer {} — doctor",
env!("CARGO_PKG_VERSION")
);
let Some(record) = InstallRecord::load(&record_path)? else {
// Not an error in the `anyhow` sense — the command ran fine and the answer is "nothing is
// installed here". Exit 1 all the same, because a monitoring script asking after a
// deployment on this host has had its question answered in the negative.
println!();
ui::warn(&format!(
"No deployment is recorded on this host.\n \
Looked for {}\n \
Run `install` first. If you installed with {} set, set it again for this run.",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(1);
};
let mut rows = vec![Row::ok(
"Install record",
format!(
"{} (bundle {}, installer {}, {})",
record_path.display(),
record.bundle.tag,
record.installer.version,
record.updated
),
)];
// ── ServUO and the overlay ───────────────────────────────────────────────
let root = open_root(cli, &record);
rows.push(servuo_row(&record, root.as_ref()));
rows.push(overlay_row(&record, root.as_ref()));
rows.push(patch_row(&record, root.as_ref(), &layout));
// ── The sidecar ──────────────────────────────────────────────────────────
// Asking the installed binary is what makes these rows describe the sidecar that will actually
// answer the website, rather than the one the record believes was installed.
let link = record.link_record();
let live = link.as_ref().and_then(live_config);
let health = live.as_ref().and_then(|doc| health_of(&doc.web.bind));
rows.push(link_row(link.as_ref(), live.as_ref()));
rows.push(service_row(link.as_ref()));
rows.push(reachable_row(live.as_ref(), health.as_ref()));
rows.push(protocol_row(&record, live.as_ref(), health.as_ref()));
rows.push(shard_row(root.as_ref(), health.as_ref()));
// ── The bundle ───────────────────────────────────────────────────────────
rows.push(bundle_row(&record));
// ── Backups ──────────────────────────────────────────────────────────────
rows.push(backup_row(&layout));
// ── Report ───────────────────────────────────────────────────────────────
println!();
for row in &rows {
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
for note in &row.notes {
println!(" {note}");
}
}
let failed = rows.iter().filter(|r| r.mark == Mark::Fail).count();
let warned = rows.iter().filter(|r| r.mark == Mark::Warn).count();
println!();
match (failed, warned) {
(0, 0) => println!("Everything checks out."),
(0, w) => println!("{w} thing(s) worth knowing about, nothing broken."),
(f, _) => println!(
"{f} check(s) failed. Start with the first ✗ above; \
INSTALL.md's Troubleshooting table is keyed to these symptoms."
),
}
Ok(if failed > 0 { 1 } else { 0 })
}
/// The ServUO root to inspect: `--servuo` if given, else the one the record names.
///
/// [`servuo::open`] rather than `open_stopped`: a running shard is the *expected* state for a
/// diagnosis — it is the only state in which the shard-connected row can be true — and refusing to
/// report on a live host would make this command useless exactly when it is needed.
fn open_root(cli: &Cli, record: &InstallRecord) -> Result<ServUoRoot> {
let path = cli
.servuo
.clone()
.unwrap_or_else(|| record.servuo.path.clone());
servuo::open(Path::new(&path))
}
fn servuo_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row {
let root = match root {
Ok(root) => root,
Err(error) => {
return Row::fail("ServUO found", record.servuo.path.clone())
.note(error.to_string().replace('\n', " "))
}
};
let row = Row::ok(
"ServUO found",
format!("{} ({})", root.path.display(), root.version_display()),
);
// A tree that has been upgraded under an install is the single most useful thing this row can
// say: the patch tier was resolved against the version recorded here, not against this one.
match &record.servuo.version {
Some(recorded) if Some(recorded) != root.version.as_ref() => Row::warn(
"ServUO found",
format!("{} ({})", root.path.display(), root.version_display()),
)
.note(format!(
"this tree was {recorded} when Runic Gateway was installed — re-check the patch tier \
below"
)),
_ if !root.is_supported_version() => row.note(format!(
"{} is the only supported version; the base overlay is expected to work anyway",
servuo::SUPPORTED_VERSION
)),
_ => row,
}
}
/// Compares every deployed file against the record.
///
/// The comparison that matters is *which* hash a file differs from (PLAN.md §7.0): differing from
/// what the installer put there means the operator edited it, while agreeing with the record on a
/// host whose bundle has moved on means the overlay upstream is newer. A file recorded as
/// `kept-operator-modified` is theirs by definition, so a further edit there is not a finding.
fn overlay_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row {
let Some(overlay) = &record.overlay else {
return Row::fail("Overlay in sync", "no overlay recorded in install.json");
};
let Ok(root) = root else {
return Row::fail(
"Overlay in sync",
format!("{} files recorded, tree unreadable", overlay.files.len()),
);
};
let mut missing = Vec::new();
let mut edited = Vec::new();
let mut operator_owned = 0usize;
for (rel, file) in &overlay.files {
if file.state == "kept-operator-modified" {
operator_owned += 1;
continue;
}
let path = patch::join(&root.path, rel);
match crate::util::sha256_file(&path) {
Ok(actual) if actual == file.on_disk_sha256 => {}
Ok(_) => edited.push(rel.clone()),
Err(_) => missing.push(rel.clone()),
}
}
let total = overlay.files.len();
let owned = if operator_owned > 0 {
format!(", {operator_owned} operator-owned")
} else {
String::new()
};
if missing.is_empty() && edited.is_empty() {
return Row::ok(
"Overlay in sync",
format!("{total} files, all hashes match install.json{owned}"),
);
}
let mark = if missing.is_empty() {
Mark::Warn
} else {
Mark::Fail
};
let mut row = Row::new(
mark,
"Overlay in sync",
format!(
"{total} files{owned}{} missing, {} edited since deployment",
missing.len(),
edited.len()
),
)
.notes_from(missing.iter().map(|f| format!("missing: {f}")))
.notes_from(edited.iter().map(|f| format!("edited: {f}")));
if !missing.is_empty() {
row = row.note("run `update` (or `install`) to put the release's copies back");
}
if !edited.is_empty() {
row = row.note(
"these are code files the overlay owns — an `update` overwrites them without asking",
);
}
row
}
/// Reports the patch tier from the record, then checks the tree still agrees with it.
///
/// The check is not decoration. The tier's whole risk is that its edits sit inside files ServUO
/// itself ships, so a core upgrade, a hand revert, or a restored backup silently removes them —
/// and nothing else in this report would notice. The cached `.patch` (PLAN.md §2.2) is what makes
/// the check possible offline: resolving it against the current file must land on rung 0, because
/// the record says it is already applied.
fn patch_row(
record: &InstallRecord,
root: Result<&ServUoRoot, &anyhow::Error>,
layout: &paths::Layout,
) -> Row {
let records = record.patch_records();
if records.is_empty() {
return Row::warn("Patch tier", "not applied — this is optional").note(
"the two optional features (vendor.sale events, in-game moderation audit) are \
unavailable; INSTALL.md §4",
);
}
let applied: Vec<String> = records
.iter()
.map(|f| {
let rungs: Vec<&str> = f.patches.iter().map(|p| p.rung.as_str()).collect();
format!("{} ({})", f.feature, rungs.join(", "))
})
.collect();
let unsupported: Vec<&str> = records
.iter()
.filter(|f| f.unsupported_servuo)
.map(|f| f.feature.as_str())
.collect();
let mut notes = Vec::new();
let mut gone = 0usize;
match root {
Ok(root) => {
for feature in &records {
for applied_patch in &feature.patches {
match verify_applied(layout, &applied_patch.name, &applied_patch.sha256) {
None => notes.push(format!(
"{}: no cached copy of the patch, so it could not be re-checked",
applied_patch.name
)),
Some(parsed) => {
let target = patch::join(&root.path, &applied_patch.target);
let content = std::fs::read(&target).unwrap_or_default();
let resolution = patch::resolve(&parsed, &content);
if !matches!(resolution, patch::Resolution::AlreadyPresent { .. }) {
gone += 1;
notes.push(format!(
"{} is NO LONGER in {} — the file was replaced, reverted or \
upgraded since it was applied",
applied_patch.name, applied_patch.target
));
}
}
}
}
for companion in &feature.companions {
if !patch::join(&root.path, &companion.path).is_file() {
gone += 1;
notes.push(format!("{} is missing from the tree", companion.path));
}
}
}
}
Err(_) => {
notes.push("the ServUO tree is unreadable, so nothing could be re-checked".into())
}
}
if !unsupported.is_empty() {
// The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able
// to see it here, not only in the output of a run they never saw.
notes.push(format!(
"applied on an UNSUPPORTED ServUO ({}): {}",
records
.iter()
.find_map(|f| f.servuo_version.clone())
.unwrap_or_else(|| "unknown".into()),
unsupported.join(", ")
));
}
let detail = format!("{} applied — {}", records.len(), applied.join("; "));
let mark = if gone > 0 {
Mark::Fail
} else if unsupported.is_empty() {
Mark::Ok
} else {
Mark::Warn
};
Row::new(mark, "Patch tier", detail).notes_from(notes)
}
/// Finds the cached `.patch` for a recorded patch and parses it.
///
/// By name first, which is what the tier writes; then by content hash across the cache, so a
/// release that renames a patch file does not silently turn this check off.
fn verify_applied(
layout: &paths::Layout,
name: &str,
sha256: &str,
) -> Option<crate::diff::FilePatch> {
let by_name = layout.patches_dir().join(format!("{name}.patch"));
let candidates: Vec<PathBuf> = if by_name.is_file() {
vec![by_name]
} else {
std::fs::read_dir(layout.patches_dir())
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "patch"))
.collect()
};
for path in candidates {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if path.file_stem().is_some_and(|s| s == name)
|| crate::util::sha256_bytes(&bytes) == sha256
{
if let Ok(parsed) = crate::diff::parse(&bytes) {
return parsed.single_file().ok().cloned();
}
}
}
None
}
fn link_row(link: Option<&LinkRecord>, live: Option<&sidecar::ConfigDoc>) -> Row {
let Some(link) = link else {
return Row::fail("uo-link installed", "no sidecar recorded in install.json")
.note("the overlay alone does not reach a website — see INSTALL.md §2");
};
let binary = Path::new(&link.binary.path);
if !binary.is_file() {
return Row::fail(
"uo-link installed",
format!("{} is missing", link.binary.path),
);
}
// The version line comes from the binary, the hash decides whether it is the one that was
// installed. A hand-replaced binary that still reports the right version is exactly the case a
// version string alone would call healthy.
let reported =
sidecar::version_line(binary).unwrap_or_else(|| format!("uo-link {}", link.version));
let row = match crate::util::sha256_file(binary) {
Ok(actual) if actual.eq_ignore_ascii_case(&link.binary.sha256) => {
Row::ok("uo-link installed", reported)
}
Ok(_) => Row::warn("uo-link installed", reported).note(format!(
"{} is not the binary this installer recorded — it was replaced by hand or by another \
tool",
link.binary.path
)),
Err(error) => Row::warn("uo-link installed", reported)
.note(format!("cannot hash {}: {error}", link.binary.path)),
};
// The paths the *binary* resolves, under the same environment the service definition pins (see
// `live_config`) — not the ones the record believes it was told. A sidecar reading a different
// config from the one the installer wrote is a failure mode nothing else here would surface.
match live {
Some(doc) => row.note(format!(
"config {} database {}",
doc.config_path, doc.store.path
)),
None => row.note(format!(
"config {} (not readable by this run) database {}",
link.config_path, link.db_path
)),
}
}
fn service_row(link: Option<&LinkRecord>) -> Row {
let Some(link) = link else {
return Row::fail("Service", "nothing recorded");
};
let Some(service) = &link.service else {
// A deliberate outcome, not a bug: a host with no service manager the installer can drive
// gets the binary, the config, and printed instructions (PLAN.md Phase 2).
return Row::warn("Service", "not registered")
.note("the binary and config are installed but nothing runs them — INSTALL.md §7");
};
let status = crate::service::observe(&service.kind, &service.name);
let account = service
.user
.as_deref()
.map(|u| format!(" as {u}"))
.unwrap_or_default();
let detail = format!("{} {}{account}", service.name, status.detail);
if !status.present {
Row::fail("Service", detail).note("re-run `install` to register it again")
} else if !status.running {
Row::fail("Service", detail)
.note("a service that will not stay up usually cannot read its config — INSTALL.md §7")
} else if !status.enabled {
Row::warn("Service", detail).note("it is running but will not come back after a reboot")
} else {
Row::ok("Service", detail)
}
}
/// Asks the installed binary what it resolves — but only if it has a config to read.
///
/// Two rules are load-bearing here:
///
/// - **Only when the config already exists.** `--print-config` provisions: it writes the file and
/// mints a token when there is none. `doctor` must not write, and a diagnosis that created the
/// very state it was asked to report on would be worse than one that said "no config".
/// - **Under the environment the service runs with.** The systemd unit pins `UOLINK_DB_PATH`
/// wherever the database does not land beside the config (`crate::paths`), so a bare
/// `--print-config` would report the path the binary picks *on its own* — `/etc/runicgateway/`
/// rather than `/var/lib/runicgateway/` — and INSTALL.md §7 promises this row names the file the
/// service actually opens.
///
/// The returned document holds the auth token. Nothing here reads it, and nothing prints it.
fn live_config(link: &LinkRecord) -> Option<sidecar::ConfigDoc> {
let config = Path::new(&link.config_path);
if !config.is_file() {
return None;
}
let db = Path::new(&link.db_path);
let db_env = (config.parent() != db.parent()).then_some(db);
sidecar::print_config(Path::new(&link.binary.path), config, db_env).ok()
}
/// The sidecar's `/health`, which needs no auth and is therefore safe to ask for from here.
///
/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and
/// this check is about whether the process on *this* host is answering.
fn health_of(bind: &str) -> Option<Health> {
let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind);
let url = format!("http://127.0.0.1:{port}/health");
let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?;
serde_json::from_str(&body).ok()
}
/// The sidecar's `/health` document. Every field is optional so a newer sidecar that drops or
/// renames one still produces a report rather than a parse failure.
#[derive(Debug, Clone, Deserialize)]
pub struct Health {
pub status: Option<String>,
pub protocol: Option<u32>,
pub plugin_connected: Option<bool>,
pub database: Option<String>,
pub uptime: Option<String>,
pub last_event: Option<String>,
}
fn reachable_row(live: Option<&sidecar::ConfigDoc>, health: Option<&Health>) -> Row {
let Some(doc) = live else {
return Row::fail("Sidecar reachable", "could not read the sidecar's config").note(
"either the config file is gone or this process cannot read it — it is deliberately \
readable only by root/Administrator and the service account",
);
};
let port = doc
.web
.bind
.rsplit_once(':')
.map(|(_, p)| p.to_string())
.unwrap_or_else(|| doc.web.bind.clone());
match health {
Some(health) => {
let uptime = health
.uptime
.as_deref()
.map(|u| format!(", up {u}"))
.unwrap_or_default();
let db = health.database.as_deref().unwrap_or("unknown");
Row::ok(
"Sidecar reachable",
format!(
"127.0.0.1:{port} /health {}{uptime}, database {db}",
health.status.as_deref().unwrap_or("ok")
),
)
}
None => Row::fail(
"Sidecar reachable",
format!("nothing answered http://127.0.0.1:{port}/health"),
)
.note("the binary is installed; this is about whether it is running and listening"),
}
}
fn protocol_row(
record: &InstallRecord,
live: Option<&sidecar::ConfigDoc>,
health: Option<&Health>,
) -> Row {
let overlay = record.overlay.as_ref().map(|o| o.protocol);
// `/health` first: that is the number the website is answered with. `--print-config` is the
// same value from the same binary and covers a sidecar that is installed but not running.
let sidecar = health.and_then(|h| h.protocol).or(live.map(|d| d.protocol));
match (sidecar, overlay) {
(Some(s), Some(o)) if s == o => {
Row::ok("Protocol", format!("sidecar {s} = overlay manifest {o}"))
}
(Some(s), Some(o)) => Row::fail("Protocol", format!("sidecar {s} ≠ overlay manifest {o}"))
.note(
"the sidecar rejects a mismatched website with 409 rather than mis-parsing it; \
this pair was never checked together — run `update` to move both to one bundle",
),
(s, o) => Row::warn(
"Protocol",
format!(
"sidecar {}, overlay manifest {}",
s.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()),
o.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into())
),
),
}
}
/// The row the rest of the report exists to make trustworthy.
///
/// A shard that is not running cannot have dialed in, so that case is a `⚠` with the reason rather
/// than a `✗`: reporting a stopped shard as a failure would train operators to ignore this line,
/// which is the one line worth reading.
fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>) -> Row {
let running = root.ok().and_then(|r| servuo::find_running(&r.path));
match (health.and_then(|h| h.plugin_connected), running) {
(Some(true), _) => {
let last = health
.and_then(|h| h.last_event.clone())
.map(|e| format!(" (last event {e})"))
.unwrap_or_default();
Row::ok("Shard connected", format!("yes{last}"))
}
(Some(false), Some(shard)) => Row::fail(
"Shard connected",
format!(
"no — the shard is running (pid {}) but has not dialed in",
shard.pid
),
)
.note(
"the classic silent failure: ServUO ignores the script build's exit code and reloads \
the previous Scripts.dll",
)
.note("run `[bridge status` in game, and check [shard] bind against Config/Bridge.cfg"),
(Some(false), None) => Row::warn(
"Shard connected",
"no — the shard process is not running on this host",
)
.note("start ServUO and re-run doctor; nothing reaches the website until it dials in"),
(None, _) => Row::warn(
"Shard connected",
"unknown — the sidecar did not answer /health",
),
}
}
/// Whether this deployment is still the current bundle.
///
/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way
/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it
/// would report a working deployment as broken.
/// The most recent backup, so "can I go back?" is answerable without knowing the layout.
///
/// Always `✓`, never a failure: having no backup is the correct state on a host that has never
/// overwritten anything, and a shard that is running fine does not become broken because nothing
/// has displaced a file yet.
fn backup_row(layout: &paths::Layout) -> Row {
let backups = crate::backup::list(layout);
let Some(newest) = backups.first() else {
return Row::ok("Backups", "none taken — no run has replaced a file yet");
};
let detail = match crate::backup::read_manifest(newest) {
Ok(manifest) => format!(
"{}{} file(s) replaced by {} to bundle {}",
manifest.taken,
manifest.files.len(),
manifest.command,
manifest.bundle_to
),
// A directory with an unreadable manifest is still a directory of the operator's files, so
// it is reported rather than skipped.
Err(_) => format!("{} — manifest unreadable", newest.display()),
};
Row::ok("Backups", detail).note(format!(
"{} kept in {}",
backups.len(),
layout.backups_dir().display()
))
}
fn bundle_row(record: &InstallRecord) -> Row {
let url = bundle::url_for(None);
let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) {
Ok(bundle) => bundle,
Err(error) => {
return Row::warn(
"Bundle",
format!("{} — could not check for a newer one", record.bundle.tag),
)
.note(error.to_string().replace('\n', " "))
}
};
if current.bundle == record.bundle.tag {
return Row::ok("Bundle", format!("{} — up to date", record.bundle.tag));
}
let mut moves = Vec::new();
if let Some(link) = record.link_record() {
if link.version != current.link.version {
moves.push(format!(
"uo-link {}{}",
link.version, current.link.version
));
}
}
if let Some(overlay) = &record.overlay {
if overlay.version != current.overlay.version {
moves.push(format!(
"overlay {}{}",
overlay.version, current.overlay.version
));
}
}
let detail = if moves.is_empty() {
format!(
"{}{} (no component changed)",
record.bundle.tag, current.bundle
)
} else {
format!(
"{}{}: {}",
record.bundle.tag,
current.bundle,
moves.join(", ")
)
};
Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA};
use std::collections::BTreeMap;
fn record() -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-05T10:00:00Z".into(),
bundle: BundleRef {
tag: "2026.08.04".into(),
protocol: 3,
url: "https://example/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::new(),
}),
link: None,
patches: Vec::new(),
extra: BTreeMap::new(),
}
}
fn health(protocol: u32, connected: bool) -> Health {
Health {
status: Some("ok".into()),
protocol: Some(protocol),
plugin_connected: Some(connected),
database: Some("ok".into()),
uptime: Some("2m".into()),
last_event: Some("2026-08-05T10:00:00Z".into()),
}
}
#[test]
fn the_documented_health_document_parses() {
// Copied from INSTALL.md §6 — the shape doctor's last three rows are read from.
let body = r#"{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok",
"uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}"#;
let health: Health = serde_json::from_str(body).unwrap();
assert_eq!(health.protocol, Some(3));
assert_eq!(health.plugin_connected, Some(true));
}
#[test]
fn a_health_document_missing_fields_still_parses() {
// A newer sidecar dropping or renaming a key must degrade to an unknown row, not to a
// doctor that cannot report at all.
let health: Health = serde_json::from_str(r#"{"status":"ok"}"#).unwrap();
assert_eq!(health.protocol, None);
assert_eq!(health.plugin_connected, None);
}
#[test]
fn a_protocol_mismatch_fails_the_row() {
assert_eq!(
protocol_row(&record(), None, Some(&health(3, true))).mark,
Mark::Ok
);
assert_eq!(
protocol_row(&record(), None, Some(&health(4, true))).mark,
Mark::Fail
);
// Nothing to compare is not a failure — an unreachable sidecar is already its own ✗ row,
// and reporting the same outage twice buries the one that names the cause.
assert_eq!(protocol_row(&record(), None, None).mark, Mark::Warn);
}
#[test]
fn a_stopped_shard_is_a_warning_and_a_silent_one_is_a_failure() {
// The distinction the whole row exists for: "you have not started it" and "it is running
// and the bridge is dead" are different problems, and only the second is broken.
let err = anyhow::anyhow!("no tree");
assert_eq!(
shard_row(Err(&err), Some(&health(3, false))).mark,
Mark::Warn
);
assert_eq!(shard_row(Err(&err), Some(&health(3, true))).mark, Mark::Ok);
assert_eq!(shard_row(Err(&err), None).mark, Mark::Warn);
}
#[test]
fn an_empty_patch_tier_is_a_warning_not_a_failure() {
// The tier is optional and most shards will decline it. A ✗ there would make a correct
// install look broken forever.
let layout = paths::layout();
let err = anyhow::anyhow!("no tree");
assert_eq!(patch_row(&record(), Err(&err), &layout).mark, Mark::Warn);
}
#[test]
fn a_missing_overlay_file_fails_and_an_edited_one_warns() {
let dir = crate::util::TempDir::new("rg-test-doctor").unwrap();
let root = ServUoRoot {
path: dir.path().to_path_buf(),
version: Some("57.4".into()),
};
std::fs::create_dir_all(dir.path().join("Scripts")).unwrap();
std::fs::write(dir.path().join("Scripts/A.cs"), b"deployed").unwrap();
let deployed = crate::util::sha256_bytes(b"deployed");
let mut record = record();
let files = &mut record.overlay.as_mut().unwrap().files;
files.insert(
"Scripts/A.cs".into(),
FileRecord {
overlay_sha256: deployed.clone(),
on_disk_sha256: deployed.clone(),
state: "deployed".into(),
},
);
assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Ok);
std::fs::write(dir.path().join("Scripts/A.cs"), b"edited by hand").unwrap();
assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Warn);
std::fs::remove_file(dir.path().join("Scripts/A.cs")).unwrap();
assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Fail);
}
#[test]
fn an_operator_owned_file_is_never_reported_as_drift() {
// Bridge.cfg is *meant* to be edited in place; flagging it every run would teach operators
// to ignore this row.
let dir = crate::util::TempDir::new("rg-test-doctor-owned").unwrap();
let root = ServUoRoot {
path: dir.path().to_path_buf(),
version: Some("57.4".into()),
};
std::fs::create_dir_all(dir.path().join("Config")).unwrap();
std::fs::write(dir.path().join("Config/Bridge.cfg"), b"edited again").unwrap();
let mut record = record();
record.overlay.as_mut().unwrap().files.insert(
"Config/Bridge.cfg".into(),
FileRecord {
overlay_sha256: "aa".into(),
on_disk_sha256: "bb".into(),
state: "kept-operator-modified".into(),
},
);
let row = overlay_row(&record, Ok(&root));
assert_eq!(row.mark, Mark::Ok);
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
}
}

912
src/install.rs Normal file
View File

@@ -0,0 +1,912 @@
//! The `install` command — and, in [`Mode::Update`], the deployment half of `update`.
//!
//! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
//! the overlay, run the optional patch tier, install the sidecar and register its service, record
//! what was deployed, and print the values the website needs.
//!
//! **`update` is this same pipeline, not a second one.** PLAN.md §5 Phase 4 describes it as
//! "re-resolve the bundle, then move both components to it" — which is what an `install` over an
//! existing deployment already does, down to keeping a modified `Bridge.cfg` and restarting the
//! service after replacing its binary. Writing it twice would mean two places for the sync rules,
//! the protocol cross-checks and the record-carrying logic to disagree. What actually differs is
//! decided by [`Mode`] and is small: where the ServUO root comes from, whether a prior record is
//! required, how much of the patch tier is in scope, and what is printed at the end. The
//! update-only parts live in [`crate::update`].
//!
//! The order of the run is not incidental:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
//! platform, the ServUO root, and whether this process can write where it must. A run that
//! cannot finish should end before a single file enters the ServUO tree.
//! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree
//! happens together, on the near side of the running-shard check that guards it — and the tier
//! comes second because a feature's companion `.cs` lands in a directory the overlay creates.
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
//! the file the service definition points at, so the service is never started against a config
//! that does not exist yet.
//! 4. **Record last, print the handoff after that.** The token block is the final thing on screen
//! because it is the only thing the operator still has to act on.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::cli::Cli;
use crate::record::{
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
ServUoRef, ServiceRecord, SCHEMA,
};
use crate::servuo::ServUoRoot;
use crate::util::TempDir;
use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
/// Which verb is driving the pipeline.
///
/// The two runs are the same deployment; what differs is what may be assumed. An `install` may be
/// the first thing that ever ran on this host, so it detects or asks for a ServUO root and offers
/// the patch tier. An `update` is by definition a second run, so it already knows the tree, and its
/// tier scope is what a previous run recorded rather than a fresh offer (PLAN.md §5 Phase 4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Install,
Update,
}
impl Mode {
pub fn is_update(self) -> bool {
matches!(self, Self::Update)
}
}
pub fn run(cli: &Cli) -> Result<()> {
deploy(cli, Mode::Install)
}
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let layout = paths::layout();
let record_path = layout.install_record();
// Loaded before anything else because `update` is defined by it: without a record there is
// nothing to update, and the honest answer is to say so before touching the network.
let prior = InstallRecord::load(&record_path)?;
if mode.is_update() {
crate::update::require_prior(prior.as_ref(), &record_path)?;
}
// ── 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 {}{} to bundle {} (protocol {}){}",
env!("CARGO_PKG_VERSION"),
if mode.is_update() {
"update"
} else {
"install"
},
bundle.bundle,
bundle.protocol,
if cli.verify {
" [--verify: nothing will be written]"
} else {
""
}
);
println!();
// ── Where to install it ──────────────────────────────────────────────────
let root = resolve_root(cli, mode, prior.as_ref())?;
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 {}",
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
));
}
// Everything below writes into system directories. Finding out here beats finding out after
// the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot
// simply re-run their way out of.
if !cli.verify {
preflight_writable(&layout)?;
}
// ── 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 prior_files = prior_overlay_files(prior.as_ref(), &root);
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
let summary = overlay::summarize(&planned);
// ── Backup ───────────────────────────────────────────────────────────────
// Created before the first write and handed to every stage that overwrites, so each copy is
// taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily:
// a run that displaces nothing leaves nothing behind.
let mut backup = backup::Session::new(
&layout,
&root.path,
if mode.is_update() {
"update"
} else {
"install"
},
prior.as_ref().map(|p| p.bundle.tag.clone()),
bundle.bundle.clone(),
!cli.verify && !cli.no_backup,
);
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 {
// `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what
// would replace it, and `KeptOperatorModified` is not written at all — copying those three
// would bury the files that are actually being displaced.
for file in planned
.iter()
.filter(|f| f.action == overlay::Action::Change)
{
backup.capture(&file.dst, backup::Reason::OverlayChange)?;
}
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
));
}
// ── The patch tier ───────────────────────────────────────────────────────
// After the overlay, because a feature's companion `.cs` lands in the directory the overlay
// creates, and before the sidecar, because everything that edits the ServUO tree belongs on the
// near side of the running-shard check that guards it.
let tier = tier::run(
cli,
mode,
&root,
&unpacked,
manifest.patch_tier.as_ref(),
&layout,
&prior
.as_ref()
.map(|p| p.patch_records())
.unwrap_or_default(),
&mut backup,
)?;
// The config joins a backup that is already being taken; it is never the reason for one. The
// installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that
// a restored set of files comes with the token that matches them, rather than an operator
// restoring a tree and then finding the website pointed at a token that has moved on.
if backup.has_entries() {
backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?;
}
let backup_dir = backup.finish()?;
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(
cli,
&bundle,
&sidecar_asset,
&layout,
scratch.path(),
prior.as_ref(),
)?;
// ── Record ───────────────────────────────────────────────────────────────
let record = build_record(
prior.as_ref(),
&Deployment {
bundle: &bundle,
bundle_url: &bundle_url,
root: &root,
manifest: &manifest,
planned: &planned,
sidecar: sidecar.as_ref(),
tier: &tier,
verify: cli.verify,
},
);
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());
}
}
}
// Named after the record rather than at the moment it was taken, because that is where an
// operator looks when a run has finished and something is wrong. Restoring is theirs to do:
// the installer cannot know what has changed since, and putting an old `.cs` file back over a
// newer overlay eats work rather than saving it.
if let Some(dir) = &backup_dir {
println!("\n Backed up {}", dir.display());
println!(
" the files this run replaced, with a manifest naming each one.\n\
\x20 The newest {} backups are kept; `uninstall --purge` removes them.",
backup::KEEP
);
}
// ── Closing notes ────────────────────────────────────────────────────────
println!();
if cli.verify {
// Worded for the verb that was typed, and said exactly once: `update`'s own closing block
// deliberately does not repeat it.
println!(
"Nothing was written. Re-run without --verify to {}.",
if mode.is_update() { "update" } else { "deploy" }
);
} else if summary.writes_anything() || tier.core_rebuild {
if tier.core_rebuild {
// Said again here, after everything else, because it is the one step whose omission
// produces a shard that boots perfectly and never emits the events it was patched for.
println!(
"A CORE ServUO file was patched — rebuild the solution before starting:\n \
dotnet build ServUO.sln\n"
);
}
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 {
println!("The ServUO tree already has this overlay — nothing was changed there.");
}
// ── The one manual step ──────────────────────────────────────────────────
// Last, and after the record, because it is the only thing left for the operator to do. The
// token goes to the terminal and nowhere else (PLAN.md §6).
//
// An `update` prints none of it. The token has not changed, the website already holds it, and
// reprinting a secret that nobody has to act on puts it in one more scrollback for no reason.
// What an update *can* change is the protocol number the website is configured with, and
// `update::closing` says so when it moved.
match (mode, &sidecar) {
(Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify),
(Mode::Install, Some(sidecar)) => {
let host = resolve_host(cli);
println!(
"{}",
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
);
if !sidecar.service.registered() {
ui::warn(
"No service was registered, so nothing is listening yet — the values above \
describe the sidecar\n once you start it. See the steps printed above.",
);
}
}
(Mode::Install, None) => {}
}
Ok(())
}
/// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection
/// (confirmed), else a prompt.
///
/// An update never prompts and never guesses. The tree it is updating is the one `install.json`
/// names — detection could plausibly find a *different* shard on a host that has two, and moving a
/// deployment to another tree is not something an `update` should be able to do by accident.
fn resolve_root(cli: &Cli, mode: Mode, prior: Option<&InstallRecord>) -> Result<ServUoRoot> {
if let Some(path) = &cli.servuo {
return servuo::open_stopped(&PathBuf::from(path));
}
if mode.is_update() {
if let Some(prior) = prior {
return servuo::open_stopped(&PathBuf::from(&prior.servuo.path));
}
}
if let Some(detected) = servuo::detect() {
let question = format!("Use the ServUO installation at {}?", detected.display());
if ui::confirm(&question, true, cli.assume_yes)? {
return servuo::open_stopped(&detected);
}
} else if cli.assume_yes {
// --yes cannot invent a path, and picking one would be the worst possible guess.
bail!(
"no ServUO installation was found near this binary or the working directory. \
Pass --servuo <path>."
);
}
let answer = ui::prompt("Path to your ServUO root", None)
.context("a ServUO root is required; pass --servuo <path> for an unattended run")?;
servuo::open_stopped(&PathBuf::from(answer.trim().trim_matches('"')))
}
/// The previous run's file map, but only when it describes *this* tree.
///
/// The map is what distinguishes an operator-edited `Bridge.cfg` from an upstream change, and that
/// judgement is only meaningful about the tree it was recorded for. A host whose record points at a
/// different root — a shard moved or rebuilt beside the old one — is treated as having no prior
/// deployment here, which errs toward keeping the operator's file.
fn prior_overlay_files<'a>(
prior: Option<&'a InstallRecord>,
root: &ServUoRoot,
) -> Option<&'a std::collections::BTreeMap<String, crate::record::FileRecord>> {
let prior = prior?;
if Path::new(&prior.servuo.path) != root.path {
return None;
}
prior.overlay_files()
}
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
struct SidecarOutcome {
record: LinkRecord,
doc: sidecar::ConfigDoc,
service: service::Outcome,
}
/// Installs the sidecar, provisions its config, and registers its service.
///
/// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it,
/// **then** `--print-config` (which writes the config the service will be pointed at), then
/// register. Doing the last two the other way round registers a service against a file that does
/// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing
/// mistake.
fn install_sidecar(
cli: &Cli,
bundle: &bundle::Bundle,
asset: &bundle::Asset,
layout: &paths::Layout,
scratch: &Path,
prior: Option<&InstallRecord>,
) -> Result<Option<SidecarOutcome>> {
ui::heading("uo-link sidecar");
let action = sidecar::decide(asset, &layout.sidecar_bin)?;
ui::row(
"binary",
&format!("{} {}", layout.sidecar_bin.display(), action.label()),
);
if cli.verify {
ui::row(
"config",
&format!(
"{} (would be provisioned)",
layout.sidecar_config().display()
),
);
ui::row("database", &layout.sidecar_db().display().to_string());
println!(
"\n VERIFY only. Nothing installed, no service registered, no token read.\n \
The token handoff needs a provisioned config, so it is only printed by a real run."
);
return Ok(None);
}
// Detect the service manager and make sure the account exists *before* a config file is
// written that then has to be owned by it.
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if action.writes() {
// The binary is locked on Windows while its service runs, and on Linux replacing it under a
// live process leaves the old code serving until something restarts it.
service::stop_for_replacement(&prepared.manager)?;
let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?;
ui::ok(&format!(
"sidecar binary verified sha256 {}",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
std::fs::create_dir_all(&layout.data_dir)
.with_context(|| format!("cannot create {}", layout.data_dir.display()))?;
let config_path = layout.sidecar_config();
let db_path = layout.sidecar_db();
// The database is pinned by environment only where it does not already land beside the config.
// See `crate::paths` for why that is a platform difference rather than an inconsistency.
let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path());
let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?;
// The installed binary is the only thing that will actually speak to the website, so its
// protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number
// from source at the release tag; a disagreement means the pair in front of us is not the pair
// CI checked, and the sidecar would answer the website with 409 rather than mis-parsing.
if doc.protocol != bundle.protocol {
bail!(
"the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \
Refusing to register a service for a pair that was never checked together — the \
binary is installed but no service has been created.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.link.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.link.version
));
}
ui::row(
"config",
&format!(
"{} {}",
doc.config_path,
if doc.config_created {
"created"
} else {
"already present"
}
),
);
ui::row("database", &doc.store.path);
ui::row(
"listening on",
&format!("shard {} website {}", doc.shard.bind, doc.web.bind),
);
// The config holds the auth token, and neither default location protects it on its own. This
// runs before registration so the file is never left readable while the rest of the run
// happens; on Windows the service account does not exist until `sc create` creates it, so the
// grant for it comes after.
service::protect_config(
&config_path,
&layout.data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
unit_path,
user,
user_created,
state,
} => {
ui::row("service", &format!("{name} {state}"));
if let Some(user) = user {
ui::row("running as", user);
}
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: unit_path.as_ref().map(|p| p.display().to_string()),
user: user.clone(),
user_created: *user_created || created_by_an_earlier_run(prior, user.as_deref()),
})
}
service::Outcome::Skipped { reason, manual } => {
println!();
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n \
The binary and its config are in place; nothing is running them. Do this by hand:"
));
print!("{manual}");
None
}
};
Ok(Some(SidecarOutcome {
record: LinkRecord {
repo: bundle.link.repo.clone(),
tag: bundle.link.tag.clone(),
version: doc.version.clone(),
protocol: doc.protocol,
binary: BinaryRef {
path: layout.sidecar_bin.display().to_string(),
sha256: binary_sha256,
},
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
service: outcome,
}))
}
/// Whether an earlier run of this installer created the service account.
///
/// **`user_created` has to be sticky, and this is why.** `service::prepare` answers "did *this run*
/// create the account", which is `false` on every run after the first — the account exists by then.
/// Recording that verbatim makes the field describe the run instead of the state, with two
/// consequences: `install.json` changes on an otherwise-identical second run (breaking Phase 1's
/// "a second run writes nothing"), and `uninstall` — which removes only an account it created —
/// silently leaves behind the very account this tool added. Both were caught on the first real
/// systemd host the installer ever ran on, and neither is visible on Windows, where the SCM's
/// virtual account is never "created" by us at all.
///
/// Matched on the account *name*: a record naming a different user describes a different account,
/// and inheriting `true` from it would authorize deleting one this installer never made.
fn created_by_an_earlier_run(prior: Option<&InstallRecord>, user: Option<&str>) -> bool {
let (Some(prior), Some(user)) = (prior, user) else {
return false;
};
prior
.link_record()
.and_then(|link| link.service)
.is_some_and(|service| service.user.as_deref() == Some(user) && service.user_created)
}
/// Fails the run before it writes anything if this process cannot write where it must.
///
/// `create_dir_all` succeeding is not the same question as "can this process write here" — it
/// returns `Ok` for a directory that already exists and is read-only to us — so each location is
/// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree
/// that has already been deployed into.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.sidecar_bin
.parent()
.unwrap_or(&layout.sidecar_bin)
.to_path_buf();
for dir in [&layout.state_dir, &layout.data_dir, &bin_dir] {
std::fs::create_dir_all(dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}.\n \
Run this installer as {}, or set {} to a writable directory for a test run \
(no service is registered then).",
dir.display(),
if cfg!(windows) {
"Administrator, from an elevated PowerShell"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}
/// The hostname the website should use to reach this machine.
///
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}
let detected = sysinfo::System::host_name()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "this-host".to_string());
if cli.assume_yes {
return detected;
}
let answer = ui::prompt(
"Hostname your website should use to reach this machine",
Some(&detected),
);
answer.unwrap_or(detected)
}
/// Everything one run produced, gathered so the record can be built from a single value.
///
/// The three halves are assembled at different points and the record needs all of them, which is
/// how this grew a parameter per step. A struct keeps the call site readable and, more usefully,
/// makes it obvious at a glance that nothing else feeds `install.json`.
struct Deployment<'a> {
bundle: &'a bundle::Bundle,
bundle_url: &'a str,
root: &'a ServUoRoot,
manifest: &'a overlay::Manifest,
planned: &'a [overlay::PlannedFile],
sidecar: Option<&'a SidecarOutcome>,
tier: &'a tier::Outcome,
/// A dry run reports everything and records nothing, so every "carry the previous value
/// through" branch below turns on it.
verify: bool,
}
fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord {
let Deployment {
bundle,
bundle_url,
root,
manifest,
planned,
sidecar,
tier,
verify,
} = *run;
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),
}),
// A `--verify` run installs no sidecar and must not erase the record of one that is
// already there; the same reasoning keeps the patch section and any field a
// newer installer wrote intact. A record that forgot a running service would make
// `doctor` and `uninstall` forget it too.
link: match sidecar {
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
None => prior.and_then(|p| p.link.clone()),
},
// The tier's own records replace the section only when it actually ran and wrote. A
// declined tier, a refused one, and a `--verify` dry run all leave the previous record
// exactly as it was — an install where the operator said "not this time" must not erase
// the evidence of patches applied on an earlier one.
patches: match (tier.ran && !verify, prior) {
(true, _) => tier
.records
.iter()
.filter_map(|r| serde_json::to_value(r).ok())
.collect(),
(false, Some(previous)) => previous.patches.clone(),
(false, None) => Vec::new(),
},
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 an_account_this_installer_created_stays_recorded_as_created() {
// Caught on the first real systemd host: `prepare` reports "did THIS run create it", which
// is false from the second run on. Recording that verbatim rewrote install.json on an
// identical re-run and, worse, left `uninstall` believing the account was somebody else's.
let mut record = record_for("/opt/ServUO");
let service = |user: &str, created: bool| ServiceRecord {
kind: "systemd".into(),
name: "runicgateway-link.service".into(),
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
user: Some(user.into()),
user_created: created,
};
let with_service = |record: &mut InstallRecord, service: ServiceRecord| {
record.link = serde_json::to_value(LinkRecord {
repo: "RunicGateway/link".into(),
tag: "v1.1.0".into(),
version: "1.1.0".into(),
protocol: 3,
binary: BinaryRef {
path: "/usr/bin/runicgateway-link".into(),
sha256: "aa".into(),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: Some(service),
})
.ok();
};
with_service(&mut record, service("runicgateway", true));
assert!(created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
// An account this installer found already there stays somebody else's, forever.
with_service(&mut record, service("runicgateway", false));
assert!(!created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
// A record naming a different account says nothing about this one — inheriting `true`
// there would authorize deleting a user this installer never made.
with_service(&mut record, service("someone-else", true));
assert!(!created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
assert!(!created_by_an_earlier_run(None, Some("runicgateway")));
assert!(!created_by_an_earlier_run(Some(&record), None));
}
#[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());
}
}

117
src/lib.rs Normal file
View File

@@ -0,0 +1,117 @@
//! 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 Phases 1 to 4** — the whole of what `INSTALL.md` describes: bundle
//! resolution, ServUO detection and validation, the overlay sync, the optional patch tier,
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
//! commands `doctor`, `update` and `uninstall`.
//!
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that
//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that.
//! `uninstall` does the same for a step it could not carry out — everything else was still removed.
//!
//! ## Why the library target is called `rgdeploy`
//!
//! Windows applies **UAC installer detection** to unsigned executables whose file name contains
//! `install`, `setup`, `update` or `patch`: it decides the program is a legacy installer and
//! demands elevation before the process starts. That is tolerable for the shipped binary, which
//! needs Administrator anyway and is documented as being run from an elevated shell — but Cargo
//! names test harnesses after their target, so a target called `runicgateway_installer` produces
//! `runicgateway_installer-<hash>.exe`, which Windows refuses to launch (`os error 740`) and
//! `cargo test` cannot run at all on a developer's machine.
//!
//! So the code lives in a neutrally-named library, the binary target keeps the published name from
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
//! name. Nothing an operator sees changes.
pub mod backup;
pub mod bundle;
pub mod cli;
pub mod diff;
pub mod doctor;
pub mod install;
pub mod net;
pub mod overlay;
pub mod patch;
pub mod paths;
pub mod record;
pub mod service;
pub mod servuo;
pub mod sidecar;
pub mod tier;
pub mod ui;
pub mod uninstall;
pub mod update;
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;
}
};
// Every arm yields the process exit code, because one of them has more than two outcomes:
// `doctor` completes successfully while reporting a broken deployment, and a monitoring script
// has to be able to tell that from a healthy one (see `doctor::run`).
let result: anyhow::Result<i32> = match parsed.mode {
Mode::Help => {
print!("{}", cli::USAGE);
Ok(0)
}
Mode::Version => {
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
Ok(0)
}
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
Mode::Run(Command::Doctor) => doctor::run(&parsed),
Mode::Run(Command::Uninstall) => uninstall::run(&parsed),
};
match result {
Ok(code) => code,
Err(error) => {
// 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}");
}
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_documented_command_has_an_implementation() {
// The published contract is INSTALL.md §2's four commands. This build answers all of them,
// so the parser and the dispatcher must not be able to drift apart — an unhandled arm here
// used to be a "not implemented" message, and is now a compile error by construction.
for command in [
Command::Install,
Command::Doctor,
Command::Update,
Command::Uninstall,
] {
assert!(cli::USAGE.contains(&command.to_string()), "{command}");
}
}
}

6
src/main.rs Normal file
View File

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

128
src/net.rs Normal file
View File

@@ -0,0 +1,128 @@
//! 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"))
}
/// The default global timeout. 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.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
fn agent(timeout: Duration) -> ureq::Agent {
ureq::Agent::config_builder()
.user_agent(user_agent())
.timeout_global(Some(timeout))
.build()
.into()
}
/// Fetches a small text document (the bundle manifest).
pub fn get_text(url: &str) -> Result<String> {
get_text_within(url, DEFAULT_TIMEOUT)
}
/// Fetches a small text document, giving up after `timeout`.
///
/// `doctor` uses this for both of its network calls, and the short timeout is the point: every one
/// of its rows is optional context around local state, so a host with no route out must produce a
/// report a few seconds later rather than a terminal that appears to have hung. The install path
/// keeps [`DEFAULT_TIMEOUT`], where a slow answer is still worth waiting for.
pub fn get_text_within(url: &str, timeout: Duration) -> Result<String> {
let mut response = agent(timeout)
.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(DEFAULT_TIMEOUT)
.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());
}
}

703
src/overlay.rs Normal file
View File

@@ -0,0 +1,703 @@
//! 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,
/// The patch tier this release ships, generated from `servuo-plugins/patches/tier.json`
/// (PLAN.md §2.2). `None` for a release that predates the declaration — see
/// [`crate::patch::Tier::resolve`], which substitutes a built-in description rather than
/// leaving the tier silently empty.
#[serde(default)]
pub patch_tier: Option<crate::patch::Tier>,
/// SHA256 per shipped file, keyed `overlay/...` and `patches/...`.
pub files: BTreeMap<String, String>,
}
/// What the sync will do to one file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Add,
Change,
Unchanged,
/// The operator has edited this file since it was deployed (or it was already there before the
/// installer ever ran). Reported, left alone.
KeptOperatorModified,
}
impl Action {
/// The token written into `install.json` — the resulting *state*, not the verb.
///
/// Add, change and unchanged all leave the release's copy in the tree, so all three record
/// `deployed`. Collapsing them is what lets an unchanged re-run compare equal to the previous
/// record and write nothing (see [`crate::record::FileRecord::state`]).
pub fn state(self) -> &'static str {
match self {
Self::Add | Self::Change | Self::Unchanged => "deployed",
Self::KeptOperatorModified => "kept-operator-modified",
}
}
fn label(self) -> &'static str {
match self {
Self::Add => "ADD",
Self::Change => "CHANGE",
Self::Unchanged => "same",
Self::KeptOperatorModified => "KEEP",
}
}
fn writes(self) -> bool {
matches!(self, Self::Add | Self::Change)
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone)]
pub struct PlannedFile {
/// ServUO-tree-relative, always `/`-separated so the record is portable between platforms.
pub rel: String,
pub src: PathBuf,
pub dst: PathBuf,
pub action: Action,
pub overlay_sha256: String,
/// What is on disk now — `None` when the file does not exist yet.
pub on_disk_sha256: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Summary {
pub add: usize,
pub change: usize,
pub unchanged: usize,
pub kept: usize,
}
impl Summary {
pub fn writes_anything(&self) -> bool {
self.add + self.change > 0
}
}
/// Unpacks the release tarball and returns the directory holding `overlay/`, `patches/` and
/// `manifest.json`.
///
/// `tar`'s unpack refuses entries that escape the destination, so a malicious or malformed archive
/// cannot write outside the scratch directory — worth stating explicitly, since this is the one
/// place the installer expands untrusted-shaped data. The archive itself has already been checked
/// against the bundle's SHA256 by the time this runs.
pub fn extract(tarball: &Path, into: &Path) -> Result<PathBuf> {
let file = File::open(tarball).with_context(|| format!("cannot open {}", tarball.display()))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(into)
.with_context(|| format!("cannot unpack {}", tarball.display()))?;
// The fixed prefix is what the release workflow writes; falling back to the extraction root
// covers a tarball repackaged without it, which is a plausible operator mistake and a
// pointless thing to fail on when the three known paths are right there.
let with_prefix = into.join(TOP_LEVEL_DIR);
for candidate in [with_prefix, into.to_path_buf()] {
if candidate.join("manifest.json").is_file() && candidate.join("overlay").is_dir() {
return Ok(candidate);
}
}
bail!(
"{} does not contain {TOP_LEVEL_DIR}/manifest.json and {TOP_LEVEL_DIR}/overlay/ — \
this is not a Runic Gateway overlay release",
tarball.display()
);
}
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
let path = dir.join("manifest.json");
let body =
fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
serde_json::from_str(&body).with_context(|| {
format!(
"{} is not a manifest this installer understands",
path.display()
)
})
}
/// Re-hashes every file the manifest names.
///
/// The tarball's own checksum has already been verified against the bundle, so this is not the
/// trust boundary — it is a guard against a truncated extraction, a disk error, or an archive
/// repacked by hand between download and deploy. It is also what makes the hashes recorded in
/// `install.json` trustworthy, since those come from this manifest rather than from re-reading the
/// tree later.
pub fn verify_payload(dir: &Path, manifest: &Manifest) -> Result<()> {
let mut problems = Vec::new();
for (rel, expected) in &manifest.files {
let path = dir.join(rel);
if !path.is_file() {
problems.push(format!(" missing: {rel}"));
continue;
}
let actual = sha256_file(&path)?;
if &actual != expected {
problems.push(format!(" modified: {rel}"));
}
}
if !problems.is_empty() {
bail!(
"the extracted overlay does not match its own manifest:\n{}",
problems.join("\n")
);
}
Ok(())
}
/// Decides what to do with every file in `overlay/`, without touching anything.
///
/// `prior` is the previous `install.json` file map. It is what separates "the operator edited
/// `Bridge.cfg`" from "the overlay shipped a new `Bridge.cfg`" (PLAN.md §7.0): if what is on disk
/// is exactly the copy this installer last *deployed*, the operator has not touched it and an
/// upstream change may land. Anything else — including no record at all, i.e. a tree where the
/// file was put there by hand per INSTALL.md Appendix A — is treated as the operator's.
pub fn plan(
overlay_dir: &Path,
servuo_root: &Path,
prior: Option<&BTreeMap<String, FileRecord>>,
) -> Result<Vec<PlannedFile>> {
let source = overlay_dir.join("overlay");
let mut files = Vec::new();
collect(&source, &source, &mut files)?;
files.sort();
let mut planned = Vec::with_capacity(files.len());
for rel in files {
let src = source.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
let dst = servuo_root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
let overlay_sha256 = sha256_file(&src)?;
let on_disk_sha256 = if dst.is_file() {
Some(sha256_file(&dst)?)
} else {
None
};
let action = match &on_disk_sha256 {
None => Action::Add,
Some(on_disk) if *on_disk == overlay_sha256 => Action::Unchanged,
Some(on_disk) if OPERATOR_OWNED.contains(&rel.as_str()) => {
match prior.and_then(|p| p.get(&rel)) {
// What is on disk is byte-for-byte the copy the installer itself last
// deployed, so the operator has not touched it and the release's new default
// may land.
//
// Compared against `overlay_sha256` — the release copy — and NOT against
// `on_disk_sha256`: after a file has once been kept, `on_disk_sha256` holds
// the *operator's* content, so comparing to it would find a match on the very
// next run and overwrite exactly the file this rule exists to protect. A keep
// has to stay kept for as long as the operator's edit is there.
Some(record) if record.overlay_sha256 == *on_disk => Action::Change,
_ => Action::KeptOperatorModified,
}
}
Some(_) => Action::Change,
};
planned.push(PlannedFile {
rel,
src,
dst,
action,
overlay_sha256,
on_disk_sha256,
});
}
Ok(planned)
}
/// Copies every file the plan writes. Parent directories are created; nothing is removed.
pub fn apply(planned: &[PlannedFile]) -> Result<()> {
for file in planned.iter().filter(|f| f.action.writes()) {
if let Some(parent) = file.dst.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
fs::copy(&file.src, &file.dst).with_context(|| {
format!(
"cannot write {}{}",
file.dst.display(),
"check that the shard is stopped and that you are running as root/Administrator"
)
})?;
}
Ok(())
}
pub fn summarize(planned: &[PlannedFile]) -> Summary {
let mut summary = Summary::default();
for file in planned {
match file.action {
Action::Add => summary.add += 1,
Action::Change => summary.change += 1,
Action::Unchanged => summary.unchanged += 1,
Action::KeptOperatorModified => summary.kept += 1,
}
}
summary
}
/// The per-file record for `install.json`.
pub fn file_records(planned: &[PlannedFile]) -> BTreeMap<String, FileRecord> {
planned
.iter()
.map(|f| {
// For everything the installer wrote, what is on disk afterwards *is* the overlay's
// copy. Only a kept file keeps its own hash — which is precisely what makes a later
// run able to tell that the operator, not the release, owns it.
let on_disk = match f.action {
Action::KeptOperatorModified => f
.on_disk_sha256
.clone()
.unwrap_or_else(|| f.overlay_sha256.clone()),
_ => f.overlay_sha256.clone(),
};
(
f.rel.clone(),
FileRecord {
overlay_sha256: f.overlay_sha256.clone(),
on_disk_sha256: on_disk,
state: f.action.state().to_string(),
},
)
})
.collect()
}
/// Renders the changed files, collapsing a directory full of identically-treated files into one
/// line — 22 `ADD` lines for `Scripts/Custom/Bridge/*.cs` push everything else off the screen, and
/// what an operator needs to see is that `Scripts.csproj` was overwritten.
pub fn render(planned: &[PlannedFile]) -> Vec<String> {
const GROUP_AT: usize = 4;
let mut lines = Vec::new();
let mut group: Vec<&PlannedFile> = Vec::new();
let interesting: Vec<&PlannedFile> = planned
.iter()
.filter(|f| f.action != Action::Unchanged)
.collect();
let key = |f: &PlannedFile| -> (Action, String, String) {
let (dir, name) = match f.rel.rsplit_once('/') {
Some((d, n)) => (d.to_string(), n.to_string()),
None => (String::new(), f.rel.clone()),
};
let ext = name
.rsplit_once('.')
.map(|(_, e)| e.to_string())
.unwrap_or_default();
(f.action, dir, ext)
};
let flush = |group: &mut Vec<&PlannedFile>, lines: &mut Vec<String>| {
if group.is_empty() {
return;
}
if group.len() >= GROUP_AT {
let (action, dir, ext) = key(group[0]);
let glob = if ext.is_empty() {
format!("{dir}/*")
} else {
format!("{dir}/*.{ext}")
};
lines.push(format!(
" {:<7} {:<38} ({} files)",
action.label(),
glob,
group.len()
));
} else {
for f in group.iter() {
lines.push(format!(" {:<7} {}", f.action.label(), f.rel));
}
}
group.clear();
};
for file in interesting {
if group.first().map(|g| key(g)) != Some(key(file)) {
flush(&mut group, &mut lines);
}
group.push(file);
}
flush(&mut group, &mut lines);
lines
}
/// Recursively lists files under `dir` as `/`-separated paths relative to `base`.
fn collect(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
let entries = fs::read_dir(dir).with_context(|| format!("cannot list {}", dir.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("cannot list {}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
collect(base, &path, out)?;
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.with_context(|| format!("{} is not under {}", path.display(), base.display()))?;
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
struct Fixture {
_tmp: TempDir,
overlay_dir: PathBuf,
root: PathBuf,
}
/// An overlay release laid out the way the tarball is, and an empty ServUO tree.
fn fixture() -> Fixture {
let tmp = TempDir::new("rg-test-overlay").unwrap();
let overlay_dir = tmp.path().join("runicgateway-overlay");
let root = tmp.path().join("ServUO");
let bridge = overlay_dir
.join("overlay")
.join("Scripts")
.join("Custom")
.join("Bridge");
fs::create_dir_all(&bridge).unwrap();
fs::create_dir_all(overlay_dir.join("overlay").join("Config")).unwrap();
fs::write(
overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\n",
)
.unwrap();
fs::write(
overlay_dir
.join("overlay")
.join("Scripts")
.join("Scripts.csproj"),
b"<Project/>\n",
)
.unwrap();
for i in 0..5 {
fs::write(bridge.join(format!("Bridge{i}.cs")), format!("// {i}\n")).unwrap();
}
fs::create_dir_all(&root).unwrap();
Fixture {
_tmp: tmp,
overlay_dir,
root,
}
}
fn action_of<'a>(planned: &'a [PlannedFile], rel: &str) -> &'a PlannedFile {
planned.iter().find(|f| f.rel == rel).expect(rel)
}
#[test]
fn a_first_install_adds_everything() {
let fx = fixture();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
let summary = summarize(&planned);
assert_eq!(summary.add, 7);
assert_eq!(summary.change + summary.unchanged + summary.kept, 0);
apply(&planned).unwrap();
assert!(fx.root.join("Config").join("Bridge.cfg").is_file());
assert!(fx
.root
.join("Scripts")
.join("Custom")
.join("Bridge")
.join("Bridge0.cs")
.is_file());
}
#[test]
fn a_second_run_with_no_upstream_change_writes_nothing() {
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
let second = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
let summary = summarize(&second);
assert_eq!(summary.unchanged, 7);
assert!(!summary.writes_anything());
assert!(
render(&second).is_empty(),
"an unchanged run prints no file lines"
);
// ...and it must produce a byte-identical record, or install.json would be rewritten on
// every run — "reports unchanged and writes nothing" is the requirement, and a file map
// that recorded `add` the first time and `unchanged` the second would quietly break it.
assert_eq!(records, file_records(&second));
}
#[test]
fn code_files_are_always_overwritten() {
// A hand-edited .cs file or Scripts.csproj is a stale copy that breaks the build, and
// ServUO will not say so — it reloads the previous Scripts.dll and boots clean.
let fx = fixture();
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
let csproj = fx.root.join("Scripts").join("Scripts.csproj");
fs::write(&csproj, b"<Project> hand edited </Project>\n").unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
assert_eq!(
action_of(&planned, "Scripts/Scripts.csproj").action,
Action::Change
);
apply(&planned).unwrap();
assert_eq!(fs::read(&csproj).unwrap(), b"<Project/>\n");
}
#[test]
fn an_edited_bridge_cfg_is_kept_even_when_the_release_moved_on() {
// The deviation from deploy.ps1: overwriting here would silently revert LinkUrl,
// PublicConnectAddress and every sweep interval on an update.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
let deployed = fx.root.join("Config").join("Bridge.cfg");
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
// ...and the release ships a new default too, so this is not merely "no upstream change".
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
let cfg = action_of(&planned, "Config/Bridge.cfg");
assert_eq!(cfg.action, Action::KeptOperatorModified);
apply(&planned).unwrap();
assert_eq!(
fs::read(&deployed).unwrap(),
b"LinkUrl=https://myshard.example/link\n",
"the operator's file must survive"
);
// And the record keeps the operator's hash, not the release's — otherwise the next run
// would conclude the operator had never touched it and overwrite on the run after that.
let records = file_records(&planned);
let record = &records["Config/Bridge.cfg"];
assert_ne!(record.on_disk_sha256, record.overlay_sha256);
assert_eq!(record.state, "kept-operator-modified");
}
#[test]
fn a_kept_bridge_cfg_stays_kept_run_after_run() {
// The rule has to survive its own bookkeeping. Once a file is kept, the record holds the
// operator's hash as what is on disk — so a rule that asked "is the tree still what the
// record last saw?" would answer yes on the next run and overwrite the very file it had
// just protected. Three runs, because the bug only appears from the second one on.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let mut records = file_records(&first);
let deployed = fx.root.join("Config").join("Bridge.cfg");
fs::write(&deployed, b"LinkUrl=https://myshard.example/link\n").unwrap();
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
for run in 2..=4 {
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::KeptOperatorModified,
"run {run} must still keep the operator's file"
);
apply(&planned).unwrap();
assert_eq!(
fs::read(&deployed).unwrap(),
b"LinkUrl=https://myshard.example/link\n",
"run {run} overwrote the operator's file"
);
records = file_records(&planned);
}
}
#[test]
fn an_untouched_bridge_cfg_takes_the_upstream_change() {
// The other half of the rule: if what is on disk is exactly what was deployed, the
// operator has not edited it and a new default may land.
let fx = fixture();
let first = plan(&fx.overlay_dir, &fx.root, None).unwrap();
apply(&first).unwrap();
let records = file_records(&first);
fs::write(
fx.overlay_dir
.join("overlay")
.join("Config")
.join("Bridge.cfg"),
b"LinkUrl=https://yoursite/link\nNewSetting=1\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, Some(&records)).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::Change
);
}
#[test]
fn a_hand_installed_tree_with_no_record_keeps_its_bridge_cfg() {
// INSTALL.md Appendix A tells operators to deploy by hand today. When the installer later
// arrives on such a host there is no record to compare against, and the safe reading of an
// unknown edit is that it is the operator's.
let fx = fixture();
fs::create_dir_all(fx.root.join("Config")).unwrap();
fs::write(
fx.root.join("Config").join("Bridge.cfg"),
b"LinkUrl=https://myshard.example/link\n",
)
.unwrap();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
assert_eq!(
action_of(&planned, "Config/Bridge.cfg").action,
Action::KeptOperatorModified
);
}
#[test]
fn nothing_outside_the_overlay_is_touched() {
let fx = fixture();
let stranger = fx.root.join("Scripts").join("Custom").join("MyShard.cs");
fs::create_dir_all(stranger.parent().unwrap()).unwrap();
fs::write(&stranger, b"// mine\n").unwrap();
apply(&plan(&fx.overlay_dir, &fx.root, None).unwrap()).unwrap();
assert_eq!(fs::read(&stranger).unwrap(), b"// mine\n");
}
#[test]
fn a_directory_of_identical_actions_collapses_to_one_line() {
let fx = fixture();
let planned = plan(&fx.overlay_dir, &fx.root, None).unwrap();
let lines = render(&planned);
assert!(
lines
.iter()
.any(|l| l.contains("Scripts/Custom/Bridge/*.cs") && l.contains("(5 files)")),
"{lines:#?}"
);
// The single-file entries stay individually visible — Scripts.csproj overwriting a stock
// file is exactly what must not get folded away.
assert!(
lines.iter().any(|l| l.contains("Scripts/Scripts.csproj")),
"{lines:#?}"
);
}
#[test]
fn the_manifest_check_catches_a_tampered_payload() {
let fx = fixture();
let cfg_rel = "overlay/Config/Bridge.cfg";
let manifest = Manifest {
component: "servuo-plugins-overlay".into(),
version: "0.1.1".into(),
commit: "3a52abb".into(),
repo: "RunicGateway/servuo-plugins".into(),
protocol: 3,
servuo: ServUoCompat {
min_version: "57.4".into(),
patches_verified_against: "57.4".into(),
},
patch_tier: None,
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}");
}
}

890
src/patch.rs Normal file
View File

@@ -0,0 +1,890 @@
//! The patch tier — resolving and applying diffs against stock ServUO files.
//!
//! Most of the plugin ships as *added* files, which is why the overlay sync is a safe copy. Two
//! features cannot: they need edits to stock ServUO sources, because the events they depend on do
//! not exist (PLAN.md §2.2). This module is the part of the installer that edits a file the
//! operator owns, and it is written to be the most conservative thing in the tool.
//!
//! ## The rung ladder (PLAN.md §2.2.1)
//!
//! A whole-file hash compare answers "is this entire file stock?", which is the wrong question:
//! these patches touch three small regions of three large files, and an operator who added a
//! command to `Logging.cs` has changed its hash without going near the lines the patch edits.
//! Refusing on that would hand most real shards a manual job they did not need. So the decision is
//! made cheapest-and-safest first, and only the last rung gives up:
//!
//! | Rung | Test | Outcome |
//! |---|---|---|
//! | 0 `already-present` | every hunk's *post*-image is in the file | no-op, recorded — keeps re-runs idempotent |
//! | 1 `stock-hash` | the whole file reproduces the diff's `index` pre-image | apply |
//! | 2 `region-match` | every hunk's stock-side region is still byte-identical | apply at the matched offsets |
//! | 3 `region-modified` | anything else | **do not touch the file** — print it for the operator |
//!
//! Rungs 1 and 2 differ only in the *verdict recorded*, never in what is written: both place text
//! by content match, through [`apply`]. A `region-match` apply on a modified file is a different
//! support story from a clean apply to a stock tree, which is why `install.json` keeps them apart.
//!
//! ## The rules that make it safe
//!
//! - **Exact match, not fuzzy.** Only line-ending and trailing-whitespace normalization
//! ([`diff::normalize`]). No `patch --fuzz`, no context reduction — dropping context to force a
//! match is precisely how a hunk lands in the wrong method.
//! - **Exactly one occurrence, or it fails.** Zero means the region moved or was edited; more than
//! one means the anchor is ambiguous and nothing here can know which the author meant. Both are
//! rung 3.
//! - **All-or-nothing per patch file**, and rung 0 likewise: a file where some hunks are present
//! and others are not is a hand-merge in progress, not an idempotent re-run.
//! - **Untouched bytes stay byte-identical.** [`apply`] splices over the matched ranges rather than
//! re-rendering the file from parsed lines, so nothing outside a hunk can be reformatted by
//! accident — and inserted lines take the target file's own dominant line ending, so patching a
//! CRLF file does not leave LF islands in it.
//!
//! ## No `git`
//!
//! PLAN.md §2.2.1 wrote rung 1 as "apply verbatim with `git apply`", but §1 chose the release
//! tarball specifically so there would be **no git on the shard host**, and rung 2 needs a native
//! applier regardless. One engine serves both: rung 1 keeps its distinct, stronger verdict (the
//! whole file reproduced the pre-image hash) while the write goes through the same code path, so
//! there is no second set of CRLF and whitespace behaviours to reason about and a bug report never
//! has to say which engine ran.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::diff::{self, FilePatch, Hunk};
use crate::util::git_blob_hash;
/// How a patch was placed. Recorded in `install.json` and reported by `doctor` and `uninstall`,
/// because the three are different support stories.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rung {
/// Rung 0 — the change is already in the file. Nothing was written.
AlreadyPresent,
/// Rung 1 — the whole file was stock.
StockHash,
/// Rung 2 — the file had been modified, but every patched region was still stock.
RegionMatch,
}
impl Rung {
pub fn as_str(self) -> &'static str {
match self {
Self::AlreadyPresent => "already-present",
Self::StockHash => "stock-hash",
Self::RegionMatch => "region-match",
}
}
/// The one-line explanation an operator reads next to the patch name.
pub fn detail(self) -> &'static str {
match self {
Self::AlreadyPresent => "already applied — nothing to do",
Self::StockHash => "stock file",
Self::RegionMatch => "file modified, patched region stock",
}
}
}
/// Why a patch could not be placed. Every variant is a refusal to write, and each one names
/// something the operator can act on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// The target file is not in the ServUO tree at all.
Missing,
/// A hunk's stock region is not in the file — moved, edited, or already partly merged.
RegionModified { hunk: usize },
/// A hunk's stock region appears more than once, so the anchor cannot identify one place.
Ambiguous { hunk: usize, occurrences: usize },
/// Some hunks are already applied and others are not: a hand-merge in progress, which is the
/// one state where "finish the job" is the most dangerous thing the installer could do.
PartiallyApplied,
/// The file could not be read.
Unreadable(String),
}
impl Refusal {
pub fn detail(&self) -> String {
match self {
Self::Missing => "the file is not in this ServUO tree — not applied".into(),
Self::RegionModified { hunk } => {
format!("patched region has been modified (hunk {hunk}) — not applied")
}
Self::Ambiguous { hunk, occurrences } => {
format!("hunk {hunk}'s region appears {occurrences} times — ambiguous, not applied")
}
Self::PartiallyApplied => {
"partly applied already — a hand merge in progress, not touched".into()
}
Self::Unreadable(why) => format!("cannot be read ({why}) — not applied"),
}
}
}
/// What resolving one patch against one tree concluded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
/// Nothing to do — the change is already in the file.
AlreadyPresent { hunks: Vec<HunkPlacement> },
/// The patch can be placed. `edits` are byte ranges in the current file content, highest offset
/// first, so applying them in order keeps every later offset valid.
Applicable {
rung: Rung,
hunks: Vec<HunkPlacement>,
edits: Vec<Edit>,
},
/// The patch will not be placed, and why.
Refused(Refusal),
}
impl Resolution {
pub fn rung(&self) -> Option<Rung> {
match self {
Self::AlreadyPresent { .. } => Some(Rung::AlreadyPresent),
Self::Applicable { rung, .. } => Some(*rung),
Self::Refused(_) => None,
}
}
pub fn placements(&self) -> &[HunkPlacement] {
match self {
Self::AlreadyPresent { hunks } | Self::Applicable { hunks, .. } => hunks,
Self::Refused(_) => &[],
}
}
}
/// Where a hunk was found, for the record and for the report.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HunkPlacement {
/// The `@@` header's stock line number — what the patch author saw.
pub declared_line: usize,
/// The 1-based line the region was actually found at. Insertions above the region shift this,
/// which is exactly why the match is by content and this number is an output rather than an
/// input.
pub matched_line: usize,
}
/// A byte range of the file to replace with `replacement`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub start: usize,
pub end: usize,
pub replacement: Vec<u8>,
}
/// Resolves one patch against one file's current content.
///
/// The order is the ladder's: rung 0 first (so a re-run is a no-op rather than a second apply),
/// then the whole-file hash, then the region match. `pre_blob` is the diff's `index` pre-image; a
/// patch without an `index` line simply cannot reach rung 1, which is not a defect — rung 2 is the
/// stronger check anyway.
pub fn resolve(file: &FilePatch, content: &[u8]) -> Resolution {
let hunks: Vec<&Hunk> = file.hunks.iter().filter(|h| !h.is_noop()).collect();
if hunks.is_empty() {
return Resolution::Refused(Refusal::RegionModified { hunk: 1 });
}
let lines = diff::split_lines(content);
let normalized: Vec<&[u8]> = lines.iter().map(|l| diff::normalize(l)).collect();
// ── Rung 0 ───────────────────────────────────────────────────────────────
// All-or-nothing: a file where some hunks are present and others are not is a hand-merge in
// progress, and "finish it" is the one thing that must not happen automatically.
let post_hits: Vec<Option<Vec<usize>>> = hunks
.iter()
.map(|h| find_block(&normalized, &normalize_all(&h.post_image())))
.collect();
let present = post_hits
.iter()
.filter(|hit| hit.as_ref().is_some_and(|m| m.len() == 1))
.count();
if present == hunks.len() {
return Resolution::AlreadyPresent {
hunks: hunks
.iter()
.zip(&post_hits)
.map(|(h, hit)| HunkPlacement {
declared_line: h.old_start,
matched_line: hit.as_ref().map(|m| m[0] + 1).unwrap_or(0),
})
.collect(),
};
}
if present > 0 {
return Resolution::Refused(Refusal::PartiallyApplied);
}
// ── Rungs 1 and 2 ────────────────────────────────────────────────────────
// Both place text by content match; only the verdict differs. A file whose hash says "stock"
// must match by content too — if it somehow did not, the honest answer is rung 3, not a write
// made on the strength of a hash alone.
let stock = file
.pre_blob
.as_deref()
.is_some_and(|expected| git_blob_hash(content).starts_with(expected));
let eol = dominant_eol(content);
let mut placements = Vec::with_capacity(hunks.len());
let mut edits = Vec::with_capacity(hunks.len());
for (index, hunk) in hunks.iter().enumerate() {
let needle = normalize_all(&hunk.pre_image());
let matches = match find_block(&normalized, &needle) {
Some(m) => m,
None => return Resolution::Refused(Refusal::RegionModified { hunk: index + 1 }),
};
if matches.len() > 1 {
return Resolution::Refused(Refusal::Ambiguous {
hunk: index + 1,
occurrences: matches.len(),
});
}
let at = matches[0];
placements.push(HunkPlacement {
declared_line: hunk.old_start,
matched_line: at + 1,
});
edits.push(splice(content, &lines, at, needle.len(), hunk, eol));
}
// Two hunks resolving onto overlapping text would corrupt the file even though each matched
// uniquely. It cannot happen with a well-formed diff — git never emits overlapping hunks — but
// a hand-assembled one could, and the cost of the check is nothing.
let mut ordered: Vec<&Edit> = edits.iter().collect();
ordered.sort_by_key(|e| e.start);
if ordered.windows(2).any(|w| w[0].end > w[1].start) {
return Resolution::Refused(Refusal::RegionModified { hunk: 1 });
}
// Highest offset first, so applying one edit never invalidates the next one's range.
edits.sort_by_key(|e| std::cmp::Reverse(e.start));
Resolution::Applicable {
rung: if stock {
Rung::StockHash
} else {
Rung::RegionMatch
},
hunks: placements,
edits,
}
}
/// Builds the replacement for one hunk: the byte range the matched pre-image occupies, and the
/// post-image rendered with the file's own line ending.
///
/// Working in byte ranges rather than rebuilding the file from parsed lines is what guarantees the
/// rest of the file comes out unchanged down to the byte — including any mixed line endings,
/// trailing whitespace, or unusual encoding elsewhere in it, none of which is this tool's business.
fn splice(
content: &[u8],
lines: &[&[u8]],
at: usize,
span: usize,
hunk: &Hunk,
eol: &[u8],
) -> Edit {
let start = offset_of(content, lines, at);
let last = at + span - 1;
// The end of the matched block, terminator included — unless it is the file's last line and
// the file has no trailing newline.
let end = if last + 1 < lines.len() {
offset_of(content, lines, last + 1)
} else {
content.len()
};
let trailing_newline = end > 0 && content[end - 1] == b'\n';
let post = hunk.post_image();
let mut replacement = Vec::with_capacity(end - start);
for (i, line) in post.iter().enumerate() {
replacement.extend_from_slice(line);
let is_last = i + 1 == post.len();
// The last line keeps whatever the region it replaces had: a hunk in the middle of a file
// is always terminated, and one at the end of a file with no final newline must not gain
// one. `\ No newline at end of file` on the new side says the same thing explicitly.
if !is_last || (trailing_newline && !hunk.new_no_newline) {
replacement.extend_from_slice(eol);
}
}
Edit {
start,
end,
replacement,
}
}
/// The byte offset at which line `index` starts.
///
/// Derived from the slice's position inside the buffer rather than by re-scanning: [`split_lines`]
/// borrows from `content`, so the arithmetic is exact and cannot disagree with the split.
fn offset_of(content: &[u8], lines: &[&[u8]], index: usize) -> usize {
if index >= lines.len() {
return content.len();
}
lines[index].as_ptr() as usize - content.as_ptr() as usize
}
/// The line ending the file mostly uses, for inserted lines.
///
/// The three files this tier edits are CRLF. Writing LF into them would leave islands of the wrong
/// ending inside a method — harmless to the C# compiler, and a permanent source of noise in every
/// diff the operator takes afterwards.
fn dominant_eol(content: &[u8]) -> &'static [u8] {
let total = content.iter().filter(|b| **b == b'\n').count();
let crlf = content.windows(2).filter(|w| w == b"\r\n").count();
if total > 0 && crlf * 2 >= total {
b"\r\n"
} else {
b"\n"
}
}
fn normalize_all<'a>(lines: &[&'a [u8]]) -> Vec<&'a [u8]> {
lines.iter().map(|l| diff::normalize(l)).collect()
}
/// Every starting line at which `needle` appears in `haystack`, comparing normalized content.
///
/// Returns `None` for an empty needle rather than "matches everywhere", which is the difference
/// between refusing a degenerate hunk and splicing at line 1 of the file.
fn find_block(haystack: &[&[u8]], needle: &[&[u8]]) -> Option<Vec<usize>> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
let hits: Vec<usize> = (0..=haystack.len() - needle.len())
.filter(|start| haystack[*start..*start + needle.len()] == *needle)
.collect();
if hits.is_empty() {
None
} else {
Some(hits)
}
}
/// Applies the edits of an [`Resolution::Applicable`] to a buffer.
pub fn apply(content: &[u8], edits: &[Edit]) -> Vec<u8> {
let mut out = content.to_vec();
// The edits arrive highest-offset-first from `resolve`, so each splice leaves every remaining
// range valid. Re-sorting here rather than trusting the caller keeps that a local property.
let mut ordered: Vec<&Edit> = edits.iter().collect();
ordered.sort_by_key(|e| std::cmp::Reverse(e.start));
for edit in ordered {
out.splice(edit.start..edit.end, edit.replacement.iter().copied());
}
out
}
// ─────────────────────────────────────────────────────────────────────────────
// The tier's declared shape
// ─────────────────────────────────────────────────────────────────────────────
/// The patch tier as the overlay release declares it (`patch_tier` in `manifest.json`, generated
/// from `servuo-plugins/patches/tier.json`).
///
/// A `.patch` does not carry enough on its own: which patches form one all-or-nothing unit, which
/// companion `.cs` may only be copied once that unit lands, whether a **core** solution rebuild is
/// needed, and what the operator loses by declining are all things the diffs cannot say. Declaring
/// them in the release means adding a patch regenerates release metadata rather than requiring an
/// installer release — the same rule PLAN.md §7.1 applies to the bundle.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tier {
pub features: Vec<Feature>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Feature {
pub name: String,
/// What the operator gains, for the offer.
pub summary: String,
/// What they lose by declining, phrased to complete "Without it: …".
pub lost: String,
/// `core` — the ServUO solution must be rebuilt (`dotnet build ServUO.sln`); the dynamic script
/// build is not enough. `scripts` — a shard restart suffices.
pub rebuild: Rebuild,
pub patches: Vec<PatchRef>,
pub companions: Vec<Companion>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Rebuild {
Core,
Scripts,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PatchRef {
pub name: String,
/// Relative to the extracted tarball root, i.e. `patches/<file>.patch`.
pub file: String,
/// Relative to the ServUO root, `/`-separated.
pub target: String,
}
/// A source file that may only be copied once its feature's patches have landed, because it
/// references symbols they introduce. Shipping these in the base overlay would break the build on
/// every unpatched install, which is why they live in `patches/`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Companion {
/// Relative to the extracted tarball root.
pub file: String,
/// Relative to the ServUO root.
pub install_to: String,
}
impl Tier {
/// The tier an overlay declares, or the built-in description of the one that shipped before
/// `patch_tier` existed.
///
/// The fallback is not a convenience: overlay `v0.1.1` is in the current bundle and declares
/// nothing, so without it this whole phase would be unusable until a new overlay release
/// existed. It describes exactly the three patches that release ships. An overlay that declares
/// its own tier always wins, so the fallback goes quiet the moment it is wrong.
pub fn resolve(declared: Option<&Tier>) -> Tier {
declared.cloned().unwrap_or_else(Tier::builtin)
}
/// Mirrors `servuo-plugins/patches/tier.json`, which is the source of truth. Only reached for
/// an overlay released before that file existed.
pub fn builtin() -> Tier {
Tier {
features: vec![
Feature {
name: "vendor-sale".into(),
summary: "vendor.sale events — player-vendor purchases with buyer, owner, \
item, price and commission"
.into(),
lost: "no vendor.sale events".into(),
rebuild: Rebuild::Core,
patches: vec![
PatchRef {
name: "playervendor-sale-eventsink".into(),
file: "patches/playervendor-sale-eventsink.patch".into(),
target: "Server/EventSink.cs".into(),
},
PatchRef {
name: "playervendor-sale-gump".into(),
file: "patches/playervendor-sale-gump.patch".into(),
target: "Scripts/Gumps/PlayerVendorGumps.cs".into(),
},
],
companions: vec![Companion {
file: "patches/BridgeVendorSale.cs".into(),
install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(),
}],
},
Feature {
name: "moderation-audit".into(),
summary: "in-game moderation actions ([ban, [kick, [bcast) forwarded to the \
website as admin.audit"
.into(),
lost: "no in-game moderation audit forwarding".into(),
rebuild: Rebuild::Scripts,
patches: vec![PatchRef {
name: "commandlogging-event".into(),
file: "patches/commandlogging-event.patch".into(),
target: "Scripts/Commands/Logging.cs".into(),
}],
companions: vec![Companion {
file: "patches/BridgeModerationAudit.cs".into(),
install_to: "Scripts/Custom/Bridge/BridgeModerationAudit.cs".into(),
}],
},
],
}
}
pub fn patch_count(&self) -> usize {
self.features.iter().map(|f| f.patches.len()).sum()
}
}
/// Joins a `/`-separated relative path onto a root, using this platform's separator.
pub fn join(root: &Path, rel: &str) -> PathBuf {
root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
}
/// Reads and parses one declared patch, checking that the diff edits the file the tier says it
/// does.
///
/// The cross-check is not redundant with the release gate: the gate gives up if a patch is renamed
/// in one place and not the other, but an operator can also be running an installer against an
/// overlay whose tier declaration and patches were assembled by hand. The installer is about to
/// edit a stock file on the strength of that pairing, so it verifies it rather than assuming.
pub fn load(unpacked: &Path, patch: &PatchRef) -> Result<(Vec<u8>, FilePatch)> {
let path = join(unpacked, &patch.file);
let bytes = std::fs::read(&path)
.with_context(|| format!("cannot read {} from the overlay release", patch.file))?;
let parsed = diff::parse(&bytes)
.with_context(|| format!("{} is not a patch this installer can read", patch.file))?;
let file = parsed
.single_file()
.with_context(|| format!("{} cannot be applied as a single-target patch", patch.file))?
.clone();
if file.path != patch.target {
bail!(
"{} edits {} but the overlay declares its target as {} — refusing to patch a file the \
release does not claim it patches",
patch.file,
file.path,
patch.target
);
}
Ok((bytes, file))
}
/// The applied patch tier, as `install.json` records it.
///
/// Only features that are actually in place are recorded: a declined or refused feature left no
/// trace in the tree, and a record of it would make `doctor` and `uninstall` report work that was
/// never done.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeatureRecord {
pub feature: String,
pub rebuild: Rebuild,
/// The ServUO version detected when the tier ran, and whether it ran on an unsupported one.
///
/// This is the label that follows the install (PLAN.md §2.2.2): `doctor` shows it on every
/// later run and the uninstall report carries it, so whoever inherits this shard can see it
/// without being told.
pub servuo_version: Option<String>,
pub unsupported_servuo: bool,
pub patches: Vec<AppliedPatch>,
pub companions: Vec<CompanionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppliedPatch {
pub name: String,
/// Relative to the ServUO root.
pub target: String,
/// `stock-hash`, `region-match` or `already-present`.
pub rung: String,
/// SHA256 of the `.patch` file, which is also cached beside `install.json`.
pub sha256: String,
pub hunks: Vec<HunkPlacement>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionRecord {
/// Relative to the ServUO root.
pub path: String,
pub sha256: String,
}
/// Indexes recorded features by name, for the idempotence rule in `install.rs`.
pub fn index_records(records: &[FeatureRecord]) -> BTreeMap<&str, &FeatureRecord> {
records
.iter()
.map(|r| (r.feature.as_str(), r))
.collect::<BTreeMap<_, _>>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::parse;
/// A file with the patched region buried in enough surrounding text that a match is meaningful.
fn stock_file() -> Vec<u8> {
let mut s = String::new();
for i in 0..40 {
s.push_str(&format!("// filler {i}\n"));
}
s.push_str(" public void Alpha()\n");
s.push_str(" {\n");
s.push_str(" Work();\n");
s.push_str(" }\n");
for i in 0..40 {
s.push_str(&format!("// tail {i}\n"));
}
s.into_bytes()
}
/// Adds a line inside the patched region.
const PATCH: &str = "\
--- a/Target.cs
+++ b/Target.cs
@@ -41,4 +41,5 @@
public void Alpha()
{
+ Hook();
Work();
}
";
fn file_patch(text: &str) -> FilePatch {
parse(text.as_bytes())
.unwrap()
.single_file()
.unwrap()
.clone()
}
fn applied(patch: &FilePatch, content: &[u8]) -> Vec<u8> {
match resolve(patch, content) {
Resolution::Applicable { edits, .. } => apply(content, &edits),
other => panic!("expected an applicable resolution, got {other:?}"),
}
}
#[test]
fn a_stock_file_reaches_rung_one_and_applies() {
let content = stock_file();
// A real diff's index line names the stock blob; build one so the hash rung is exercised
// against a hash this test did not also invent.
let patch = file_patch(&PATCH.replace(
"--- a/Target.cs",
&format!(
"index {}..0000000 100644\n--- a/Target.cs",
git_blob_hash(&content)
),
));
let resolution = resolve(&patch, &content);
assert_eq!(resolution.rung(), Some(Rung::StockHash));
let out = apply(
&content,
match &resolution {
Resolution::Applicable { edits, .. } => edits,
_ => unreachable!(),
},
);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains(" Hook();\n Work();\n"),
"{text}"
);
}
#[test]
fn an_edit_far_from_the_region_still_reaches_rung_two() {
// The whole point of the ladder: most real shards are hand-modified somewhere, and
// refusing on a whole-file hash would hand them a manual job they did not need.
let mut content = stock_file();
content.extend_from_slice(b"// the operator added their own command down here\n");
let patch = file_patch(PATCH);
let resolution = resolve(&patch, &content);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch));
assert_eq!(resolution.placements()[0].matched_line, 41);
assert!(String::from_utf8(applied(&patch, &content))
.unwrap()
.contains("Hook();"));
}
#[test]
fn an_edit_inside_the_region_is_refused_and_writes_nothing() {
let content = String::from_utf8(stock_file())
.unwrap()
.replace(" Work();", " Work(withMyArgument);")
.into_bytes();
match resolve(&file_patch(PATCH), &content) {
Resolution::Refused(Refusal::RegionModified { hunk }) => assert_eq!(hunk, 1),
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_region_that_appears_twice_fails_rather_than_picking_the_first() {
// The rule that keeps a hunk out of the wrong method. Both copies are legitimate code;
// nothing here can know which one the patch author meant, so it must not guess.
let mut content = stock_file();
content.extend_from_slice(
b" public void Alpha()\n {\n Work();\n }\n",
);
match resolve(&file_patch(PATCH), &content) {
Resolution::Refused(Refusal::Ambiguous { occurrences, .. }) => {
assert_eq!(occurrences, 2)
}
other => panic!("expected an ambiguity refusal, got {other:?}"),
}
}
#[test]
fn an_already_patched_file_is_rung_zero_and_a_re_run_is_a_no_op() {
let content = stock_file();
let patch = file_patch(PATCH);
let once = applied(&patch, &content);
let resolution = resolve(&patch, &once);
assert_eq!(resolution.rung(), Some(Rung::AlreadyPresent));
assert!(matches!(resolution, Resolution::AlreadyPresent { .. }));
// ...and there is nothing to apply, so a third run cannot double up.
assert_eq!(applied(&patch, &content), once);
}
#[test]
fn a_half_merged_file_is_refused() {
// Some hunks present, others not. "Finish the job" is the most dangerous thing available
// here, because the operator is evidently mid-merge.
let two_hunks = "\
--- a/Target.cs
+++ b/Target.cs
@@ -41,2 +41,3 @@
public void Alpha()
{
+ Hook();
@@ -60,1 +61,2 @@
// tail 15
+// second hook
";
let content = stock_file();
let patch = file_patch(two_hunks);
let both = applied(&patch, &content);
// Undo only the second hunk, leaving the first in place.
let half = String::from_utf8(both)
.unwrap()
.replace("// tail 15\n// second hook\n", "// tail 15\n")
.into_bytes();
assert!(matches!(
resolve(&patch, &half),
Resolution::Refused(Refusal::PartiallyApplied)
));
}
#[test]
fn a_crlf_file_keeps_its_line_endings_and_its_other_bytes() {
// All three ServUO files this tier edits are CRLF. Inserting LF lines into one would leave
// islands of the wrong ending inside a method and pollute every later diff.
let content = String::from_utf8(stock_file())
.unwrap()
.replace('\n', "\r\n")
.into_bytes();
let out = applied(&file_patch(PATCH), &content);
assert!(!String::from_utf8_lossy(&out).contains("Hook();\n Work"));
assert!(String::from_utf8_lossy(&out).contains("Hook();\r\n Work"));
// Every line is still CRLF — no islands.
assert_eq!(
out.iter().filter(|b| **b == b'\n').count(),
out.windows(2).filter(|w| w == b"\r\n").count()
);
}
#[test]
fn everything_outside_the_hunk_comes_back_byte_identical() {
// The splice, not a re-render. Unusual bytes elsewhere in the file are the operator's
// business and must survive untouched.
let mut content = stock_file();
content.extend_from_slice(b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n");
let out = applied(&file_patch(PATCH), &content);
let tail = b"// trailing spaces \r\n// \xe2\x80\x94 em dash\n// \x92\n";
assert!(out.ends_with(tail));
assert_eq!(out.len(), content.len() + b" Hook();\n".len());
}
#[test]
fn a_file_without_a_trailing_newline_does_not_gain_one() {
let content = b"alpha\nbeta".to_vec();
let patch = file_patch("--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n alpha\n+middle\n beta\n");
let out = applied(&patch, &content);
assert_eq!(out, b"alpha\nmiddle\nbeta");
}
#[test]
fn trailing_whitespace_differences_do_not_block_a_match() {
// Editors and mail transports strip trailing whitespace routinely; PLAN.md §2.2.1 allows
// exactly this much normalization and no more.
let content = String::from_utf8(stock_file())
.unwrap()
.replace(" {\n", " { \n")
.into_bytes();
assert!(resolve(&file_patch(PATCH), &content).rung().is_some());
}
#[test]
fn a_hash_that_says_stock_but_content_that_does_not_is_refused() {
// A contradiction: the file claims to be the pre-image but the region is not there. The
// only safe reading is rung 3 — a write made on the strength of a hash alone is exactly
// what the ladder exists to avoid.
let content = b"nothing like the patched file at all\n".to_vec();
let patch = file_patch(&PATCH.replace(
"--- a/Target.cs",
&format!(
"index {}..0000000 100644\n--- a/Target.cs",
git_blob_hash(&content)
),
));
assert!(matches!(
resolve(&patch, &content),
Resolution::Refused(Refusal::RegionModified { .. })
));
}
#[test]
fn the_builtin_tier_describes_the_release_that_predates_the_declaration() {
// Mirrors servuo-plugins/patches/tier.json. If that file gains a feature, this fallback is
// only ever used for older overlays, which do not have it — so it stays as it is.
let tier = Tier::resolve(None);
assert_eq!(tier.features.len(), 2);
assert_eq!(tier.patch_count(), 3);
let vendor = &tier.features[0];
assert_eq!(vendor.rebuild, Rebuild::Core, "EventSink.cs is a core file");
assert_eq!(
vendor.patches.len(),
2,
"the two vendor patches are one unit"
);
for feature in &tier.features {
assert!(!feature.companions.is_empty());
for companion in &feature.companions {
assert!(companion.file.starts_with("patches/"), "{companion:?}");
assert!(
companion.install_to.starts_with("Scripts/Custom/Bridge/"),
"{companion:?}"
);
}
}
}
#[test]
fn a_declared_tier_wins_over_the_builtin_one() {
let declared = Tier {
features: vec![Feature {
name: "future-feature".into(),
summary: "something later".into(),
lost: "nothing yet".into(),
rebuild: Rebuild::Scripts,
patches: vec![],
companions: vec![],
}],
};
assert_eq!(Tier::resolve(Some(&declared)), declared);
}
#[test]
fn the_tier_round_trips_through_the_manifests_json_shape() {
// The exact shape servuo-plugins/patches/tier.json produces, so a mismatch shows up here
// rather than as a silently empty tier on an operator's shard.
let json = r#"{
"features": [{
"name": "moderation-audit",
"summary": "in-game moderation actions forwarded as admin.audit",
"lost": "no in-game moderation audit forwarding",
"rebuild": "scripts",
"patches": [{
"name": "commandlogging-event",
"file": "patches/commandlogging-event.patch",
"target": "Scripts/Commands/Logging.cs"
}],
"companions": [{
"file": "patches/BridgeModerationAudit.cs",
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
}]
}]
}"#;
let tier: Tier = serde_json::from_str(json).unwrap();
assert_eq!(tier.features[0].rebuild, Rebuild::Scripts);
assert_eq!(tier.patch_count(), 1);
assert_eq!(
tier.features[0].patches[0].target,
"Scripts/Commands/Logging.cs"
);
}
}

208
src/paths.rs Normal file
View File

@@ -0,0 +1,208 @@
//! 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 pins the config path into the service definition.
//!
//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The
//! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml`
//! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the
//! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the
//! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries
//! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which
//! every process on the host inherits and which outlives an uninstall.
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.
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
pub sidecar_bin: PathBuf,
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
/// deployment. Service registration is skipped when it is set — see [`layout`].
pub relocated: bool,
}
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")
}
/// `/etc/runicgateway/patches` — the cached patch set (INSTALL.md §3).
///
/// Every patch the tier *evaluated* is cached here, not only the ones that applied. `uninstall`
/// needs the applied ones to print the exact hunks to revert long after the release tarball is
/// gone (PLAN.md §5), and a refused one is the file the run just told the operator to apply by
/// hand — pointing them at a path that only exists on success would be the less useful half.
pub fn patches_dir(&self) -> PathBuf {
self.state_dir.join("patches")
}
/// `/etc/runicgateway/patches/originals` — each patched file exactly as it was before the tier
/// first touched it, mirroring its path in the ServUO tree.
///
/// The tier edits files the operator owns, so the pre-image is what turns "here are the hunks
/// we added" into a revert anyone can verify. It lives here rather than beside the file it
/// copies, because an installer-owned file inside the ServUO tree is one `uninstall` has
/// promised never to clean up.
pub fn patch_originals_dir(&self) -> PathBuf {
self.patches_dir().join("originals")
}
/// `/etc/runicgateway/backups` — one dated directory per run that overwrote something
/// (PLAN.md §5.3).
///
/// Beside the cached patch set rather than inside it: both survive an uninstall and both go
/// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy
/// of what the *release* shipped.
pub fn backups_dir(&self) -> PathBuf {
self.state_dir.join("backups")
}
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
/// layout, where no service is registered at all.
pub fn systemd_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
///
/// The override moves **everything the installer would write**: state, data, and the sidecar
/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that
/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the
/// half-in-the-real-system accident this variable exists to avoid.
///
/// A relocated layout also **suppresses service registration** (see `service::ensure`). There is
/// no such thing as a relocated systemd unit or a relocated Windows service — both are
/// system-global — so the honest behaviour is to install the files, say plainly that no service was
/// registered, and print what a real run would have done.
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);
// The file name is kept so a relocated run installs the same binary name a real one would,
// which is what makes `--print-config` and `--version` output comparable between the two.
let bin_name = layout
.sidecar_bin
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
layout.data_dir = root.join("data");
layout.sidecar_bin = root.join("bin").join(bin_name);
layout.state_dir = root;
layout.relocated = true;
}
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"),
relocated: false,
}
}
#[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"),
relocated: false,
}
}
#[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 and the pre-image
// copies of every patched file live there too.
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);
assert!(!l.relocated);
}
#[test]
fn the_windows_database_lands_beside_its_config_by_default() {
// The Windows service pins only the config path; the database follows because the sidecar
// anchors a relative [store].path to the config's directory. That only holds while these
// two directories are the same one, so it is asserted rather than assumed.
#[cfg(windows)]
{
let l = platform_layout();
assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent());
}
}
#[test]
fn a_relocated_layout_moves_the_binary_too() {
// The failure this prevents: a test run that writes its config and database under the
// override while still dropping a binary into /usr/bin or %ProgramFiles%.
let mut l = platform_layout();
let real_bin = l.sidecar_bin.clone();
let root = std::env::temp_dir().join("rg-layout-test");
let bin_name = l.sidecar_bin.file_name().map(PathBuf::from).unwrap();
l.data_dir = root.join("data");
l.sidecar_bin = root.join("bin").join(&bin_name);
l.state_dir = root.clone();
l.relocated = true;
assert!(l.install_record().starts_with(&root));
assert!(l.sidecar_config().starts_with(&root));
assert!(l.sidecar_db().starts_with(&root));
assert!(l.sidecar_bin.starts_with(&root));
assert_ne!(l.sidecar_bin, real_bin);
assert_eq!(l.sidecar_bin.file_name(), real_bin.file_name());
}
}

387
src/record.rs Normal file
View File

@@ -0,0 +1,387 @@
//! `install.json` — what this host has deployed.
//!
//! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later
//! command reasons from. Two of its properties are load-bearing rather than informational:
//!
//! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the
//! record and the release manifest means the overlay moved on; differing from the record alone
//! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and
//! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on.
//! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on
//! a host where Phase 2 and 3 have written sidecar and patch records must give them back
//! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved
//! verbatim. A future field that silently vanished on a re-run would be worse than one that was
//! never written.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::util::write_atomic;
/// The shape of this document. Independent of the bundle's `schema` and of any protocol version.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallRecord {
pub schema: u32,
pub installer: InstallerInfo,
/// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one
/// excluded when deciding whether a re-run has anything to record.
pub updated: String,
pub bundle: BundleRef,
pub servuo: ServUoRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub overlay: Option<OverlayRecord>,
/// The sidecar: binary, config, database, service (Phase 2).
///
/// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer*
/// installer — with fields this build has no name for — survives a re-run here intact. Phase 1
/// carried this section through without understanding it at all; the same tolerance now applies
/// in the other direction. Read it with [`InstallRecord::link_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<serde_json::Value>,
/// The patch tier: one entry per feature actually in place, with the rung that applied each of
/// its patches.
///
/// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer
/// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`].
/// Only features that are *applied* appear: a declined or refused one left no trace in the
/// tree, and recording it would make `doctor` and `uninstall` report work nobody did.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patches: Vec<serde_json::Value>,
/// Anything a newer installer wrote that this one has no name for.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallerInfo {
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BundleRef {
pub tag: String,
pub protocol: u32,
/// The exact document this install resolved, so a re-install can be reproduced and a support
/// question about "which bundle?" is answered by the file rather than by memory.
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServUoRef {
pub path: String,
/// The detected version, or `null`. Recorded even when unknown: the patch tier's support story
/// follows the install (§2.2.2), and a later `doctor` must be able to show it without
/// re-deriving it.
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OverlayRecord {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
/// Keyed by ServUO-tree-relative path, always with `/` separators so a record written on
/// Windows is readable on Linux and vice versa.
pub files: BTreeMap<String, FileRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileRecord {
/// What the release shipped for this path.
pub overlay_sha256: String,
/// What is on disk in the ServUO tree after this run. Equal to `overlay_sha256` except for a
/// file the installer deliberately left alone (`Config/Bridge.cfg`, once edited).
pub on_disk_sha256: String,
/// `deployed` (the tree holds the release's copy) or `kept-operator-modified` (it holds the
/// operator's).
///
/// Deliberately a **state, not a verb**: recording `add` on the first run and `unchanged` on
/// the next would make every re-run rewrite this file, which is exactly the "a second run
/// writes nothing" promise in PLAN.md Phase 1. What later commands need to know is whose copy
/// is in the tree, and that does not change just because time passed.
pub state: String,
}
/// The sidecar half of a deployment, as `install.json` records it.
///
/// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once
/// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted
/// into bug reports.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkRecord {
pub repo: String,
pub tag: String,
/// What the installed binary reports, not what the bundle claimed — the two agree, and if they
/// ever did not, the binary is the one that will actually run.
pub version: String,
pub protocol: u32,
pub binary: BinaryRef,
pub config_path: String,
/// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's
/// directory rather than pinned by the service, which is why it is recorded rather than derived.
pub db_path: String,
/// `None` when no service was registered — a relocated test run, or a host with no service
/// manager the installer can drive. `doctor` reports that as an unfinished install rather than
/// as a healthy one.
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BinaryRef {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceRecord {
/// `systemd` or `windows-scm`.
pub kind: String,
/// `runicgateway-link.service` or `RunicGatewayLink`.
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit_path: Option<String>,
/// The account the service runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// The installer created that account. `uninstall` (Phase 4) removes only what it created —
/// deleting a user that was already on the host is not this tool's business.
pub user_created: bool,
}
impl InstallRecord {
/// Whether a re-run would record anything new.
///
/// Everything except `updated` is compared: PLAN.md Phase 1 requires that a second run with no
/// upstream change reports "unchanged" and **writes nothing**, and rewriting the file purely to
/// move a timestamp would break that promise in the least visible way possible — by touching a
/// file whose mtime an operator may be watching.
pub fn same_deployment_as(&self, other: &Self) -> bool {
let mut a = self.clone();
let mut b = other.clone();
a.updated.clear();
b.updated.clear();
a == b
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let record: Self = serde_json::from_str(&body).with_context(|| {
format!(
"{} exists but is not a record this installer understands. \
Move it aside to start over, or install a newer installer.",
path.display()
)
})?;
Ok(Some(record))
}
pub fn save(&self, path: &Path) -> Result<()> {
// Pretty-printed with a trailing newline: this file is read by humans during support, and
// diffed by anyone who keeps /etc under version control.
let mut body =
serde_json::to_string_pretty(self).context("cannot serialize install.json")?;
body.push('\n');
write_atomic(path, body.as_bytes())
}
/// Files this installer previously deployed, for the `Bridge.cfg` comparison in `overlay::plan`.
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
self.overlay.as_ref().map(|o| &o.files)
}
/// The sidecar section, when it is one this build understands.
///
/// A section it cannot parse yields `None` rather than an error: the raw value is still carried
/// through on save, so the worst case is that this run re-derives what it needs instead of
/// reading it — never that an older installer refuses to run on a newer host.
pub fn link_record(&self) -> Option<LinkRecord> {
serde_json::from_value(self.link.clone()?).ok()
}
/// The patch-tier entries this build understands.
///
/// An entry it cannot parse is dropped from the returned list but still carried through on
/// save, exactly as with [`Self::link_record`]. The consequence of a dropped entry is that this
/// run re-derives that feature's state from the tree — which the rung ladder answers correctly
/// on its own — rather than an older installer refusing to run on a newer host.
pub fn patch_records(&self) -> Vec<crate::patch::FeatureRecord> {
self.patches
.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
}
}
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 the_link_section_round_trips_and_holds_no_secret() {
let link = LinkRecord {
repo: "RunicGateway/link".into(),
tag: "v1.1.0".into(),
version: "1.1.0".into(),
protocol: 3,
binary: BinaryRef {
path: "/usr/bin/runicgateway-link".into(),
sha256: "27d491ef".repeat(8),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: Some(ServiceRecord {
kind: "systemd".into(),
name: "runicgateway-link.service".into(),
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
user: Some("runicgateway".into()),
user_created: true,
}),
};
let mut record = sample();
record.link = Some(serde_json::to_value(&link).unwrap());
assert_eq!(record.link_record().unwrap(), link);
// install.json is pasted into bug reports. The token lives in sidecar.toml and on the
// operator's terminal; there is no field here for it to arrive in.
let text = serde_json::to_string(&record).unwrap();
assert!(!text.contains("auth_token"), "{text}");
assert!(!text.contains("token"), "{text}");
}
#[test]
fn an_unreadable_link_section_is_ignored_rather_than_fatal() {
// A record written by a future installer must not stop this one from running.
let mut record = sample();
record.link = Some(serde_json::json!({ "shape": "from a newer installer" }));
assert!(record.link_record().is_none());
assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok());
}
#[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}");
}
}

1076
src/service.rs Normal file

File diff suppressed because it is too large Load Diff

402
src/servuo.rs Normal file
View File

@@ -0,0 +1,402 @@
//! Finding, validating and interrogating a ServUO installation.
//!
//! Three questions, in the order the installer asks them:
//!
//! 1. **Where is it?** `--servuo`, else detection from where the binary was run, else a prompt.
//! 2. **Is it really one?** `ServUO.exe`, `Scripts/` and `Config/` must all be present
//! (INSTALL.md §2). Deploying 24 files into a directory that merely looked plausible is a mess
//! to unpick by hand.
//! 3. **Is it running?** If it is, the run stops. `deploy.ps1` hard-throws here and the installer
//! inherits that (PLAN.md §2.5): ServUO holds `Scripts.dll` open and rewrites `Saves/` on exit,
//! so deploying underneath it corrupts one or both.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// The version everything is designed, built and tested against (PLAN.md §2.2.2).
pub const SUPPORTED_VERSION: &str = "57.4";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServUoRoot {
pub path: PathBuf,
/// `None` when `Server/AssemblyInfo.cs` is absent or unparseable. Reported as "unknown", which
/// is treated exactly like any other non-57.4 answer: the base install proceeds, and the patch
/// tier takes its unsupported path.
pub version: Option<String>,
}
impl ServUoRoot {
/// Whether this tree is the one supported version. `None` (unknown) is deliberately **not**
/// supported: an unreadable version is not evidence of a good one.
pub fn is_supported_version(&self) -> bool {
self.version
.as_deref()
.map(normalize_version)
.as_deref()
.map(|v| v == SUPPORTED_VERSION)
.unwrap_or(false)
}
pub fn version_display(&self) -> String {
self.version.clone().unwrap_or_else(|| "unknown".into())
}
}
/// Validates a candidate directory and reads its version.
pub fn open(path: &Path) -> Result<ServUoRoot> {
if !path.exists() {
bail!("no such directory: {}", path.display());
}
if !path.is_dir() {
bail!("not a directory: {}", path.display());
}
if !looks_like_root(path) {
bail!(
"{} does not look like a ServUO root — it must contain ServUO.exe, Scripts/ and Config/",
path.display()
);
}
// Canonicalized so the path recorded in install.json is stable across runs started from
// different working directories. Windows' \\?\ prefix is stripped: it is correct but appears
// in every printed line and in the operator's copy-pasted report.
let path = fs::canonicalize(path)
.map(strip_extended_prefix)
.unwrap_or_else(|_| path.to_path_buf());
let version = read_version(&path);
Ok(ServUoRoot { path, version })
}
/// The membership test from INSTALL.md §2 — all three, not any.
pub fn looks_like_root(path: &Path) -> bool {
path.join("ServUO.exe").is_file()
&& path.join("Scripts").is_dir()
&& path.join("Config").is_dir()
}
/// Looks for a ServUO root around where the installer was run.
///
/// "Run from inside it or from an obvious sibling" (INSTALL.md §2) means: the working directory or
/// one of its parents, then the directory holding the binary or one of its parents — an operator
/// who `scp`'d the installer into the server root and ran it there should not be asked where the
/// server root is. Parents are walked because `cd Scripts && ../installer` is a normal thing to do.
/// Nothing outside those two chains is searched: guessing at unrelated directories on the host is
/// how a tool deploys into the wrong shard.
pub fn detect() -> Option<PathBuf> {
let mut starts: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
starts.push(cwd);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
starts.push(dir.to_path_buf());
}
}
for start in starts {
// Four levels is enough for Scripts/Custom/Bridge and nothing like enough to wander into
// an unrelated tree.
let mut candidate: &Path = &start;
for _ in 0..5 {
if looks_like_root(candidate) {
return Some(candidate.to_path_buf());
}
match candidate.parent() {
Some(parent) => candidate = parent,
None => break,
}
}
}
None
}
/// Reads the version from `Server/AssemblyInfo.cs`.
///
/// The source file rather than `ServUO.exe`'s PE metadata: it is the same *source* tree the patch
/// tier diffs against, it works identically on Linux and Windows, and it costs no dependency. The
/// exe describes whenever the core was last built, which on a tree mid-upgrade is a different — and
/// less relevant — answer.
fn read_version(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("Server").join("AssemblyInfo.cs")).ok()?;
parse_assembly_version(&text)
}
/// Extracts `57.4` from `[assembly: AssemblyVersion("57.4")]`.
///
/// Hand-parsed rather than regex'd (no dependency for one pattern), and tolerant of the whitespace
/// and attribute-ordering variations that show up across forks. Commented-out lines are skipped:
/// ServUO's own file has none, but a fork that left an old declaration behind would otherwise hand
/// back a version nobody is running.
pub fn parse_assembly_version(source: &str) -> Option<String> {
for line in source.lines() {
let line = line.trim();
if line.starts_with("//") {
continue;
}
let Some(rest) = line.split_once("AssemblyVersion").map(|(_, r)| r) else {
continue;
};
let Some(open) = rest.find('"') else { continue };
let Some(close) = rest[open + 1..].find('"') else {
continue;
};
let value = &rest[open + 1..open + 1 + close];
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
/// Drops trailing `.0` components so `57.4.0.0` and `57.4` compare equal.
///
/// ServUO declares `57.4` in source while .NET reports `57.4.0.0`; both name the same release, and
/// an operator should not be told their supported tree is unsupported over padding.
pub fn normalize_version(raw: &str) -> String {
let parts: Vec<&str> = raw.trim().split('.').collect();
let mut end = parts.len();
while end > 1 && parts[end - 1] == "0" {
end -= 1;
}
parts[..end].join(".")
}
#[cfg(windows)]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(stripped) => PathBuf::from(stripped),
None => path,
}
}
#[cfg(not(windows))]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
path
}
/// A ServUO process found running out of the tree being deployed into.
#[derive(Debug, Clone)]
pub struct RunningShard {
pub pid: u32,
pub detail: String,
}
/// Refuses to proceed if a shard is running out of `root`.
///
/// Matched by **executable and command-line path**, not by process name. `deploy.ps1` can look for
/// a process called `ServUO` because it only ever runs on Windows; on Linux the same shard appears
/// as `mono` or `dotnet` with `ServUO.exe` as an argument, and a name match would return "not
/// running" for a shard that is very much running — the one wrong answer that corrupts a live
/// `Scripts.dll`. Scoping to processes under *this* root also means a second shard on the same host
/// does not block a deploy into the first.
pub fn find_running(root: &Path) -> Option<RunningShard> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let root_str = normalize_for_match(&root.to_string_lossy());
for (pid, process) in system.processes() {
// Own process first: the installer may well have been copied into the server root, and
// matching itself would make every run refuse to start.
if pid.as_u32() == std::process::id() {
continue;
}
let exe = process
.exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let args: Vec<String> = process
.cmd()
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let hay = normalize_for_match(&format!("{exe} {}", args.join(" ")));
// Two conditions, both required: something in this process names the tree, AND it names
// the ServUO assembly. Either alone over-matches — an editor with the path open, or a
// different shard's ServUO.exe.
if hay.contains(&root_str) && hay.contains("servuo.exe") {
let detail = if exe.is_empty() { args.join(" ") } else { exe };
return Some(RunningShard {
pid: pid.as_u32(),
detail,
});
}
}
None
}
/// Lower-cases and unifies separators so a Windows path compares equal however it was spelled.
fn normalize_for_match(s: &str) -> String {
s.to_lowercase().replace('\\', "/")
}
/// Builds the refusal message. Separate from [`find_running`] so the wording is testable and so
/// callers cannot accidentally soften it.
pub fn running_error(root: &Path, shard: &RunningShard) -> anyhow::Error {
anyhow::anyhow!(
"ServUO is running from {} (pid {} — {}).\n\
Stop the shard before installing. ServUO.exe holds Scripts.dll open and rewrites Saves/ \
on exit, so deploying underneath it corrupts one or both. This is not overridable.",
root.display(),
shard.pid,
shard.detail
)
}
/// Convenience wrapper used by the commands: validate the path, then refuse if it is in use.
pub fn open_stopped(path: &Path) -> Result<ServUoRoot> {
let root =
open(path).with_context(|| format!("cannot use {} as a ServUO root", path.display()))?;
if let Some(shard) = find_running(&root.path) {
return Err(running_error(&root.path, &shard));
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn fake_root(dir: &Path) {
fs::create_dir_all(dir.join("Scripts")).unwrap();
fs::create_dir_all(dir.join("Config")).unwrap();
fs::create_dir_all(dir.join("Server")).unwrap();
fs::write(dir.join("ServUO.exe"), b"MZ").unwrap();
}
#[test]
fn all_three_markers_are_required() {
let tmp = TempDir::new("rg-test-servuo").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
assert!(looks_like_root(&root));
for missing in ["ServUO.exe", "Scripts", "Config"] {
let partial = tmp.path().join(format!("partial-{missing}"));
fake_root(&partial);
let victim = partial.join(missing);
if victim.is_dir() {
fs::remove_dir_all(&victim).unwrap();
} else {
fs::remove_file(&victim).unwrap();
}
assert!(
!looks_like_root(&partial),
"a tree without {missing} must not qualify"
);
assert!(open(&partial).is_err());
}
}
#[test]
fn the_version_comes_from_assembly_info() {
let tmp = TempDir::new("rg-test-version").unwrap();
let root = tmp.path().join("srv");
fake_root(&root);
fs::write(
root.join("Server").join("AssemblyInfo.cs"),
"using System.Reflection;\n[assembly: AssemblyTitle(\"ServUO\")]\n[assembly: AssemblyVersion(\"57.4\")]\n",
)
.unwrap();
let opened = open(&root).unwrap();
assert_eq!(opened.version.as_deref(), Some("57.4"));
assert!(opened.is_supported_version());
}
#[test]
fn an_unreadable_version_is_unknown_and_unsupported() {
// "Unknown" must not be optimistically treated as 57.4: an unreadable version is not
// evidence of a good one, and it is what gates the patch tier.
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());
}
}

465
src/sidecar.rs Normal file
View File

@@ -0,0 +1,465 @@
//! The uo-link sidecar: install the binary, provision its config, read the token back.
//!
//! This is the half of the deployment that makes the website work at all. The overlay puts code in
//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for
//! the shard to dial out to, and until the website has been given its address, protocol version and
//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened"
//! failure mode).
//!
//! Three rules govern this module:
//!
//! - **Every value in the handoff comes from asking the installed binary**, via
//! `--print-config --config <the pinned path>`. Not from the log, not from re-reading the TOML,
//! and not from the installer's own idea of what it wrote. That single call also *provisions* —
//! it writes the config file if absent and generates the token if blank — which is why PLAN.md
//! §5 requires it to run **before** the service is registered: the service must never start
//! against a config that does not exist yet.
//! - **The token is printed and never stored.** It goes to the operator's terminal and into
//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not
//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through
//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a
//! command printed.
//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is
//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s
//! question (Phase 4).
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::bundle::Asset;
use crate::util::{run, sha256_file};
/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`).
///
/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an
/// installer that does not know about it, and every field read here has been in the document since
/// the CLI was introduced in link v1.1.0.
#[derive(Debug, Clone, Deserialize)]
pub struct ConfigDoc {
pub component: String,
pub version: String,
pub protocol: u32,
pub config_path: String,
/// This run created the config file. False on every re-run — which is how the installer knows
/// not to report a token as newly minted when it is simply being read back.
pub config_created: bool,
pub token_generated: bool,
pub shard: ShardDoc,
pub web: WebDoc,
pub store: StoreDoc,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ShardDoc {
pub bind: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebDoc {
pub bind: String,
pub ws_path: String,
pub auth_required: bool,
/// **A secret.** Printed in the handoff block and never recorded anywhere else.
pub auth_token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StoreDoc {
/// Absolute, already resolved by the sidecar against its config file's directory.
pub path: String,
}
/// What installing the binary would do, decided by hash before anything is downloaded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryAction {
/// Nothing is installed at the target path yet.
Install,
/// Something is, and it is not what the bundle names.
Replace,
/// The bundle's binary is already in place, byte for byte.
Unchanged,
}
impl BinaryAction {
pub fn writes(self) -> bool {
!matches!(self, Self::Unchanged)
}
pub fn label(self) -> &'static str {
match self {
Self::Install => "install",
Self::Replace => "replace",
Self::Unchanged => "unchanged",
}
}
}
/// Compares what is installed against what the bundle names.
///
/// Hash rather than version string: the version an installed binary reports costs a process launch
/// to obtain and would still not distinguish two builds of the same version. The bundle records the
/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is
/// against the same value the download will be checked against.
pub fn decide(asset: &Asset, dest: &Path) -> Result<BinaryAction> {
if !dest.exists() {
return Ok(BinaryAction::Install);
}
let installed = sha256_file(dest)?;
if installed.eq_ignore_ascii_case(asset.sha256.trim()) {
Ok(BinaryAction::Unchanged)
} else {
Ok(BinaryAction::Replace)
}
}
/// Downloads the sidecar binary and puts it at `dest`, executable.
///
/// The download lands in the scratch directory and is checksum-verified there, then copied to a
/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for
/// different reasons: on Windows the target is locked while the service runs (the caller stops it
/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at
/// exactly the path a service is about to execute.
pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result<String> {
let staged = scratch.join(&asset.name);
crate::net::download_verified(&asset.url, &staged, &asset.sha256)?;
let parent = dest
.parent()
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?;
fs::create_dir_all(parent).with_context(|| {
format!(
"cannot create {} — run as root/Administrator",
parent.display()
)
})?;
let pending = pending_path(dest);
fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?;
set_executable(&pending)?;
// Windows will not rename onto an existing file. The old binary goes first; the replacement is
// already complete on disk by this point, so the window is a rename wide.
if dest.exists() {
fs::remove_file(dest).with_context(|| {
format!(
"cannot replace {} — if a service is running it, stop it first",
dest.display()
)
})?;
}
fs::rename(&pending, dest)
.with_context(|| format!("cannot move {} into place", pending.display()))?;
sha256_file(dest)
}
/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target.
///
/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and
/// `/usr/bin` are routinely different mounts, and a rename between them fails.
fn pending_path(dest: &Path) -> PathBuf {
let mut name = dest.file_name().unwrap_or_default().to_os_string();
name.push(".new");
dest.with_file_name(name)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("cannot make {} executable", path.display()))
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
/// Runs the installed binary's `--print-config`, provisioning the config and returning the token.
///
/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would
/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`.
/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path`
/// to its config's directory, and passing the variable would buy nothing while implying the service
/// needs a machine-wide environment variable it does not (see [`crate::paths`]).
///
/// **This function must not print, log or attach the child's stdout to an error.** It is the one
/// place in the installer where a secret crosses a process boundary.
pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result<ConfigDoc> {
let mut command = std::process::Command::new(binary);
command.arg("--print-config").arg("--config").arg(config);
if let Some(db) = db_path {
command.env("UOLINK_DB_PATH", db);
}
let output = command.output().with_context(|| {
format!(
"cannot run {} --print-config. The binary was just installed, so this usually means it \
cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.",
binary.display()
)
})?;
if !output.status.success() {
// stderr only. stdout is the document, and the document contains the auth token.
let reason = String::from_utf8_lossy(&output.stderr)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(nothing on stderr)")
.to_string();
bail!(
"{} --print-config --config {} failed with {}: {reason}",
binary.display(),
config.display(),
match output.status.code() {
Some(code) => format!("exit code {code}"),
None => "no exit code".to_string(),
}
);
}
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
"the sidecar's --print-config output is not the document this installer expects. \
Its contents are not shown here because they would contain the auth token; run the same \
command by hand to see it (INSTALL.md Appendix A3).",
)?;
if doc.component != "uo-link-sidecar" {
bail!(
"the binary at {} identifies itself as {:?}, not uo-link-sidecar",
binary.display(),
doc.component
);
}
if doc.web.auth_token.trim().is_empty() {
// Authentication is always on in the sidecar, so this cannot happen against a real one —
// and if it ever did, an unauthenticated web surface must not be reported as a success.
bail!(
"the sidecar reported an empty auth token from {}. Authentication is always on; refusing \
to continue with a config that would leave its web surface unauthenticated.",
doc.config_path
);
}
Ok(doc)
}
/// Asks an installed binary for its version line, `uo-link-sidecar <ver> (protocol <n>)`.
///
/// Used to report what is already installed on a run that installs nothing. It is deliberately
/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose
/// real work has already succeeded.
pub fn version_line(binary: &Path) -> Option<String> {
let output = run(&binary.to_string_lossy(), &["--version"]).ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
}
/// The two URLs the website needs, composed from the sidecar's own answers plus a host.
///
/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently
/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken
/// from it; the host is the one the operator named or the installer detected.
pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) {
let port = port_of(&doc.web.bind);
let ws_path = if doc.web.ws_path.starts_with('/') {
doc.web.ws_path.clone()
} else {
format!("/{}", doc.web.ws_path)
};
(
format!("http://{host}:{port}"),
format!("ws://{host}:{port}{ws_path}"),
)
}
/// The port half of a bind address.
///
/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment
/// of the address. A bind with no port at all is not something the sidecar produces, so the whole
/// string is handed back rather than guessing a default that would then be wrong everywhere it was
/// printed.
fn port_of(bind: &str) -> &str {
match bind.rsplit_once(':') {
Some((_, port)) if !port.is_empty() => port,
_ => bind,
}
}
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
///
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
/// goes. It goes to stdout. It never goes to a file.
pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
let (base_url, ws_url) = website_urls(doc, host);
let site = site_url
.map(|s| s.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://<your-site>".to_string());
format!(
"\nRunic Gateway is installed.\n\n\
One manual step remains — connect the website to this sidecar:\n\n \
Base URL {base_url}\n \
WebSocket URL {ws_url}\n \
Protocol version {protocol}\n \
Auth token {token}\n \
(also in {config})\n\n\
Paste these into Admin → Shard on your Runic Gateway site:\n \
{site}/admin/shard\n\n\
The token is write-only once saved — the site will never show it back to you.\n",
protocol = doc.protocol,
token = doc.web.auth_token,
config = doc.config_path,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
/// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3.
const PRINT_CONFIG: &str = r#"{
"component": "uo-link-sidecar",
"version": "1.1.0",
"protocol": 3,
"config_path": "/etc/runicgateway/sidecar.toml",
"config_created": true,
"token_generated": true,
"shard": { "bind": "127.0.0.1:7788" },
"web": {
"bind": "127.0.0.1:8080",
"ws_path": "/ws",
"auth_required": true,
"auth_token": "4f9c00112233445566778899aabbccdd"
},
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
}"#;
fn doc() -> ConfigDoc {
serde_json::from_str(PRINT_CONFIG).unwrap()
}
#[test]
fn the_documented_print_config_output_parses() {
let doc = doc();
assert_eq!(doc.version, "1.1.0");
assert_eq!(doc.protocol, 3);
assert!(doc.web.auth_required);
assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db");
}
#[test]
fn a_newer_sidecar_adding_fields_still_parses() {
// The sidecar and the installer version independently; a key added to the document must not
// strand an installed installer.
let body = PRINT_CONFIG.replace(
"\"protocol\": 3,",
"\"protocol\": 3, \"something_new\": { \"nested\": true },",
);
assert!(serde_json::from_str::<ConfigDoc>(&body).is_ok());
}
#[test]
fn the_website_urls_use_the_host_not_the_bind_address() {
// The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website.
let mut d = doc();
let (base, ws) = website_urls(&d, "shard.example.com");
assert_eq!(base, "http://shard.example.com:8080");
assert_eq!(ws, "ws://shard.example.com:8080/ws");
d.web.bind = "0.0.0.0:9001".into();
let (base, ws) = website_urls(&d, "shard.example.com");
assert_eq!(base, "http://shard.example.com:9001");
assert_eq!(ws, "ws://shard.example.com:9001/ws");
}
#[test]
fn an_ipv6_bind_yields_its_port() {
// Splitting on the first colon would produce "http://host::" from "[::]:8080".
assert_eq!(port_of("[::]:8080"), "8080");
assert_eq!(port_of("[::1]:7788"), "7788");
assert_eq!(port_of("127.0.0.1:8080"), "8080");
}
#[test]
fn the_handoff_carries_every_value_the_admin_form_asks_for() {
// INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them.
let doc = doc();
let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/"));
assert!(block.contains("http://shard.example.com:8080"), "{block}");
assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}");
assert!(block.contains("Protocol version 3"), "{block}");
assert!(block.contains(&doc.web.auth_token), "{block}");
// The trailing slash on the site URL must not produce a double slash in the link.
assert!(
block.contains("https://my-site.example/admin/shard"),
"{block}"
);
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
}
#[test]
fn the_handoff_still_works_without_a_site_url() {
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
// link the operator does not need.
let block = handoff(&doc(), "shard", None);
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
assert!(block.contains("4f9c"), "{block}");
}
#[test]
fn an_installed_binary_matching_the_bundle_is_left_alone() {
let dir = TempDir::new("rg-test-sidecar").unwrap();
let dest = dir.path().join("uo-link-sidecar");
assert_eq!(
decide(&asset("0".repeat(64)), &dest).unwrap(),
BinaryAction::Install
);
fs::write(&dest, b"pretend binary").unwrap();
let installed = sha256_file(&dest).unwrap();
assert_eq!(
decide(&asset(installed.clone()), &dest).unwrap(),
BinaryAction::Unchanged
);
// Hex case must not decide whether a host reinstalls its sidecar on every run.
assert_eq!(
decide(&asset(installed.to_uppercase()), &dest).unwrap(),
BinaryAction::Unchanged
);
assert_eq!(
decide(&asset("a".repeat(64)), &dest).unwrap(),
BinaryAction::Replace
);
}
#[test]
fn the_staging_file_sits_beside_its_target() {
// /tmp and /usr/bin are routinely different filesystems, and rename across them fails.
let dest = Path::new("/usr/bin/runicgateway-link");
assert_eq!(pending_path(dest).parent(), dest.parent());
assert_eq!(
pending_path(Path::new("/usr/bin/x.exe"))
.file_name()
.unwrap(),
"x.exe.new"
);
}
fn asset(sha256: String) -> Asset {
Asset {
name: "uo-link-sidecar-linux-x86_64".into(),
url: "https://example/uo-link-sidecar".into(),
sha256,
}
}
}

1072
src/tier.rs Normal file

File diff suppressed because it is too large Load Diff

123
src/ui.rs Normal file
View File

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

707
src/uninstall.rs Normal file
View File

@@ -0,0 +1,707 @@
//! The `uninstall` command — remove what the installer exclusively owns, print the rest.
//!
//! PLAN.md §5 is unusually specific about the shape of this command, and the reason is worth
//! keeping in front of whoever edits it: **the installer cannot know what the operator has changed
//! in their own ServUO tree since deployment.** A clever automatic revert — deleting the overlay's
//! files, reversing the patch hunks — would silently eat work that is not ours to judge. So this
//! command draws a hard line:
//!
//! | | |
//! |---|---|
//! | Removed | the sidecar binary, its service entry, `install.json` |
//! | Kept | `sidecar.toml`, `uo-link.db`, the cached patch set and the pre-patch originals (`--purge` drops them) |
//! | Printed, not done | every overlay file in the ServUO tree, and the exact hunks each applied patch added |
//!
//! ## Why the patch cache outlives the uninstall
//!
//! PLAN.md's table put the cached patch set under "removed", but the report this command prints
//! tells the operator to diff their stock files against the pre-patch copies under
//! `patches/originals/` — advice that the same command would have made impossible to follow. The
//! cache and the originals are the only offline record of what the tier changed once the release
//! tarball is gone, so they survive by default and `--purge` is what removes them, alongside the
//! config and the database. The report names every path it left behind.
//!
//! ## Why the report is a file as well as output
//!
//! It is the only thing the operator still needs after this command exits, and it arrives at the
//! end of the longest output the installer ever produces. A terminal's scrollback is not a place to
//! keep the list of files somebody has to go and delete by hand.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::cli::Cli;
use crate::diff::HunkLine;
use crate::record::{now_rfc3339, InstallRecord, LinkRecord};
use crate::{patch, paths, service, ui, util};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.install_record();
println!(
"\nRunic Gateway installer {} — uninstall",
env!("CARGO_PKG_VERSION")
);
let Some(record) = InstallRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no deployment is recorded on this host.\n \
Looked for {}\n \
If this host is installed, this run cannot see its record: run as \
root/Administrator, and set {} to the same value the install used (if any).",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(0);
};
let link = record.link_record();
print_intent(&record, link.as_ref(), &layout, cli.purge);
// Default **no**, because this is the one command that removes a running service and the
// listing above is what the operator is being asked about — a defaulted-yes prompt on a
// destructive action is answered by reflex rather than read.
//
// `--yes` is nevertheless a **yes** here, not "take the default". Everywhere else that flag
// answers an offer the run made (the patch tier, a detected ServUO root), so taking the safe
// default is right. Here the operator typed the destructive verb themselves; reading `--yes` as
// "no" would leave an unattended uninstall with no way to express itself at all, and a script
// that appeared to succeed while removing nothing is the worse of the two failures.
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove what is exclusively ours ──────────────────────────────────────
println!();
ui::heading("Removing");
let mut done: Vec<String> = Vec::new();
let mut problems: Vec<String> = Vec::new();
if let Some(link) = &link {
if let Some(service_record) = &link.service {
let removal = service::remove(service_record);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&link.binary.path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&link.config_path), &mut done, &mut problems);
// The database is removed with its journal and WAL siblings; SQLite writes those beside
// it, and leaving them behind would confuse the next install rather than protect
// anything.
for suffix in ["", "-journal", "-wal", "-shm"] {
let path = PathBuf::from(format!("{}{suffix}", link.db_path));
if path.exists() {
remove_file(&path, &mut done, &mut problems);
}
}
} else {
done.push(format!(
"kept {} and {} (--purge removes them)",
link.config_path, link.db_path
));
}
}
// The report is written before the record is, because it is rendered *from* the record.
let report = render_report(&record, link.as_ref(), &layout, cli.purge);
let report_path = write_report(&report, &layout);
if cli.purge {
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
} else {
if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
}
// Same rule and the same reason as the patch cache: a backup is the only copy of what this
// host had before an upgrade replaced it, and it outlives the deployment that took it.
let backups = crate::backup::list(&layout);
if !backups.is_empty() {
done.push(format!(
"kept {}{} backup(s) of files earlier runs replaced",
layout.backups_dir().display(),
backups.len()
));
}
}
remove_file(&record_path, &mut done, &mut problems);
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
// ── What only the operator can do ────────────────────────────────────────
print!("{report}");
match &report_path {
Ok(path) => println!("This report is also saved at:\n {}\n", path.display()),
Err(error) => ui::warn(&format!(
"The report above could not be saved to a file ({error}) — copy it out of this \
terminal before you lose it."
)),
}
// A step that could not be carried out is worth an exit code, for the same reason `doctor` has
// one: the run itself succeeded, and only the shell knows whether anybody is reading the
// output. Everything that *could* be removed still was.
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// Says exactly what will happen, before asking. Nothing here touches the disk.
fn print_intent(
record: &InstallRecord,
link: Option<&LinkRecord>,
layout: &paths::Layout,
purge: bool,
) {
println!();
ui::heading("This will remove");
match link {
Some(link) => {
if let Some(service) = &link.service {
println!(" · the {} service", service.name);
if let (Some(user), true) = (service.user.as_deref(), service.user_created) {
println!(" · the {user} account, which the installer created");
}
}
println!(" · {}", link.binary.path);
if purge {
println!(" · {} [--purge]", link.config_path);
println!(" · {} [--purge]", link.db_path);
}
}
None => println!(" · (no sidecar is recorded on this host)"),
}
println!(" · {}", layout.install_record().display());
if purge {
println!(" · {} [--purge]", layout.patches_dir().display());
println!(" · {} [--purge]", layout.backups_dir().display());
}
println!();
ui::heading("This will NOT touch");
println!(" · your ServUO tree — every deployed file is listed for you to delete");
println!(
" · any patched stock file — the hunks to revert are printed with the rung each landed at"
);
println!(" · your shard, which is neither stopped nor started");
if !purge {
if let Some(link) = link {
println!(" · {} (the auth token)", link.config_path);
println!(" · {} (event history)", link.db_path);
}
println!(
" · {} (cached patches and pre-patch originals)",
layout.patches_dir().display()
);
let backups = crate::backup::list(layout);
if !backups.is_empty() {
println!(
" · {} ({} backup(s) of files earlier runs replaced)",
layout.backups_dir().display(),
backups.len()
);
}
}
let _ = record;
println!();
}
/// The report: everything the operator has to finish by hand.
fn render_report(
record: &InstallRecord,
link: Option<&LinkRecord>,
layout: &paths::Layout,
purge: bool,
) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n",
"",
now_rfc3339(),
env!("CARGO_PKG_VERSION"),
""
);
let _ = writeln!(
out,
"ServUO tree {}{}",
record.servuo.path,
record
.servuo
.version
.as_ref()
.map(|v| format!(" ({v})"))
.unwrap_or_default()
);
if let Some(link) = link {
// Stated flatly, with no claim about what this run managed to remove: the report is
// rendered from the record and is about what is *left* to do. A line asserting "(removed)"
// is a line that can be wrong — a binary locked by a still-running process is exactly the
// case where it would be.
let _ = writeln!(out, "uo-link {}", link.version);
}
render_overlay_section(&mut out, record);
render_patch_section(&mut out, record, layout, purge);
render_backup_section(&mut out, layout, purge);
let _ = writeln!(
out,
"The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\
what you have changed in those files since they were deployed. Both lists above are\n\
yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n"
);
out
}
/// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed.
///
/// The flag is the point: an operator deleting this list file by file must not lose their own
/// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those
/// are.
fn render_overlay_section(out: &mut String, record: &InstallRecord) {
let Some(overlay) = &record.overlay else {
return;
};
let root = Path::new(&record.servuo.path);
let _ = writeln!(
out,
"\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\
{} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n",
overlay.files.len(),
overlay.version
);
for (rel, file) in &overlay.files {
let path = patch::join(root, rel);
let note = match util::sha256_file(&path) {
Err(_) => " (already gone)",
Ok(actual) if actual == file.on_disk_sha256 => "",
Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting",
};
let _ = writeln!(out, " {}{note}", path.display());
}
let _ = writeln!(
out,
"\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n"
);
}
/// The exact hunks each applied patch added, rendered from the cached `.patch` files.
///
/// Rendered rather than referenced: the release tarball is long gone by the time somebody reads
/// this, and "apply the reverse of the patch" is not something an operator can do from a filename.
/// The rung each hunk landed at is printed with it, because a `region-match` apply means the
/// surrounding file was already the operator's and deserves a closer look than a stock-hash one.
fn render_patch_section(
out: &mut String,
record: &InstallRecord,
layout: &paths::Layout,
purge: bool,
) {
let features = record.patch_records();
if features.is_empty() {
return;
}
let _ = writeln!(
out,
"\n── Stock ServUO files this installer patched ────────────────────────────────\n"
);
if features.iter().any(|f| f.unsupported_servuo) {
let _ = writeln!(
out,
" ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n",
features
.iter()
.find_map(|f| f.servuo_version.clone())
.unwrap_or_else(|| "unknown".into())
);
}
for feature in &features {
let _ = writeln!(out, " Feature: {}", feature.feature);
for applied in &feature.patches {
let _ = writeln!(
out,
"\n {} → {}\n applied by: {}",
applied.name,
patch::join(Path::new(&record.servuo.path), &applied.target).display(),
applied.rung
);
match render_hunks(layout, &applied.name, &applied.sha256) {
Some(text) => out.push_str(&text),
None => {
let _ = writeln!(
out,
" (the cached copy of this patch could not be read — the hunks it added \
start\n near line {})",
applied
.hunks
.first()
.map(|h| h.matched_line)
.unwrap_or_default()
);
}
}
}
if !feature.companions.is_empty() {
let _ = writeln!(out, "\n Companion files added by this feature:");
for companion in &feature.companions {
let _ = writeln!(
out,
" {}",
patch::join(Path::new(&record.servuo.path), &companion.path).display()
);
}
}
let _ = writeln!(out);
}
let originals = layout.patch_originals_dir();
if purge {
let _ = writeln!(
out,
" The pre-patch copies of these files were removed by --purge, so the lines above are\n\
the only record of what changed.\n"
);
} else if originals.exists() {
let _ = writeln!(
out,
" Each of those files as it was BEFORE the tier first touched it is kept here:\n \
{}\n Diff against it rather than reversing the hunks by eye — after a region-match \
apply the\n rest of the file was already yours.\n",
originals.display()
);
}
}
/// The backups earlier runs took, since this report is the durable record of what was left behind.
///
/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by
/// the time this report is read the run that took it is long out of the scrollback.
fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) {
let backups = crate::backup::list(layout);
if backups.is_empty() {
return;
}
if purge {
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
{} backup(s) of files earlier runs replaced were removed by --purge.
",
backups.len()
);
return;
}
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
Copies of the files earlier runs replaced, newest first. These are kept:
"
);
for dir in &backups {
let count = crate::backup::read_manifest(dir)
.map(|m| m.files.len())
.unwrap_or(0);
let _ = writeln!(out, " {} ({} file(s))", dir.display(), count);
}
let _ = writeln!(
out,
"
Each carries a manifest.json naming where every file came from. Restoring is yours to
do — this tool will not put an old file back over a newer one. `--purge` removes them.
"
);
}
/// Renders one cached patch's added and removed lines, indented for the report.
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
let path = layout.patches_dir().join(format!("{name}.patch"));
let bytes = std::fs::read(&path).ok()?;
if !sha256.is_empty() && util::sha256_bytes(&bytes) != sha256 {
// Not fatal — a re-run with a newer release can legitimately have replaced the cache — but
// the operator should know the text below is not byte-for-byte what was applied.
let parsed = crate::diff::parse(&bytes).ok()?;
let file = parsed.single_file().ok()?;
let mut out = String::from(
" (the cached patch differs from the one recorded; showing the cached copy)\n",
);
out.push_str(&hunk_text(file));
return Some(out);
}
let parsed = crate::diff::parse(&bytes).ok()?;
Some(hunk_text(parsed.single_file().ok()?))
}
fn hunk_text(file: &crate::diff::FilePatch) -> String {
let mut out = String::new();
for hunk in &file.hunks {
let _ = writeln!(
out,
" @@ around line {} @@",
if hunk.old_start > 0 {
hunk.old_start
} else {
1
}
);
for line in &hunk.lines {
let (sign, bytes) = match line {
HunkLine::Context(b) => (' ', b),
HunkLine::Added(b) => ('+', b),
HunkLine::Removed(b) => ('-', b),
};
let _ = writeln!(out, " {sign}{}", String::from_utf8_lossy(bytes));
}
}
out
}
/// Writes the report where the operator ran the command, falling back to the state directory.
///
/// The working directory is the one place they are certainly looking; `/etc/runicgateway` is being
/// emptied by this very command, and a report inside a directory the operator has just been told is
/// gone is a report nobody finds.
fn write_report(report: &str, layout: &paths::Layout) -> Result<PathBuf> {
let name = format!(
"runicgateway-uninstall-{}.txt",
now_rfc3339().replace([':', '-'], "").replace('Z', "")
);
let cwd = std::env::current_dir().unwrap_or_else(|_| layout.state_dir.clone());
let primary = cwd.join(&name);
if util::write_atomic(&primary, report.as_bytes()).is_ok() {
return Ok(primary);
}
let fallback = layout.state_dir.join(&name);
util::write_atomic(&fallback, report.as_bytes()).with_context(|| {
format!(
"cannot write the uninstall report to {}",
fallback.display()
)
})?;
Ok(fallback)
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(error) => {
// A permission error on the sidecar binary is nearly always a running process holding
// it, not an access-control problem: Windows locks a running executable, and a service
// this command knows about was already stopped above. Saying so beats sending the
// operator to look at ACLs.
let hint = if error.kind() == std::io::ErrorKind::PermissionDenied {
"\n If something is still running it — a sidecar started by hand, or a service \
this installer did not register — stop that first and delete the file."
} else {
""
};
problems.push(format!("cannot remove {}: {error}{hint}", path.display()))
}
}
}
fn remove_dir(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
if !path.exists() {
return;
}
match std::fs::remove_dir_all(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(error) => problems.push(format!("cannot remove {}: {error}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch::{AppliedPatch, FeatureRecord, Rebuild};
use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA};
use std::collections::BTreeMap;
const PATCH: &[u8] = b"\
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -10,3 +10,4 @@ public static class CommandLogging
public static void WriteLine()
{
+ BridgeModerationAudit.Raise();
}
";
fn record(root: &Path) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-05T10:00:00Z".into(),
bundle: BundleRef {
tag: "2026.08.04".into(),
protocol: 3,
url: "https://example/current.json".into(),
},
servuo: ServUoRef {
path: root.display().to_string(),
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([
(
"Scripts/Custom/Bridge/BridgeLink.cs".to_string(),
FileRecord {
overlay_sha256: util::sha256_bytes(b"deployed"),
on_disk_sha256: util::sha256_bytes(b"deployed"),
state: "deployed".into(),
},
),
(
"Config/Bridge.cfg".to_string(),
FileRecord {
overlay_sha256: util::sha256_bytes(b"shipped"),
on_disk_sha256: util::sha256_bytes(b"mine"),
state: "kept-operator-modified".into(),
},
),
]),
}),
link: None,
patches: vec![serde_json::to_value(FeatureRecord {
feature: "moderation-audit".into(),
rebuild: Rebuild::Scripts,
servuo_version: Some("57.4".into()),
unsupported_servuo: false,
patches: vec![AppliedPatch {
name: "commandlogging-event".into(),
target: "Scripts/Commands/Logging.cs".into(),
rung: "region-match".into(),
sha256: util::sha256_bytes(PATCH),
hunks: Vec::new(),
}],
companions: Vec::new(),
})
.unwrap()],
extra: BTreeMap::new(),
}
}
fn layout_in(dir: &Path) -> paths::Layout {
paths::Layout {
state_dir: dir.to_path_buf(),
data_dir: dir.join("data"),
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
relocated: true,
}
}
#[test]
fn the_report_lists_every_overlay_file_and_flags_the_edited_ones() {
let dir = util::TempDir::new("rg-test-uninstall").unwrap();
let root = dir.path().join("ServUO");
std::fs::create_dir_all(root.join("Scripts/Custom/Bridge")).unwrap();
std::fs::create_dir_all(root.join("Config")).unwrap();
std::fs::write(
root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
b"deployed",
)
.unwrap();
// Edited after deployment: the operator must be warned before deleting this one.
std::fs::write(root.join("Config/Bridge.cfg"), b"changed again").unwrap();
let layout = layout_in(dir.path());
let record = record(&root);
let report = render_report(&record, None, &layout, false);
assert!(report.contains("BridgeLink.cs"), "{report}");
assert!(
report.contains("EDITED SINCE DEPLOYMENT"),
"the edited Bridge.cfg must be flagged:\n{report}"
);
}
#[test]
fn the_report_renders_the_hunks_from_the_cached_patch() {
// The whole reason the tier caches its patches: this text has to be produceable long after
// the release tarball is gone.
let dir = util::TempDir::new("rg-test-uninstall-hunks").unwrap();
let layout = layout_in(dir.path());
std::fs::create_dir_all(layout.patches_dir()).unwrap();
std::fs::write(
layout.patches_dir().join("commandlogging-event.patch"),
PATCH,
)
.unwrap();
let report = render_report(&record(&dir.path().join("ServUO")), None, &layout, false);
assert!(report.contains("commandlogging-event"), "{report}");
assert!(
report.contains("+ BridgeModerationAudit.Raise();"),
"the added line must appear verbatim:\n{report}"
);
assert!(report.contains("region-match"), "{report}");
}
#[test]
fn a_missing_patch_cache_degrades_to_a_line_number() {
// --purge on an earlier run, or a hand-cleaned /etc: the report still has to say something
// useful rather than claim there was nothing to revert.
let dir = util::TempDir::new("rg-test-uninstall-nocache").unwrap();
let report = render_report(
&record(&dir.path().join("ServUO")),
None,
&layout_in(dir.path()),
false,
);
assert!(report.contains("could not be read"), "{report}");
assert!(report.contains("commandlogging-event"), "{report}");
}
#[test]
fn the_report_is_written_where_the_operator_is_standing() {
let dir = util::TempDir::new("rg-test-uninstall-report").unwrap();
let path = write_report("hello", &layout_in(dir.path())).unwrap();
assert!(path.is_file());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
assert!(
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with("runicgateway-uninstall-"),
"{path:?}"
);
let _ = std::fs::remove_file(path);
}
}

218
src/update.rs Normal file
View File

@@ -0,0 +1,218 @@
//! The `update` command — move an existing deployment to the current bundle.
//!
//! The deployment itself is [`crate::install`] in [`Mode::Update`]; this module holds only what is
//! genuinely different, which is smaller than it looks:
//!
//! - **A prior record is required.** `update` on a host that has never been installed is a typo, or
//! a state directory the run cannot see — never a reason to perform a first install under a verb
//! that promises to preserve what is already there.
//! - **Both halves move together.** The bundle is the compat matrix (PLAN.md §7.1): resolving it
//! and taking both components from it is what stops an update from landing two independently
//! latest artifacts whose protocol versions disagree. That property comes free from reusing the
//! install pipeline — it is stated here because it is the whole reason `update` is not simply
//! "download the newest sidecar".
//! - **The close is a diff, not a handoff.** What moved, what the operator must now do (restart the
//! shard; and, only if the protocol number changed, edit one field in Admin → Shard), and nothing
//! else. The auth token is not reprinted: it has not changed, the website already has it, and a
//! secret that requires no action does not belong in another terminal scrollback.
//!
//! What `update` deliberately does **not** do is restart the shard (the installer never owns
//! another process's lifecycle — PLAN.md §8) or widen the patch tier on its own. The tier's scope
//! under this verb is `[crate::tier]`'s business: features a previous run recorded are re-resolved
//! against the new release, and anything new is named but not applied without `--patches`.
use std::path::Path;
use anyhow::{bail, Result};
use crate::bundle::Bundle;
use crate::cli::Cli;
use crate::install::{self, Mode};
use crate::record::InstallRecord;
use crate::{paths, ui};
pub fn run(cli: &Cli) -> Result<()> {
install::deploy(cli, Mode::Update)
}
/// Refuses an update on a host with nothing recorded.
///
/// Phrased around the state directory rather than the verb, because the overwhelmingly likely cause
/// is a run that cannot see the state it is looking for: a re-run without the `RUNICGATEWAY_STATE_DIR`
/// that the install used, or an unelevated shell on Windows.
pub fn require_prior(prior: Option<&InstallRecord>, record_path: &Path) -> Result<()> {
if prior.is_some() {
return Ok(());
}
bail!(
"there is no deployment to update — {} does not exist.\n\
Run `install` to deploy for the first time. If this host *is* installed, this run cannot \
see its record: check that you are running as root/Administrator, and that {} is set to \
the same value the install used (if any).",
record_path.display(),
paths::STATE_DIR_ENV
)
}
/// The end of an update: what moved, and what the operator has to do about it.
pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallRecord, verify: bool) {
let moves = describe_moves(prior, now);
println!();
ui::heading(if verify {
"Would move [--verify]"
} else {
"Updated"
});
if moves.is_empty() {
println!(" Both halves were already on bundle {}.", bundle.bundle);
} else {
for line in &moves {
println!(" {line}");
}
}
// The one thing an update can change that the *website* has to be told about. The token, the
// URLs and the ports are all unchanged, so this is the only reason to reopen Admin → Shard —
// and it must be said plainly, because a stale number there is answered with 409 by the
// sidecar rather than mis-parsed, which looks to an operator like the shard going offline.
let previous_protocol = prior.map(|p| p.bundle.protocol);
if previous_protocol.is_some_and(|p| p != bundle.protocol) {
println!();
ui::warn(&format!(
"The protocol version changed: {}{}.\n \
Update the Protocol version field in Admin → Shard on your website. Nothing else \
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
website still set to\n {} with 409 rather than mis-parsing it.",
previous_protocol.unwrap_or(bundle.protocol),
bundle.protocol,
previous_protocol.unwrap_or(bundle.protocol),
));
}
// No "nothing was written" line here: the shared closing in `install::deploy` has already said
// it, in the wording of the verb that was typed. Saying it twice reads like two dry runs.
}
/// The version moves between two records, as printed lines.
///
/// Compared per component rather than by bundle tag: a new bundle whose components happen to be
/// unchanged is not something to report as an upgrade, and the tag alone cannot say which half
/// actually moved.
fn describe_moves(prior: Option<&InstallRecord>, now: &InstallRecord) -> Vec<String> {
let Some(prior) = prior else {
return Vec::new();
};
let mut moves = Vec::new();
if prior.bundle.tag != now.bundle.tag {
moves.push(format!(
"bundle {}{}",
prior.bundle.tag, now.bundle.tag
));
}
match (prior.link_record(), now.link_record()) {
(Some(before), Some(after)) if before.version != after.version => moves.push(format!(
"uo-link {}{} (service restarted)",
before.version, after.version
)),
_ => {}
}
match (&prior.overlay, &now.overlay) {
(Some(before), Some(after)) if before.version != after.version => moves.push(format!(
"overlay {}{} (ServUO must be restarted to compile it)",
before.version, after.version
)),
_ => {}
}
moves
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::{
BinaryRef, BundleRef, InstallerInfo, LinkRecord, OverlayRecord, ServUoRef, SCHEMA,
};
use std::collections::BTreeMap;
fn record(bundle_tag: &str, protocol: u32, link: &str, overlay: &str) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-05T10:00:00Z".into(),
bundle: BundleRef {
tag: bundle_tag.into(),
protocol,
url: "https://example/current.json".into(),
},
servuo: ServUoRef {
path: "/opt/ServUO".into(),
version: Some("57.4".into()),
},
overlay: Some(OverlayRecord {
repo: "RunicGateway/servuo-plugins".into(),
tag: format!("v{overlay}"),
version: overlay.into(),
commit: "3a52abb".into(),
protocol,
files: BTreeMap::new(),
}),
link: serde_json::to_value(LinkRecord {
repo: "RunicGateway/link".into(),
tag: format!("v{link}"),
version: link.into(),
protocol,
binary: BinaryRef {
path: "/usr/bin/runicgateway-link".into(),
sha256: "aa".into(),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: None,
})
.ok(),
patches: Vec::new(),
extra: BTreeMap::new(),
}
}
#[test]
fn an_update_with_nothing_recorded_is_refused_with_the_state_dir_named() {
// The failure this message exists for is a run that cannot *see* an install, not one that
// has none — so the text has to point at the state directory, not just say "run install".
let error = require_prior(None, Path::new("/etc/runicgateway/install.json")).unwrap_err();
let message = error.to_string();
assert!(message.contains("install.json"), "{message}");
assert!(message.contains(paths::STATE_DIR_ENV), "{message}");
assert!(require_prior(Some(&record("a", 3, "1.1.0", "0.1.1")), Path::new("x")).is_ok());
}
#[test]
fn only_components_that_actually_moved_are_reported() {
let before = record("2026.08.04", 3, "1.1.0", "0.1.1");
let after = record("2026.09.01", 3, "1.2.0", "0.1.1");
let moves = describe_moves(Some(&before), &after);
assert!(
moves.iter().any(|m| m.contains("uo-link 1.1.0 → 1.2.0")),
"{moves:?}"
);
// The overlay did not move, so nothing may tell the operator to restart their shard for it.
assert!(!moves.iter().any(|m| m.contains("overlay")), "{moves:?}");
assert!(moves.iter().any(|m| m.contains("bundle")), "{moves:?}");
}
#[test]
fn a_new_bundle_with_unchanged_components_reports_only_the_bundle() {
// The nightly cron can publish a new tag whose matrix is identical; calling that an upgrade
// would send an operator looking for a change that does not exist.
let before = record("2026.08.04", 3, "1.1.0", "0.1.1");
let after = record("2026.08.05", 3, "1.1.0", "0.1.1");
let moves = describe_moves(Some(&before), &after);
assert_eq!(moves.len(), 1, "{moves:?}");
assert!(moves[0].contains("bundle"), "{moves:?}");
}
}

365
src/util.rs Normal file
View File

@@ -0,0 +1,365 @@
//! Hashing, scratch directories, and running other programs.
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, 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.
///
/// The run hashes files and streams almost everywhere, since they are large. The exception is the
/// patch tier, which already holds each `.patch` in memory to parse it and would otherwise re-read
/// from disk purely to hash a few kilobytes it is looking at.
pub fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex(&hasher.finalize())
}
/// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's
/// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs
/// once per deployed file.
pub fn sha256_file(path: &Path) -> Result<String> {
let mut file =
File::open(path).with_context(|| format!("cannot read {} to hash it", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("cannot read {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex(&hasher.finalize()))
}
/// The git object name of a buffer treated as a blob: `sha1("blob " + len + "\0" + content)`.
///
/// This is what `git hash-object` prints and what a patch's `index <old>..<new>` line records, so
/// reproducing it is how the patch tier answers rung 1 — "is this whole file still the one the
/// patch was written against?" (PLAN.md §2.2.1). Computed here rather than by shelling out, because
/// the entire reason the plugin ships as a release tarball is that a shard host has no git on it
/// (§1).
///
/// The bytes are hashed exactly as they sit on disk. That matters: the three files this tier edits
/// are CRLF, and the recorded hashes were taken from those CRLF bytes, so any normalization here
/// would make every rung-1 check miss.
pub fn git_blob_hash(content: &[u8]) -> String {
use sha1::{Digest as _, Sha1};
let mut hasher = Sha1::new();
hasher.update(format!("blob {}\0", content.len()).as_bytes());
hasher.update(content);
hex(&hasher.finalize())
}
/// A [`Write`] that hashes everything passing through it.
///
/// Downloads are verified *while* being written rather than by re-reading the finished file: it
/// halves the I/O and, more importantly, means the bytes that were hashed are provably the bytes
/// that were written.
pub struct HashingWriter<W: Write> {
inner: W,
hasher: Sha256,
}
impl<W: Write> HashingWriter<W> {
pub fn new(inner: W) -> Self {
Self {
inner,
hasher: Sha256::new(),
}
}
pub fn finish(self) -> String {
hex(&self.hasher.finalize())
}
}
impl<W: Write> Write for HashingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.hasher.update(&buf[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// A scratch directory that deletes itself.
///
/// Downloads and the extracted overlay land here. Hand-rolled rather than pulled from a crate
/// because the requirement is one directory with a unique name and a `Drop` — and because a failed
/// cleanup must never fail the run: by the time it matters the install has already succeeded or
/// failed on its own merits.
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn new(prefix: &str) -> Result<Self> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()));
fs::create_dir_all(&path)
.with_context(|| format!("cannot create scratch directory {}", path.display()))?;
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
/// Writes a file by writing a sibling `.tmp` and renaming over the target.
///
/// `install.json` is the record every later command reasons from; a half-written one after a
/// crash or a full disk would be worse than none at all, because `doctor` and `update` would
/// believe it.
pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
{
let mut file =
File::create(&tmp).with_context(|| format!("cannot create {}", tmp.display()))?;
file.write_all(contents)
.with_context(|| format!("cannot write {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("cannot flush {}", tmp.display()))?;
}
// Windows will not rename onto an existing file, so the old one goes first. The window this
// opens is the reason for the .tmp file existing at all: its content is already durable.
if path.exists() {
fs::remove_file(path).with_context(|| format!("cannot replace {}", path.display()))?;
}
fs::rename(&tmp, path).with_context(|| format!("cannot move {} into place", tmp.display()))?;
Ok(())
}
/// How a command is written back to the operator when it fails.
///
/// Reproducible by hand is the whole point: every external command this tool runs — `systemctl`,
/// `useradd`, `sc.exe` — is one an operator can run themselves, and a failure they can retype is a
/// failure they can diagnose.
pub fn command_line<S: AsRef<OsStr>>(program: &str, args: &[S]) -> String {
let mut line = String::from(program);
for arg in args {
let text = arg.as_ref().to_string_lossy().into_owned();
line.push(' ');
if text.contains(' ') && !text.starts_with('"') {
line.push('"');
line.push_str(&text);
line.push('"');
} else {
line.push_str(&text);
}
}
line
}
/// Runs a program to completion, capturing its output. A non-zero exit is **not** an error here —
/// several callers ask questions whose answer *is* the exit code (`id -u`, `sc query`).
pub fn run<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
Command::new(program).args(args).output().with_context(|| {
format!(
"cannot run `{}` — is it installed and on PATH?",
command_line(program, args)
)
})
}
/// Runs a program and treats a non-zero exit as a failure, quoting what it printed.
///
/// **Never call this on anything that emits a secret.** The sidecar's `--print-config` writes the
/// auth token to stdout, so it is run through [`run`] and handled where the token can be kept out
/// of the error path (PLAN.md §6).
pub fn run_ok<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
let output = run(program, args)?;
if !output.status.success() {
bail!(failure_message(
&command_line(program, args),
output.status.code(),
&output.stderr,
&output.stdout,
));
}
Ok(output)
}
/// The message a failed command produces. Split out from [`run_ok`] because it is the part worth
/// testing — spawning a process that fails identically on Linux and Windows is not.
fn failure_message(line: &str, code: Option<i32>, stderr: &[u8], stdout: &[u8]) -> String {
let detail = first_useful_line(stderr)
.or_else(|| first_useful_line(stdout))
.unwrap_or_else(|| "(no output)".to_string());
let status = match code {
Some(code) => format!("exit code {code}"),
None => "no exit code (killed by a signal)".to_string(),
};
format!("`{line}` failed with {status}: {detail}")
}
/// The first non-blank line of a captured stream, for a one-line error message.
fn first_useful_line(bytes: &[u8]) -> Option<String> {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
// The canonical empty-input SHA256. If this ever changes, everything else in the trust chain
// is meaningless, so it is worth one line.
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn hashing_matches_sha256sum() {
assert_eq!(sha256_bytes(b""), EMPTY);
assert_eq!(
sha256_bytes(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn file_and_byte_hashing_agree() {
let dir = TempDir::new("rg-test-hash").unwrap();
let path = dir.path().join("f.bin");
// Larger than the 64 KiB read buffer, so the streaming path is actually exercised.
let blob: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
fs::write(&path, &blob).unwrap();
assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob));
}
#[test]
fn blob_hashing_matches_git_hash_object() {
// These are the values `git hash-object` prints, and the same ones a patch's `index` line
// carries. If this drifts, rung 1 silently stops recognising a stock file and every patch
// falls through to the region match — which still works, and would hide the bug for a long
// time.
assert_eq!(
git_blob_hash(b""),
"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
);
assert_eq!(
git_blob_hash(b"hello\n"),
"ce013625030ba8dba906f756967f9e9ca394464a"
);
// CRLF is hashed as it sits on disk — the ServUO files this is used on are all CRLF, and
// normalizing here would make every rung-1 check miss.
assert_ne!(git_blob_hash(b"a\r\n"), git_blob_hash(b"a\n"));
}
#[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 a_failed_command_is_reported_with_what_it_printed() {
// Both halves matter: the command to retype, and the reason it failed. stderr wins over
// stdout because that is where systemctl and sc.exe put the reason.
let message = failure_message(
"systemctl enable --now runicgateway-link.service",
Some(1),
b"Failed to enable unit: Unit file does not exist.\n",
b"noise\n",
);
assert!(message.contains("systemctl enable"), "{message}");
assert!(message.contains("exit code 1"), "{message}");
assert!(message.contains("Unit file does not exist."), "{message}");
// A command that fails silently must still say something usable.
let quiet = failure_message("sc.exe start RunicGatewayLink", Some(1053), b"", b"");
assert!(
quiet.contains("1053") && quiet.contains("(no output)"),
"{quiet}"
);
}
#[test]
fn a_missing_program_says_so_rather_than_panicking() {
let err = run("rg-no-such-program-exists", &["x"])
.unwrap_err()
.to_string();
assert!(err.contains("rg-no-such-program-exists"), "{err}");
}
#[test]
fn command_lines_quote_arguments_containing_spaces() {
// These strings are printed for an operator to paste back; an unquoted Windows path with
// spaces in it would be a command that does not work when they do.
let line = command_line(
"sc.exe",
&[
"create",
"RunicGatewayLink",
"binPath=",
"C:\\Program Files\\x.exe",
],
);
assert!(line.contains("\"C:\\Program Files\\x.exe\""), "{line}");
}
#[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());
}
}

View File

@@ -0,0 +1,33 @@
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -75,16 +75,27 @@
return o;
}
+ /// <summary>
+ /// Raised for every staff command log line — even when file logging is disabled — so an
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
+ /// </summary>
+ public static event Action<Mobile, string> OnWrite;
+
public static void WriteLine(Mobile from, string format, params object[] args)
{
- if (!m_Enabled)
- return;
-
WriteLine(from, String.Format(format, args));
}
public static void WriteLine(Mobile from, string text)
{
+ var onWrite = OnWrite;
+ if (onWrite != null)
+ {
+ try { onWrite(from, text); }
+ catch { }
+ }
+
if (!m_Enabled)
return;

47
tests/fixtures/patch_tier.json vendored Normal file
View File

@@ -0,0 +1,47 @@
{
"features": [
{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{
"name": "playervendor-sale-eventsink",
"file": "patches/playervendor-sale-eventsink.patch",
"target": "Server/EventSink.cs"
},
{
"name": "playervendor-sale-gump",
"file": "patches/playervendor-sale-gump.patch",
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
}
],
"companions": [
{
"file": "patches/BridgeVendorSale.cs",
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
}
]
},
{
"name": "moderation-audit",
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit",
"lost": "no in-game moderation audit forwarding",
"rebuild": "scripts",
"patches": [
{
"name": "commandlogging-event",
"file": "patches/commandlogging-event.patch",
"target": "Scripts/Commands/Logging.cs"
}
],
"companions": [
{
"file": "patches/BridgeModerationAudit.cs",
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
}
]
}
]
}

View File

@@ -0,0 +1,66 @@
diff --git a/Server/EventSink.cs b/Server/EventSink.cs
index d30788f..1da2667 100644
--- a/Server/EventSink.cs
+++ b/Server/EventSink.cs
@@ -171,6 +171,8 @@ namespace Server
public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
+ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
+
public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
public delegate void RepairItemEventHandler(RepairItemEventArgs e);
@@ -1521,6 +1523,29 @@ namespace Server
}
}
+ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
+ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
+ // are all in scope -- the data the bridge's cheat-detection feed needs.
+ public class PlayerVendorSaleEventArgs : EventArgs
+ {
+ public Mobile Buyer { get; set; }
+ public Mobile Vendor { get; set; }
+ public Mobile Owner { get; set; }
+ public Item Item { get; set; }
+ public int Price { get; set; }
+ public int Commission { get; set; }
+
+ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
+ {
+ Buyer = buyer;
+ Vendor = vendor;
+ Owner = owner;
+ Item = item;
+ Price = price;
+ Commission = commission;
+ }
+ }
+
public class CorpseLootEventArgs : EventArgs
{
public Mobile Mobile { get; set; }
@@ -1771,6 +1796,7 @@ namespace Server
public static event TameCreatureEventHandler TameCreature;
public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
public static event ValidVendorSellEventHandler ValidVendorSell;
+ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
public static event CorpseLootEventHandler CorpseLoot;
public static event RepairItemEventHandler RepairItem;
public static event AlterItemEventHandler AlterItem;
@@ -2416,6 +2442,14 @@ namespace Server
}
}
+ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
+ {
+ if (PlayerVendorSale != null)
+ {
+ PlayerVendorSale(e);
+ }
+ }
+
public static void InvokeCorpseLoot(CorpseLootEventArgs e)
{
if (CorpseLoot != null)

View File

@@ -0,0 +1,15 @@
diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
index 049aae6..f1b30d2 100644
--- a/Scripts/Gumps/PlayerVendorGumps.cs
+++ b/Scripts/Gumps/PlayerVendorGumps.cs
@@ -95,6 +95,10 @@ namespace Server.Gumps
m_Vendor.HoldGold += m_VI.Price - commission;
+ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
+ EventSink.InvokePlayerVendorSale(
+ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
+
from.SendLocalizedMessage(503201); // You take the item.
}
}

317
tests/real_patches.rs Normal file
View File

@@ -0,0 +1,317 @@
//! The rung ladder against the patches this tier actually ships.
//!
//! `src/patch.rs` proves the engine's rules on synthetic diffs, which is the right place to make a
//! rule fail on purpose. This file proves the same engine handles the three real ones — because
//! every property that matters here is a property of *those* files rather than of unified diffs in
//! general:
//!
//! - `commandlogging-event.patch` has no `diff --git` and no `index` line, so rung 1 is
//! structurally unavailable for it and it must still apply through rung 2.
//! - `playervendor-sale-eventsink.patch` carries four hunks against one file, so the all-or-nothing
//! rule, the descending-offset splice and the overlap check all get exercised at once.
//! - Every one of the three is CRLF in a Windows checkout and LF in the tarball CI builds, and both
//! spellings have to behave identically.
//!
//! The fixtures are copies of `servuo-plugins/patches/*.patch`. The *targets* are synthesized
//! rather than vendored: the real ones are ServUO's own sources, and reproducing the region a hunk
//! expects — surrounded by filler that is deliberately not ServUO — is a stricter test of a
//! content match than pasting in 2,600 lines that happen to contain it.
use rgdeploy::diff;
use rgdeploy::patch::{self, Refusal, Resolution, Rung};
use rgdeploy::util::git_blob_hash;
const PATCHES: &[(&str, &str)] = &[
("commandlogging-event.patch", "Scripts/Commands/Logging.cs"),
("playervendor-sale-eventsink.patch", "Server/EventSink.cs"),
(
"playervendor-sale-gump.patch",
"Scripts/Gumps/PlayerVendorGumps.cs",
),
];
fn fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
fn parse(name: &str) -> diff::FilePatch {
diff::parse(&fixture(name))
.unwrap_or_else(|e| panic!("{name} did not parse: {e}"))
.single_file()
.unwrap_or_else(|e| panic!("{name} is not single-target: {e}"))
.clone()
}
/// A stand-in for the stock ServUO file: each hunk's pre-image, in order, separated by filler that
/// could not be mistaken for context.
fn synthesize_target(file: &diff::FilePatch, eol: &str) -> Vec<u8> {
let mut out = String::new();
for (i, hunk) in file.hunks.iter().enumerate() {
for f in 0..12 {
out.push_str(&format!("// unrelated shard code {i}/{f}{eol}"));
}
for line in hunk.pre_image() {
out.push_str(&String::from_utf8_lossy(line));
out.push_str(eol);
}
}
out.push_str(&format!("// end of file{eol}"));
out.into_bytes()
}
fn edits_of(resolution: &Resolution) -> &[patch::Edit] {
match resolution {
Resolution::Applicable { edits, .. } => edits,
other => panic!("expected an applicable resolution, got {other:?}"),
}
}
#[test]
fn every_shipped_patch_parses_and_names_its_declared_target() {
for (name, target) in PATCHES {
let file = parse(name);
assert_eq!(&file.path, target, "{name}");
assert!(!file.hunks.is_empty(), "{name}");
assert!(
file.hunks.iter().any(|h| !h.is_noop()),
"{name} changes nothing"
);
}
}
#[test]
fn a_patch_without_an_index_line_still_applies_through_rung_two() {
// commandlogging-event.patch is a plain ---/+++ diff. Rung 1 cannot be reached for it at all,
// which must be a fact the tier reports rather than a reason to skip the patch.
let file = parse("commandlogging-event.patch");
assert_eq!(file.pre_blob, None, "this fixture has no index line");
let target = synthesize_target(&file, "\r\n");
let resolution = patch::resolve(&file, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch));
let patched = patch::apply(&target, edits_of(&resolution));
let text = String::from_utf8(patched).unwrap();
assert!(
text.contains("public static event Action<Mobile, string> OnWrite;"),
"{text}"
);
// The `m_Enabled` early return is deleted from the two-argument overload — a removal, not just
// an insertion, so the splice is doing more than appending.
assert_eq!(text.matches("if (!m_Enabled)").count(), 1, "{text}");
}
#[test]
fn the_four_hunk_patch_applies_all_of_them_at_the_right_offsets() {
// EventSink.cs is the one with several hunks in one file: the descending-offset splice, the
// overlap check and the all-or-nothing rule are all exercised together here.
let file = parse("playervendor-sale-eventsink.patch");
assert_eq!(file.hunks.len(), 4);
let target = synthesize_target(&file, "\r\n");
let resolution = patch::resolve(&file, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch));
assert_eq!(resolution.placements().len(), 4);
// Every hunk landed somewhere different, and in the order the diff declares them.
let lines: Vec<usize> = resolution
.placements()
.iter()
.map(|p| p.matched_line)
.collect();
assert!(lines.windows(2).all(|w| w[0] < w[1]), "{lines:?}");
let text = String::from_utf8(patch::apply(&target, edits_of(&resolution))).unwrap();
for expected in [
"public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);",
"public class PlayerVendorSaleEventArgs : EventArgs",
"public static event PlayerVendorSaleEventHandler PlayerVendorSale;",
"public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)",
] {
assert!(text.contains(expected), "missing: {expected}");
}
}
#[test]
fn a_stock_target_reaches_rung_one() {
// The synthesized file is not ServUO's, so its blob hash is not the one the diff records. Feed
// the diff the hash of the file it is about to be resolved against, which is exactly the
// situation on a genuinely stock tree.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\r\n");
let stated = diff::FilePatch {
pre_blob: Some(git_blob_hash(&target)[..7].to_string()),
..file
};
assert_eq!(
patch::resolve(&stated, &target).rung(),
Some(Rung::StockHash),
"{name}"
);
}
}
#[test]
fn applying_twice_is_a_no_op_for_every_shipped_patch() {
// The idempotence promise: `install` is documented as safe to re-run, and the tier is the part
// of it that edits files the operator owns.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\r\n");
let first = patch::apply(&target, edits_of(&patch::resolve(&file, &target)));
let second = patch::resolve(&file, &first);
assert_eq!(second.rung(), Some(Rung::AlreadyPresent), "{name}");
assert!(
matches!(second, Resolution::AlreadyPresent { .. }),
"{name} would be applied a second time"
);
}
}
#[test]
fn an_edit_inside_a_patched_region_is_refused_for_every_shipped_patch() {
// Rung 3 is the outcome most real shards will see on at least one patch, so it must be the one
// that never writes. The edit is placed on the first line the hunk actually removes or keeps.
for (name, _) in PATCHES {
let file = parse(name);
let target = synthesize_target(&file, "\n");
let anchor = file.hunks[0]
.pre_image()
.iter()
.map(|l| String::from_utf8_lossy(l).to_string())
.find(|l| l.trim().len() > 12)
.expect("a substantial context line to vandalize");
let vandalized = String::from_utf8_lossy(&target)
.replacen(&anchor, &format!("{anchor} /* operator's own change */"), 1)
.into_bytes();
assert_ne!(vandalized, target, "{name}: the fixture was not modified");
match patch::resolve(&file, &vandalized) {
Resolution::Refused(Refusal::RegionModified { .. }) => {}
other => panic!("{name}: expected a refusal, got {other:?}"),
}
}
}
#[test]
fn line_endings_do_not_change_the_verdict_or_the_result() {
// A patch is CRLF in a Windows checkout and LF in the tarball, and a target may be either. The
// rung reached must not depend on that, and the patched file must keep the ending it had.
for (name, _) in PATCHES {
let raw = fixture(name);
let as_lf = String::from_utf8_lossy(&raw)
.replace("\r\n", "\n")
.into_bytes();
let as_crlf = String::from_utf8_lossy(&as_lf)
.replace('\n', "\r\n")
.into_bytes();
let from_lf = diff::parse(&as_lf).unwrap().single_file().unwrap().clone();
let from_crlf = diff::parse(&as_crlf)
.unwrap()
.single_file()
.unwrap()
.clone();
assert_eq!(
from_lf, from_crlf,
"{name}: the two spellings parsed differently"
);
for eol in ["\n", "\r\n"] {
let target = synthesize_target(&from_lf, eol);
let resolution = patch::resolve(&from_lf, &target);
assert_eq!(resolution.rung(), Some(Rung::RegionMatch), "{name} {eol:?}");
let patched = patch::apply(&target, edits_of(&resolution));
let newlines = patched.iter().filter(|b| **b == b'\n').count();
let crlfs = patched.windows(2).filter(|w| w == b"\r\n").count();
if eol == "\r\n" {
assert_eq!(crlfs, newlines, "{name}: LF islands in a CRLF file");
} else {
assert_eq!(crlfs, 0, "{name}: CR appeared in an LF file");
}
}
}
}
#[test]
fn the_tier_the_release_workflow_emits_is_the_one_this_installer_reads() {
// `patch_tier.json` is the literal output of the jq filter in
// servuo-plugins/.gitea/workflows/release.yml, run over that repo's patches/tier.json. It is
// checked in so the two repos cannot drift apart quietly: a renamed field there would fail
// here rather than producing an empty tier on an operator's shard, where the only symptom is
// a patch tier that is never offered.
let json = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("patch_tier.json"),
)
.unwrap();
// Deserialized through Manifest, not through Tier, because the field's name and its
// `Option`-ness are half of the contract.
let manifest: serde_json::Value = serde_json::json!({
"component": "servuo-plugins-overlay",
"version": "0.2.0",
"commit": "0000000",
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"patch_tier": serde_json::from_str::<serde_json::Value>(&json).unwrap(),
"files": {}
});
let manifest: rgdeploy::overlay::Manifest = serde_json::from_value(manifest).unwrap();
let declared = manifest.patch_tier.expect("patch_tier must deserialize");
// The declared tier and the built-in fallback have to describe the same release, or an
// installer would behave differently against v0.1.1 and v0.2.0 of the same overlay.
assert_eq!(declared, rgdeploy::patch::Tier::builtin());
// ...and everything it names must be resolvable against the patches that ship.
for feature in &declared.features {
for declared_patch in &feature.patches {
let file = parse(&declared_patch.file.replace("patches/", ""));
assert_eq!(file.path, declared_patch.target, "{}", declared_patch.name);
}
for companion in &feature.companions {
assert!(companion.file.starts_with("patches/"), "{companion:?}");
}
}
}
#[test]
fn the_builtin_tier_names_exactly_the_patches_that_ship() {
// The fallback for overlay releases older than `patch_tier` in the manifest. It has to describe
// the release it stands in for, and the fixtures here are that release's patches.
let tier = rgdeploy::patch::Tier::resolve(None);
let mut declared: Vec<String> = tier
.features
.iter()
.flat_map(|f| f.patches.iter())
.map(|p| p.file.replace("patches/", ""))
.collect();
declared.sort();
let mut shipped: Vec<String> = PATCHES.iter().map(|(n, _)| n.to_string()).collect();
shipped.sort();
assert_eq!(declared, shipped);
for feature in &tier.features {
for declared in &feature.patches {
let file = parse(&declared.file.replace("patches/", ""));
assert_eq!(
file.path, declared.target,
"{}: the fallback declares a target the diff does not edit",
declared.name
);
}
}
}