feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
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:
117
src/net.rs
Normal file
117
src/net.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! HTTP fetches and verified downloads.
|
||||
//!
|
||||
//! Everything this module pulls comes from a public Gitea repo over anonymous HTTPS — the shard
|
||||
//! host has no Gitea credentials and needs none (PLAN.md §1). The one rule that matters:
|
||||
//! **nothing downloaded is used before its SHA256 has been checked against the bundle.** The
|
||||
//! artifacts are deliberately unsigned (§3), so the checksum is the entire trust anchor, and a
|
||||
//! download that "mostly worked" is exactly the case that must not proceed.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufWriter};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
|
||||
use crate::util::HashingWriter;
|
||||
|
||||
/// Identifies the tool and its version in Gitea's logs — worth having when an operator reports that
|
||||
/// a fetch failed and nobody can tell which build made the request.
|
||||
fn user_agent() -> String {
|
||||
format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
|
||||
///
|
||||
/// The global timeout is generous because the overlay tarball travels over whatever link the shard
|
||||
/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run
|
||||
/// with a message instead of hanging an operator's terminal indefinitely.
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::Agent::config_builder()
|
||||
.user_agent(user_agent())
|
||||
.timeout_global(Some(Duration::from_secs(300)))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Fetches a small text document (the bundle manifest).
|
||||
pub fn get_text(url: &str) -> Result<String> {
|
||||
let mut response = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.with_context(|| format!("cannot reach {url}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!("{url} returned HTTP {}", status.as_u16());
|
||||
}
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.with_context(|| format!("cannot read the response from {url}"))
|
||||
}
|
||||
|
||||
/// Downloads `url` to `dest`, verifying SHA256 **while writing**.
|
||||
///
|
||||
/// On mismatch the partial file is removed before returning: leaving a wrong-hash artifact on disk
|
||||
/// invites a later step — or a puzzled operator — to use it anyway.
|
||||
pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Result<()> {
|
||||
let expected = expected_sha256.trim().to_ascii_lowercase();
|
||||
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})");
|
||||
}
|
||||
|
||||
let mut response = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.with_context(|| format!("cannot reach {url}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!("{url} returned HTTP {}", status.as_u16());
|
||||
}
|
||||
|
||||
let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?;
|
||||
let mut writer = HashingWriter::new(BufWriter::new(file));
|
||||
io::copy(&mut response.body_mut().as_reader(), &mut writer)
|
||||
.with_context(|| format!("download of {url} failed"))?;
|
||||
let actual = writer.finish();
|
||||
|
||||
if actual != expected {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
return Err(anyhow!(
|
||||
"checksum mismatch for {url}\n expected {expected}\n got {actual}\n\
|
||||
These artifacts are unsigned, so the checksum is the only thing vouching for them. \
|
||||
Refusing to use this download."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
#[test]
|
||||
fn the_user_agent_names_the_build() {
|
||||
let ua = user_agent();
|
||||
assert!(ua.starts_with("runicgateway-installer/"), "{ua}");
|
||||
assert!(ua.len() > "runicgateway-installer/".len(), "{ua}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_expected_hash_is_refused_before_any_request() {
|
||||
// A bundle whose sha256 field is truncated, uppercase-garbled or empty must stop the run
|
||||
// rather than download something that can then only be compared against nonsense. The URL
|
||||
// is unroutable on purpose: reaching the network at all would be the bug.
|
||||
let dir = TempDir::new("rg-test-net").unwrap();
|
||||
let dest = dir.path().join("artifact");
|
||||
for bad in ["", "abc", &"z".repeat(64)] {
|
||||
let err = download_verified("http://127.0.0.1:1/artifact", &dest, bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("unusable SHA256"),
|
||||
"expected a pre-flight refusal, got: {err}"
|
||||
);
|
||||
}
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user