7 Commits
v0.3.0 ... main

Author SHA1 Message Date
331e30710e Merge pull request 'feat(rust): install the helpers a plugin release ships beside the bridge (D182)' (#35) from feat/rust-plugin-helpers into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Release installer / release (push) Successful in 6m12s
Reviewed-on: #35
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-27 06:11:18 +00:00
9d9ee3344b feat(rust): install the helpers a plugin release ships beside the bridge (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m37s
Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
RunicGateway.cs (docs/modules/rust/PLAN_FIXES.md D181, D182), and the org lead
decided it is installed by default. The tarball reader took only the bridge,
so the helper would have been downloaded and dropped.

- plugin::read_tarball reads every other .cs the release's manifest lists in
  `files`, and refuses the release when one is missing, does not match its own
  sha256, or is not a plain `<Name>.cs` (the name is written into a plugins
  directory, so nothing in a tarball may reach outside it). A .cs the manifest
  does not list is ignored, so a release older than helpers has none.
- install/update place each helper before the bridge, in place like the
  bridge (Oxide and Carbon reload on a change, not on a rename), put back one
  that was deleted or edited, and remove one a later release stops shipping.
- The record gains per-instance `helpers` (path + sha256). It is absent from
  every record written before this and reads back as none.
- doctor warns — never fails — on a missing or edited helper and says what
  the bridge loses without it; uninstall removes helpers before the bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:24:33 -05:00
3a3676a22d Merge pull request 'fix(rust): drop the stray spaces in the running-server handoff line' (#34) from fix/rust-handoff-spaces into main
All checks were successful
Release installer / release (push) Successful in 4m57s
sync-project-tree / sync (push) Successful in -1m31s
Compose bundle / compose (push) Successful in -1m29s
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 15:41:23 +00:00
9d73367e63 test(rust): pin the running-server handoff line whole
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m0s
The existing assertions checked fragments ("doctor --game rust
--server-id alpha"), which the stray-space version also contained. The
whole first line is now compared, so a wrapped source line cannot put
its indent back into the sentence. Fails against the pre-fix literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 10:14:22 -05:00
d61f46ffb6 fix(rust): drop the stray spaces in the running-server handoff line
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 57s
The D156 line printed "confirms it                  connected." — a
wrapped source line left a run of spaces inside the string literal.
Found by the phase 18 step 7 re-walk on Debian 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 10:09:33 -05:00
dc3d360cf2 Merge pull request 'fix(release): static musl Linux binaries, so the installer runs on Debian 12 (D158)' (#33) from ci/static-musl-installer into main
All checks were successful
sync-project-tree / sync (push) Successful in 19s
Release installer / release (push) Successful in 7m50s
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 14:53:13 +00:00
9f43260ee7 fix(release): static musl Linux binaries, so the installer runs on Debian 12 (D158)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m1s
The Linux installers were linked against the release runner's glibc and
needed glibc 2.39: v0.3.0 (and v0.2.0) failed on the first line on
Debian 12 (2.36) and Ubuntu 22.04 (2.35) with "GLIBC_2.39 not found" --
found by the Rust phase 18 walk, step 7. Both Linux targets are now
x86_64/aarch64-unknown-linux-musl, built with cargo-zigbuild (zig is the
one C compiler `ring` needs for both musl targets; Ubuntu packages a
musl gcc for x86_64 only), and the package step refuses a Linux binary
that `file` does not call statically linked. Windows is unchanged.

Tried in rust:1-slim-bookworm: both targets build, both are "statically
linked", and the x86_64 binary runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 04:47:07 -05:00
7 changed files with 419 additions and 30 deletions

View File

@@ -64,11 +64,15 @@ env:
GITEA_HOST: gitea.whitlocktech.com GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/installer REPO: RunicGateway/installer
BIN: runicgateway-installer BIN: runicgateway-installer
LINUX_TARGET: x86_64-unknown-linux-gnu # Both Linux binaries are STATIC (musl), D158. Linked against the runner's glibc
# they needed glibc 2.39 and would not start on Debian 12 or Ubuntu 22.04 — the
# hosts an operator is most likely to have (Rust phase 18 walk, step 7). The
# sidecar the installer deploys is already static for the same reason (D149).
LINUX_TARGET: x86_64-unknown-linux-musl
WINDOWS_TARGET: x86_64-pc-windows-gnu WINDOWS_TARGET: x86_64-pc-windows-gnu
# The installer has to run wherever the sidecar it installs can run, and link # 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). # publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
ARM64_TARGET: aarch64-unknown-linux-gnu ARM64_TARGET: aarch64-unknown-linux-musl
jobs: jobs:
release: release:
@@ -280,13 +284,14 @@ jobs:
set -euo pipefail set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update $SUDO apt-get update
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only # `ring` (under ureq's rustls) compiles C, so each Linux target needs a C compiler that
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust # targets musl. Ubuntu packages one for x86_64 only; zig (via cargo-zigbuild) is one
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling # compiler for both, so both Linux builds go through it. python3-pip installs it;
# C, on a missing bits/libc-header-start.h. # `file` is for the static-link check after the build.
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 gcc-aarch64-linux-gnu libc6-dev-arm64-cross \ build-essential gcc-mingw-w64-x86-64 python3-pip file \
curl ca-certificates git jq curl ca-certificates git jq
pip3 install --quiet --break-system-packages ziglang cargo-zigbuild
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -295,6 +300,7 @@ jobs:
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}" export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt rustup component add rustfmt
rustup target add "${LINUX_TARGET}"
rustup target add "${WINDOWS_TARGET}" rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}" rustup target add "${ARM64_TARGET}"
@@ -322,9 +328,9 @@ jobs:
run: cargo test --locked run: cargo test --locked
# ── RUST ADAPTER: build both targets ───────────────────────────────── # ── RUST ADAPTER: build both targets ─────────────────────────────────
- name: cargo build --release (Linux) - name: cargo build --release (Linux, static musl)
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
run: cargo build --release --locked --target "${LINUX_TARGET}" run: cargo zigbuild --release --locked --target "${LINUX_TARGET}"
- name: cargo build --release (Windows, cross via MinGW) - name: cargo build --release (Windows, cross via MinGW)
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
@@ -338,13 +344,9 @@ jobs:
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the # arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
# artifact is packaged, but nothing ever builds it — which is exactly how the first release # artifact is packaged, but nothing ever builds it — which is exactly how the first release
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`. # attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
- name: cargo build --release (Linux arm64, cross) - name: cargo build --release (Linux arm64, static musl)
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
env: run: cargo zigbuild --release --locked --target "${ARM64_TARGET}"
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: cargo build --release --locked --target "${ARM64_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ──────────────────── # ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
# SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3), # SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3),
@@ -356,6 +358,13 @@ jobs:
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64" cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
# A "static" binary that is quietly dynamic fails only on the operator's host, on the
# first line, with a glibc version error — so it is refused here instead (D158).
for f in "dist/${BIN}-linux-x86_64" "dist/${BIN}-linux-aarch64"; do
if ! file "$f" | grep -q 'statically linked'; then
echo "::error::$f is not statically linked: $(file -b "$f")"; exit 1
fi
done
# Every artifact must be listed: `sha256sum -c` passes silently over a # Every artifact must be listed: `sha256sum -c` passes silently over a
# file the sums do not mention, and an operator verifying a download # file the sums do not mention, and an operator verifying a download
# would get a pass on a binary nobody vouched for. # would get a pass on a binary nobody vouched for.

View File

@@ -4,6 +4,8 @@
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3): //! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
//! //!
//! - the framework, and whether the plugin file is still the one deployed; //! - the framework, and whether the plugin file is still the one deployed;
//! - each helper deployed beside it (D182), as a warning when it is gone or edited — the bridge
//! runs without one, and the row says what that costs;
//! - that the plugin's config names this instance; //! - that the plugin's config names this instance;
//! - `requires_plugins` present, as a warning (D153); //! - `requires_plugins` present, as a warning (D153);
//! - the service registered and running; //! - the service registered and running;
@@ -151,6 +153,31 @@ fn instance_rows(
)), )),
} }
// ── The helpers: ours too, and optional to the bridge (D182) ─────────────
// A warning rather than a failure: the bridge runs without a helper and falls back. What is
// lost is said, so an operator who removed one on purpose knows what they chose.
for (name, helper) in &instance.helpers {
match std::fs::read(&helper.path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => {
rows.push(Row::ok(&label("helper"), helper.path.clone()))
}
Ok(_) => rows.push(
Row::warn(
&label("helper"),
format!("{} is not the file that was deployed", helper.path),
)
.note(
"edited or replaced by hand; `update --game rust` puts the released one back",
),
),
Err(_) => rows.push(
Row::warn(&label("helper"), format!("{} is missing", helper.path))
.note(helper_absence(name))
.note("`update --game rust` puts it back"),
),
}
}
// ── The plugin's config: the website's ─────────────────────────────────── // ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) { match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok( Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
@@ -279,6 +306,18 @@ fn instance_rows(
} }
/// Whether a newer Rust bundle than the installed one is published. /// Whether a newer Rust bundle than the installed one is published.
/// What a missing helper costs, in the operator's words. Unknown helpers get a generic line.
fn helper_absence(name: &str) -> String {
match name {
"RunicGatewayZones.cs" => {
"the bridge still runs and scores its zones by position, but ZoneManager's \
own flags miss anybody already standing in a zone when it is created or restored"
.to_string()
}
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
}
}
fn bundle_row(record: &RustRecord) -> Row { fn bundle_row(record: &RustRecord) -> Row {
match bundle::fetch_rust(None) { match bundle::fetch_rust(None) {
Ok((current, _)) if current.bundle == record.bundle.tag => { Ok((current, _)) if current.bundle == record.bundle.tag => {

View File

@@ -19,7 +19,7 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use super::plugin::{self, ConfigView}; use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA}; use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
use super::server::{self, RustServer}; use super::server::{self, RustServer};
use super::sidecar; use super::sidecar;
use crate::bundle::{self, RustBundle}; use crate::bundle::{self, RustBundle};
@@ -42,6 +42,10 @@ struct Planned {
running: bool, running: bool,
plugin_config: Option<ConfigView>, plugin_config: Option<ConfigView>,
plugin_action: BinaryAction, plugin_action: BinaryAction,
/// Each helper in the release and what happens to it, in the release's (name) order.
helper_actions: Vec<BinaryAction>,
/// Helpers this instance's record holds that the release no longer ships: removed.
retired_helpers: Vec<String>,
game_port: u16, game_port: u16,
web_port: u16, web_port: u16,
config_path: PathBuf, config_path: PathBuf,
@@ -179,7 +183,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
)?); )?);
} }
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?; let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle); print_plan(&layout, &planned, binary_action, &bundle, &released);
if cli.verify { if cli.verify {
println!( println!(
@@ -326,7 +330,14 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
Some(b) if b.tag == bundle.bundle => { Some(b) if b.tag == bundle.bundle => {
// The same bundle can still have written something: a plugin edited by hand is // The same bundle can still have written something: a plugin edited by hand is
// put back, which is what `doctor` tells an operator to run `update` for. // put back, which is what `doctor` tells an operator to run `update` for.
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count(); let restored = planned
.iter()
.filter(|p| {
p.plugin_action.writes()
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
})
.count();
if restored == 0 && !binary_action.writes() { if restored == 0 && !binary_action.writes() {
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle) println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
} else { } else {
@@ -431,6 +442,30 @@ fn plan_instance(
} }
Ok(_) => BinaryAction::Replace, Ok(_) => BinaryAction::Replace,
}; };
// Helpers are decided the same way as the plugin: absent, identical or replaced. One the
// operator deleted is put back — the installer owns it, and `doctor` says what its absence costs.
let helper_actions = released
.helpers
.iter()
.map(
|helper| match std::fs::read(server.plugins_dir().join(&helper.name)) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
},
)
.collect();
let retired_helpers = recorded
.map(|i| {
i.helpers
.keys()
.filter(|name| !released.helpers.iter().any(|h| &h.name == *name))
.cloned()
.collect()
})
.unwrap_or_default();
let config_path = layout.rust_config(id); let config_path = layout.rust_config(id);
Ok(Planned { Ok(Planned {
id: id.to_string(), id: id.to_string(),
@@ -441,6 +476,8 @@ fn plan_instance(
server, server,
plugin_config, plugin_config,
plugin_action, plugin_action,
helper_actions,
retired_helpers,
game_port, game_port,
web_port, web_port,
}) })
@@ -451,6 +488,7 @@ fn print_plan(
planned: &[Planned], planned: &[Planned],
binary: BinaryAction, binary: BinaryAction,
bundle: &RustBundle, bundle: &RustBundle,
released: &plugin::Released,
) { ) {
ui::row( ui::row(
"binary", "binary",
@@ -479,6 +517,25 @@ fn print_plan(
p.plugin_action.label() p.plugin_action.label()
), ),
); );
for (helper, action) in released.helpers.iter().zip(&p.helper_actions) {
ui::row(
"helper",
&format!(
"{} {}",
p.server.plugins_dir().join(&helper.name).display(),
action.label()
),
);
}
for name in &p.retired_helpers {
ui::row(
"helper",
&format!(
"{} removed (no longer released)",
p.server.plugins_dir().join(name).display()
),
);
}
ui::row( ui::row(
"plugin config", "plugin config",
&match &p.plugin_config { &match &p.plugin_config {
@@ -662,6 +719,45 @@ fn deploy_instance(
} }
}; };
// The helpers before the plugin (D182): each loads the moment it lands, and the bridge reads a
// helper's state at hello — a helper already there is one the bridge's first hello reports.
// Written in place for the plugin's reason below.
let mut helpers = std::collections::BTreeMap::new();
for (helper, action) in released.helpers.iter().zip(&plan.helper_actions) {
let path = plan.server.plugins_dir().join(&helper.name);
if action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| {
format!("cannot create {}", plan.server.plugins_dir().display())
})?;
std::fs::write(&path, &helper.source)
.with_context(|| format!("cannot write {}", path.display()))?;
ui::ok(&format!(
"helper {} {}",
if *action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
path.display()
));
}
helpers.insert(
helper.name.clone(),
DeployedFile {
path: path.display().to_string(),
sha256: helper.sha256.clone(),
},
);
}
for name in &plan.retired_helpers {
let path = plan.server.plugins_dir().join(name);
match std::fs::remove_file(&path) {
Ok(()) => ui::ok(&format!("helper removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => ui::warn(&format!("could not remove {}: {e}", path.display())),
}
}
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial. // The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path(); let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() { if plan.plugin_action.writes() {
@@ -694,6 +790,7 @@ fn deploy_instance(
framework: plan.server.framework.as_str().to_string(), framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(), plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(), plugin_sha256: released.sha256.clone(),
helpers,
plugin_config: plugin_config_path.display().to_string(), plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before, plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port, game_port: plan.game_port,

View File

@@ -1,9 +1,12 @@
//! The plugin: its released tarball, and its config in the server root. //! The plugin: its released tarball, and its config in the server root.
//! //!
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3): //! Two kinds of file, two owners (docs/modules/rust/PLAN.md §34.2.3):
//! //!
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle //! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
//! moves, and is removed by `uninstall`. //! moves, and is removed by `uninstall`. So are the **helpers** released beside it
//! (docs/modules/rust/PLAN_FIXES.md D168, D182 — today `RunicGatewayZones.cs`): every other `.cs`
//! the release's manifest lists in `files`, each checked against its own sha256. A helper is
//! optional to the bridge at runtime and installed by default.
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the //! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist //! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose //! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
@@ -37,6 +40,28 @@ pub struct Released {
pub manifest: Manifest, pub manifest: Manifest,
pub source: Vec<u8>, pub source: Vec<u8>,
pub sha256: String, pub sha256: String,
/// The helpers the manifest lists, in name order. Empty for a release older than them.
pub helpers: Vec<Helper>,
}
/// One helper plugin shipped beside the bridge (D168, D182).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Helper {
/// The file name, as the manifest and the plugins directory both spell it.
pub name: String,
pub source: Vec<u8>,
pub sha256: String,
}
/// A helper's name is written into a plugins directory, so it must be a plain `<Name>.cs` and
/// nothing a tarball could use to reach outside that directory.
pub fn is_helper_name(name: &str) -> bool {
let Some(stem) = name.strip_suffix(".cs") else {
return false;
};
!stem.is_empty()
&& name != super::server::PLUGIN_FILE
&& stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
} }
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree. /// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
@@ -46,22 +71,27 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None; let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None; let mut source: Option<Vec<u8>> = None;
let mut others: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for entry in archive for entry in archive
.entries() .entries()
.context("the plugin tarball is not a tar.gz")? .context("the plugin tarball is not a tar.gz")?
{ {
let mut entry = entry.context("the plugin tarball is truncated")?; let mut entry = entry.context("the plugin tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/"); let name = entry.path()?.to_string_lossy().replace('\\', "/");
let slot = if name == format!("{PREFIX}/manifest.json") {
&mut manifest
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
&mut source
} else {
continue;
};
let mut bytes = Vec::new(); let mut bytes = Vec::new();
if name == format!("{PREFIX}/manifest.json") {
entry.read_to_end(&mut bytes)?; entry.read_to_end(&mut bytes)?;
*slot = Some(bytes); manifest = Some(bytes);
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
entry.read_to_end(&mut bytes)?;
source = Some(bytes);
} else if let Some(file) = name.strip_prefix(&format!("{PREFIX}/")) {
// Kept only if it is a plain helper name; which of them count is the manifest's call.
if is_helper_name(file) {
entry.read_to_end(&mut bytes)?;
others.insert(file.to_string(), bytes);
}
}
} }
let manifest = manifest let manifest = manifest
@@ -84,13 +114,51 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
); );
} }
} }
let helpers = read_helpers(&manifest, others)?;
Ok(Released { Ok(Released {
manifest, manifest,
source, source,
sha256, sha256,
helpers,
}) })
} }
/// Every file the manifest lists besides the bridge, each present and matching its sha256. A name
/// the manifest lists that is not a plain helper name refuses the release: the installer would
/// otherwise be asked to write it somewhere, and it will not guess where.
fn read_helpers(manifest: &Manifest, mut found: BTreeMap<String, Vec<u8>>) -> Result<Vec<Helper>> {
let mut helpers = Vec::new();
for (name, declared) in &manifest.files {
if name == super::server::PLUGIN_FILE {
continue;
}
if !is_helper_name(name) {
bail!(
"the plugin's manifest lists {name:?}, which is not a plugin file name this \
installer will write into a plugins directory — refusing the release."
);
}
let source = found.remove(name).ok_or_else(|| {
anyhow::anyhow!(
"the plugin's manifest lists {name} but the tarball has no {PREFIX}/{name}"
)
})?;
let sha256 = sha256_bytes(&source);
if !declared.eq_ignore_ascii_case(&sha256) {
bail!(
"{name} in the plugin tarball does not match its own manifest (sha256 {sha256}, \
manifest says {declared}) — refusing the release."
);
}
helpers.push(Helper {
name: name.clone(),
source,
sha256,
});
}
Ok(helpers)
}
/// What the plugin's config says, as far as the installer cares. /// What the plugin's config says, as far as the installer cares.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigView { pub struct ConfigView {
@@ -228,6 +296,133 @@ mod tests {
assert_eq!(view.port, Some(7799)); assert_eq!(view.port, Some(7799));
} }
/// A plugin tarball shaped like Rust-Plugins' release: `runicgateway-rust-plugin/<name>` entries.
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
let path = dir.join("plugin.tar.gz");
let file = std::fs::File::create(&path).unwrap();
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
file,
flate2::Compression::default(),
));
for (name, bytes) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
.unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
path
}
fn manifest(files: &[(&str, &[u8])]) -> Vec<u8> {
let files: serde_json::Map<String, serde_json::Value> = files
.iter()
.map(|(name, bytes)| ((*name).to_string(), sha256_bytes(bytes).into()))
.collect();
serde_json::to_vec(
&serde_json::json!({ "version": "0.2.0", "protocol": 13, "files": files }),
)
.unwrap()
}
const BRIDGE: &[u8] = b"// the bridge";
const ZONES: &[u8] = b"// the zone helper";
#[test]
fn a_release_with_a_helper_carries_it_checked() {
let dir = TempDir::new("rg-rust-helper").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
let path = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", ZONES),
],
);
let released = read_tarball(&path).unwrap();
assert_eq!(released.source, BRIDGE);
assert_eq!(released.helpers.len(), 1);
assert_eq!(released.helpers[0].name, "RunicGatewayZones.cs");
assert_eq!(released.helpers[0].source, ZONES);
assert_eq!(released.helpers[0].sha256, sha256_bytes(ZONES));
}
#[test]
fn a_release_older_than_helpers_has_none_even_with_a_stray_file() {
// Only what the manifest lists is a helper: a `.cs` it does not name is not installed.
let dir = TempDir::new("rg-rust-helper-old").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE)]);
let path = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", ZONES),
],
);
assert!(read_tarball(&path).unwrap().helpers.is_empty());
}
#[test]
fn a_helper_the_manifest_lists_must_be_there_and_match() {
let dir = TempDir::new("rg-rust-helper-bad").unwrap();
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
let missing = tarball(
dir.path(),
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
);
let err = read_tarball(&missing).unwrap_err().to_string();
assert!(err.contains("RunicGatewayZones.cs"), "{err}");
let tampered = tarball(
dir.path(),
&[
("manifest.json", &m),
("RunicGateway.cs", BRIDGE),
("RunicGatewayZones.cs", b"// something else"),
],
);
let err = read_tarball(&tampered).unwrap_err().to_string();
assert!(err.contains("does not match its own manifest"), "{err}");
}
#[test]
fn a_manifest_naming_a_path_is_refused() {
let dir = TempDir::new("rg-rust-helper-path").unwrap();
for name in ["../Evil.cs", "sub/Evil.cs", "Evil.dll", ".cs"] {
let m = manifest(&[("RunicGateway.cs", BRIDGE), (name, ZONES)]);
let path = tarball(
dir.path(),
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
);
let err = read_tarball(&path).unwrap_err().to_string();
assert!(err.contains("refusing the release"), "{name}: {err}");
}
}
#[test]
fn helper_names_are_plain_plugin_files() {
assert!(is_helper_name("RunicGatewayZones.cs"));
assert!(is_helper_name("Helper_2.cs"));
for bad in [
"RunicGateway.cs",
"../X.cs",
"a/X.cs",
"a\\X.cs",
"X.cs.bak",
".cs",
"X .cs",
"X-y.cs",
] {
assert!(!is_helper_name(bad), "{bad}");
}
}
#[test] #[test]
fn a_missing_required_plugin_is_named() { fn a_missing_required_plugin_is_named() {
let dir = TempDir::new("rg-rust-req").unwrap(); let dir = TempDir::new("rg-rust-req").unwrap();

View File

@@ -60,6 +60,10 @@ pub struct Instance {
pub plugin_path: String, pub plugin_path: String,
/// What was deployed, so `doctor` can tell an edited plugin file from the release's. /// What was deployed, so `doctor` can tell an edited plugin file from the release's.
pub plugin_sha256: String, pub plugin_sha256: String,
/// The helpers deployed beside the plugin (D168, D182), by file name. Installer-owned like the
/// plugin. Absent from a record written before helpers existed, which reads back as none.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub helpers: BTreeMap<String, DeployedFile>,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look. /// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String, pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way. /// The installer wrote that config (it did not exist). Informational; it is kept either way.
@@ -75,6 +79,13 @@ pub struct Instance {
pub service: Option<ServiceRecord>, pub service: Option<ServiceRecord>,
} }
/// One installer-owned file in a server root, as deployed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeployedFile {
pub path: String,
pub sha256: String,
}
impl RustRecord { impl RustRecord {
/// Everything but the timestamp, so a second run with nothing to do writes nothing. /// Everything but the timestamp, so a second run with nothing to do writes nothing.
pub fn same_deployment_as(&self, other: &Self) -> bool { pub fn same_deployment_as(&self, other: &Self) -> bool {
@@ -142,6 +153,7 @@ mod tests {
framework: "oxide".into(), framework: "oxide".into(),
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"), plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32), plugin_sha256: "ab".repeat(32),
helpers: BTreeMap::new(),
plugin_config: format!("{root}/oxide/config/RunicGateway.json"), plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true, plugin_config_written: true,
game_port: game, game_port: game,
@@ -199,6 +211,25 @@ mod tests {
assert!(!text.contains("auth_token") && !text.contains("token\"")); assert!(!text.contains("auth_token") && !text.contains("token\""));
} }
#[test]
fn a_record_written_before_helpers_loads_with_none() {
// Every host installed before D182 has one of these: no `helpers` key at all.
let dir = TempDir::new("rg-rust-record-old").unwrap();
let path = dir.path().join("install.json");
let mut value = serde_json::to_value(sample()).unwrap();
for instance in value["instances"].as_object_mut().unwrap().values_mut() {
assert!(instance
.as_object_mut()
.unwrap()
.remove("helpers")
.is_none());
}
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
let record = RustRecord::load(&path).unwrap().unwrap();
assert!(record.instances.values().all(|i| i.helpers.is_empty()));
assert_eq!(record, sample());
}
#[test] #[test]
fn ports_held_by_other_instances_exclude_the_one_being_installed() { fn ports_held_by_other_instances_exclude_the_one_being_installed() {
let record = sample(); let record = sample();

View File

@@ -265,6 +265,16 @@ mod tests {
running.contains("doctor --game rust --server-id alpha"), running.contains("doctor --game rust --server-id alpha"),
"{running}" "{running}"
); );
// The whole first line, so a wrapped source line cannot leave its indent in the sentence
// again (installer#34: "confirms it connected.").
assert_eq!(
running.lines().find(|l| !l.is_empty()),
Some(
"Rust server \"alpha\" is set up. The plugin loads now; \
`doctor --game rust --server-id alpha` confirms it connected."
),
"{running}"
);
} }
#[test] #[test]

View File

@@ -59,6 +59,9 @@ pub fn run(cli: &Cli) -> Result<i32> {
println!(" · remove the service {}", svc.name); println!(" · remove the service {}", svc.name);
} }
println!(" · remove {}", i.plugin_path); println!(" · remove {}", i.plugin_path);
for helper in i.helpers.values() {
println!(" · remove {}", helper.path);
}
println!( println!(
" · keep {} (the website's; it names this server)", " · keep {} (the website's; it names this server)",
i.plugin_config i.plugin_config
@@ -106,6 +109,11 @@ pub fn run(cli: &Cli) -> Result<i32> {
done.extend(removal.done); done.extend(removal.done);
problems.extend(removal.problems); problems.extend(removal.problems);
} }
// The helpers first: without the bridge they have nothing to do, and a helper left behind
// would keep patching ZoneManager for a bridge that is gone.
for helper in instance.helpers.values() {
remove_file(Path::new(&helper.path), &mut done, &mut problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems); remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge { if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems); remove_file(Path::new(&instance.config_path), &mut done, &mut problems);