Commit Graph

6 Commits

Author SHA1 Message Date
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
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
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