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>
This commit is contained in:
2026-08-04 14:58:17 -05:00
parent 0e7d5f3bee
commit dff4ad41c9
17 changed files with 4106 additions and 21 deletions

205
src/util.rs Normal file
View File

@@ -0,0 +1,205 @@
//! Hashing and scratch-directory helpers.
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
/// Lower-case hex, written out rather than taken from a crate.
///
/// Every hash this tool handles is compared against one produced by `sha256sum` or by `jq` in CI,
/// both of which emit lower-case hex — so the formatting is part of the contract, not a display
/// choice.
pub fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
/// Hashes a buffer. Used by the tests to prove the streaming paths below agree with a
/// straight-line hash of the same bytes; the run itself only ever hashes files and streams.
#[cfg(test)]
pub fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex(&hasher.finalize())
}
/// Streams a file through SHA256 rather than reading it whole: the overlay tarball and ServUO's
/// `Scripts.dll` are both large enough that slurping them is a waste, and this same function runs
/// once per deployed file.
pub fn sha256_file(path: &Path) -> Result<String> {
let mut file =
File::open(path).with_context(|| format!("cannot read {} to hash it", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("cannot read {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex(&hasher.finalize()))
}
/// A [`Write`] that hashes everything passing through it.
///
/// Downloads are verified *while* being written rather than by re-reading the finished file: it
/// halves the I/O and, more importantly, means the bytes that were hashed are provably the bytes
/// that were written.
pub struct HashingWriter<W: Write> {
inner: W,
hasher: Sha256,
}
impl<W: Write> HashingWriter<W> {
pub fn new(inner: W) -> Self {
Self {
inner,
hasher: Sha256::new(),
}
}
pub fn finish(self) -> String {
hex(&self.hasher.finalize())
}
}
impl<W: Write> Write for HashingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.hasher.update(&buf[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// A scratch directory that deletes itself.
///
/// Downloads and the extracted overlay land here. Hand-rolled rather than pulled from a crate
/// because the requirement is one directory with a unique name and a `Drop` — and because a failed
/// cleanup must never fail the run: by the time it matters the install has already succeeded or
/// failed on its own merits.
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn new(prefix: &str) -> Result<Self> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()));
fs::create_dir_all(&path)
.with_context(|| format!("cannot create scratch directory {}", path.display()))?;
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
/// Writes a file by writing a sibling `.tmp` and renaming over the target.
///
/// `install.json` is the record every later command reasons from; a half-written one after a
/// crash or a full disk would be worse than none at all, because `doctor` and `update` would
/// believe it.
pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
{
let mut file =
File::create(&tmp).with_context(|| format!("cannot create {}", tmp.display()))?;
file.write_all(contents)
.with_context(|| format!("cannot write {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("cannot flush {}", tmp.display()))?;
}
// Windows will not rename onto an existing file, so the old one goes first. The window this
// opens is the reason for the .tmp file existing at all: its content is already durable.
if path.exists() {
fs::remove_file(path).with_context(|| format!("cannot replace {}", path.display()))?;
}
fs::rename(&tmp, path).with_context(|| format!("cannot move {} into place", tmp.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// The canonical empty-input SHA256. If this ever changes, everything else in the trust chain
// is meaningless, so it is worth one line.
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn hashing_matches_sha256sum() {
assert_eq!(sha256_bytes(b""), EMPTY);
assert_eq!(
sha256_bytes(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn file_and_byte_hashing_agree() {
let dir = TempDir::new("rg-test-hash").unwrap();
let path = dir.path().join("f.bin");
// Larger than the 64 KiB read buffer, so the streaming path is actually exercised.
let blob: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
fs::write(&path, &blob).unwrap();
assert_eq!(sha256_file(&path).unwrap(), sha256_bytes(&blob));
}
#[test]
fn the_hashing_writer_sees_what_was_written() {
let mut w = HashingWriter::new(Vec::new());
w.write_all(b"abc").unwrap();
assert_eq!(
w.finish(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn a_temp_dir_removes_itself() {
let path = {
let dir = TempDir::new("rg-test-drop").unwrap();
fs::write(dir.path().join("x"), b"x").unwrap();
dir.path().to_path_buf()
};
assert!(!path.exists());
}
#[test]
fn atomic_write_replaces_an_existing_file() {
let dir = TempDir::new("rg-test-atomic").unwrap();
let path = dir.path().join("nested").join("install.json");
write_atomic(&path, b"first").unwrap();
write_atomic(&path, b"second").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"second");
assert!(!path.with_extension("tmp").exists());
}
}