feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s

The compose job carries the latest RunicNPC release that answers the API the
bridge's manifest declares (runicnpc_api), verified against its SHA256SUMS
like the other two; none when the bridge needs none, RunicNPC has not
released, or its API is too old, each said in the summary. Walked against a
mock Gitea in all three cases.

The installer reads the optional npc component (older bundles still parse),
fetches and checks RunicNPC's tarball against its manifest, places
RunicNPC.cs before the bridge in each instance's plugins directory, records
it, puts it back on update when edited or deleted, reports it in doctor, and
removes it on uninstall. Its data directory is never touched. Kits joins the
required plugins it reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 04:52:36 -05:00
parent c68a7d9d32
commit ab2efb62e4
9 changed files with 476 additions and 10 deletions

View File

@@ -178,6 +178,27 @@ fn instance_rows(
}
}
// ── RunicNPC: ours when the bundle carried it (D224) ──────────────────────
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is
// lost is the site's NPC profiles and placements and events' profile NPCs.
if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => {
rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path)))
}
Ok(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path))
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path))
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off")
.note("`update --game rust` puts it back"),
),
}
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(

View File

@@ -18,8 +18,9 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::npc;
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
@@ -46,6 +47,8 @@ struct Planned {
helper_actions: Vec<BinaryAction>,
/// Helpers this instance's record holds that the release no longer ships: removed.
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<BinaryAction>,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -141,6 +144,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
bundle.payload.protocol
),
);
if let Some(npc) = &bundle.npc {
ui::row(
"RunicNPC",
&format!(
"{:<24} API {}",
format!("runicnpc-rust {}", npc.tag),
npc.api
),
);
}
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
@@ -170,6 +183,31 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned.
let npc_released = match &bundle.npc {
Some(component) => {
let path = scratch.path().join(&component.asset.name);
crate::net::download_verified(&component.asset.url, &path, &component.asset.sha256)?;
let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api {
bail!(
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a component the bundle does not describe.",
bundle.bundle,
released.manifest.api,
component.api
);
}
if released.manifest.version != component.version {
ui::warn(&format!(
"the RunicNPC tarball says version {} but bundle {} names {}. The checksum matched, \n so this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, component.version
));
}
Some(released)
}
None => None,
};
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
@@ -180,6 +218,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
id,
root,
&released,
npc_released.as_ref(),
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
@@ -229,6 +268,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&prepared,
&bundle,
&released,
npc_released.as_ref(),
plan,
binary_action.writes(),
prior
@@ -288,6 +328,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&bundle.payload.commit,
bundle.payload.protocol,
);
// A bundle without RunicNPC leaves a recorded one where it is: it may be an operator's.
if let Some(component) = &bundle.npc {
record.npc = Some(NpcRecord {
repo: component.repo.clone(),
tag: component.tag.clone(),
version: component.version.clone(),
commit: component.commit.clone(),
api: component.api,
});
}
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
@@ -334,6 +384,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
.iter()
.filter(|p| {
p.plugin_action.writes()
|| p.npc_action.is_some_and(|a| a.writes())
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
})
@@ -377,6 +428,7 @@ fn plan_instance(
id: &str,
root: &Path,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
@@ -466,6 +518,17 @@ fn plan_instance(
.collect()
})
.unwrap_or_default();
// RunicNPC as the plugin is: put back when it was deleted or edited (D224).
let npc_action =
npc_released.map(
|r| match std::fs::read(server.plugins_dir().join(npc::FILE)) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == r.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
},
);
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
@@ -478,6 +541,7 @@ fn plan_instance(
plugin_action,
helper_actions,
retired_helpers,
npc_action,
game_port,
web_port,
})
@@ -536,6 +600,16 @@ fn print_plan(
),
);
}
if let Some(action) = p.npc_action {
ui::row(
"RunicNPC",
&format!(
"{} {}",
p.server.plugins_dir().join(npc::FILE).display(),
action.label()
),
);
}
ui::row(
"plugin config",
&match &p.plugin_config {
@@ -566,10 +640,16 @@ fn print_plan(
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
// RunicNPC's own requirements join the bridge's: it will not load without Kits (D217).
let mut required = bundle.payload.compat.requires_plugins.clone();
if let Some(component) = &bundle.npc {
for name in &component.compat.requires_plugins {
if !required.contains(name) {
required.push(name.clone());
}
}
}
let missing = plugin::missing_plugins(&p.server.plugins_dir(), &required);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
@@ -584,11 +664,13 @@ fn print_plan(
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
#[allow(clippy::too_many_arguments)]
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
@@ -758,6 +840,35 @@ fn deploy_instance(
}
}
// RunicNPC before the bridge (D224): the bridge reports it at hello, and one already loaded is
// one its first hello names. Written in place, for the plugin's reason below.
let npc_file = match (npc_released, plan.npc_action) {
(Some(r), Some(action)) => {
let path = plan.server.plugins_dir().join(npc::FILE);
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, &r.source)
.with_context(|| format!("cannot write {}", path.display()))?;
ui::ok(&format!(
"RunicNPC {} {}",
if action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
path.display()
));
}
Some(DeployedFile {
path: path.display().to_string(),
sha256: r.sha256.clone(),
})
}
_ => None,
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
@@ -791,6 +902,7 @@ fn deploy_instance(
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
helpers,
npc: npc_file,
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
@@ -832,6 +944,7 @@ fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
npc: None,
service_user_created: false,
instances: Default::default(),
extra: Default::default(),

View File

@@ -13,6 +13,7 @@
mod doctor;
mod install;
mod npc;
mod plugin;
mod record;
mod server;

171
src/rustgame/npc.rs Normal file
View File

@@ -0,0 +1,171 @@
//! RunicNPC: Runic Gateway's NPC plugin, a third file beside the bridge (docs/runicnpc/PLAN.md
//! D224, stage 4).
//!
//! **`RunicNPC.cs` is the installer's**, like the bridge and its helpers: it comes from the bundle,
//! is replaced when the bundle moves, is put back by `update` when it was edited or deleted, and is
//! removed by `uninstall`. Its DATA is not: `data/RunicNPC/` holds an admin's placements and
//! routes, which RunicNPC writes and the site edits through the bridge, and nothing here touches
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
//! is not writable by it (runicnpc PLAN.md §1.5).
//!
//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host
//! without it runs the bridge with Rust's own scientists only (D243).
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside RunicNPC's release tarball (runicnpc-rust's release.yml).
const PREFIX: &str = "runicnpc";
/// The one file placed, in the same plugins directory as the bridge.
pub const FILE: &str = "RunicNPC.cs";
/// `runicnpc/manifest.json`, which RunicNPC's release folds from `plugin.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub api: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// RunicNPC's released file, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads RunicNPC and its manifest out of a downloaded tarball, and checks the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the RunicNPC tarball is not a tar.gz")?
{
let mut entry = entry.context("the RunicNPC tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
if name == format!("{PREFIX}/manifest.json") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
manifest = Some(bytes);
} else if name == format!("{PREFIX}/{FILE}") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
source = Some(bytes);
}
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/manifest.json"))?;
let source =
source.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/{FILE}"))?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("RunicNPC's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
match manifest.files.get(FILE) {
Some(declared) if declared.eq_ignore_ascii_case(&sha256) => {}
Some(declared) => bail!(
"RunicNPC in the tarball does not match its own manifest (sha256 {sha256}, manifest \
says {declared}). The tarball matched the bundle's checksum, so the release itself is \
inconsistent — refusing it."
),
None => bail!("RunicNPC's manifest does not list {FILE} — refusing a release that does not say what it ships"),
}
Ok(Released {
manifest,
source,
sha256,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
let path = dir.join("runicnpc.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(sha: &str) -> Vec<u8> {
serde_json::json!({
"component": "runicnpc", "version": "0.2.0", "commit": "abc", "api": 3,
"requires_plugins": ["Kits"], "files": { FILE: sha }
})
.to_string()
.into_bytes()
}
#[test]
fn a_release_is_read_and_its_file_checked_against_its_manifest() {
let dir = TempDir::new("rg-npc").unwrap();
let source = b"// Requires: Kits\nclass RunicNPC {}";
let good = tarball(
dir.path(),
&[
("manifest.json", &manifest(&sha256_bytes(source))),
(FILE, source),
],
);
let released = read_tarball(&good).unwrap();
assert_eq!(
(released.manifest.api, released.manifest.version.as_str()),
(3, "0.2.0")
);
assert_eq!(released.source, source);
let bad = tarball(
dir.path(),
&[
("manifest.json", &manifest(&"00".repeat(32))),
(FILE, source),
],
);
assert!(read_tarball(&bad)
.unwrap_err()
.to_string()
.contains("does not match its own manifest"));
}
#[test]
fn a_release_without_the_file_or_the_manifest_is_refused() {
let dir = TempDir::new("rg-npc-missing").unwrap();
let only_manifest = tarball(dir.path(), &[("manifest.json", &manifest("ab"))]);
assert!(read_tarball(&only_manifest)
.unwrap_err()
.to_string()
.contains("has no runicnpc/RunicNPC.cs"));
let only_file = tarball(dir.path(), &[(FILE, b"x")]);
assert!(read_tarball(&only_file)
.unwrap_err()
.to_string()
.contains("manifest.json"));
}
}

View File

@@ -30,6 +30,9 @@ pub struct RustRecord {
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// RunicNPC's release, when the bundle carried one (docs/runicnpc/PLAN.md D224).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcRecord>,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
@@ -51,6 +54,17 @@ pub struct ComponentRecord {
pub protocol: u32,
}
/// Which RunicNPC release is deployed, and the API it answers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub api: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
@@ -64,6 +78,9 @@ pub struct Instance {
/// 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>,
/// `RunicNPC.cs`, when the bundle carried RunicNPC. Its data directory is not the installer's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<DeployedFile>,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
@@ -154,6 +171,7 @@ mod tests {
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
helpers: BTreeMap::new(),
npc: None,
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
@@ -191,6 +209,7 @@ mod tests {
commit: "abc".into(),
protocol: 12,
},
npc: None,
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
@@ -211,6 +230,34 @@ mod tests {
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn a_record_with_runicnpc_round_trips_and_one_without_loads_as_none() {
let dir = TempDir::new("rg-rust-record-npc").unwrap();
let path = dir.path().join("install.json");
let mut record = sample();
record.npc = Some(NpcRecord {
repo: "RunicGateway/runicnpc-rust".into(),
tag: "v0.2.0".into(),
version: "0.2.0".into(),
commit: "abc".into(),
api: 3,
});
record.instances.get_mut("alpha").unwrap().npc = Some(DeployedFile {
path: "/srv/a/oxide/plugins/RunicNPC.cs".into(),
sha256: "ef".repeat(32),
});
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
// A record from before stage 4 has neither key, and reads back as none.
let old = sample();
old.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("\"npc\""));
let back = RustRecord::load(&path).unwrap().unwrap();
assert!(back.npc.is_none() && back.instances.values().all(|i| i.npc.is_none()));
}
#[test]
fn a_record_written_before_helpers_loads_with_none() {
// Every host installed before D182 has one of these: no `helpers` key at all.

View File

@@ -62,6 +62,12 @@ pub fn run(cli: &Cli) -> Result<i32> {
for helper in i.helpers.values() {
println!(" · remove {}", helper.path);
}
if let Some(npc) = &i.npc {
println!(
" · remove {} (RunicNPC; its placements in data/RunicNPC/ are kept)",
npc.path
);
}
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
@@ -114,6 +120,10 @@ pub fn run(cli: &Cli) -> Result<i32> {
for helper in instance.helpers.values() {
remove_file(Path::new(&helper.path), &mut done, &mut problems);
}
// RunicNPC is the installer's file (D224); its data directory is the admins', and stays.
if let Some(npc) = &instance.npc {
remove_file(Path::new(&npc.path), &mut done, &mut problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);