4 Commits
v0.5.0 ... edge

Author SHA1 Message Date
6ab40a1e6a Merge pull request 'feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)' (#38) from feat/runicnpc-required into edge
Reviewed-on: #38
2026-10-07 02:42:56 +00:00
04bab67778 feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
- install/update refuse a Rust bundle that carries no RunicNPC, before anything
  is written, and say how to get one that does.
- doctor fails when RunicNPC is missing or was never installed; a file edited
  by hand stays a warning, since it still loads.
- compose-bundles.sh composes no Rust bundle without a RunicNPC release that
  answers the bridge's runicnpc_api (the published one stays), and fails a
  bridge that declares none; every Rust bundle now has `npc`.
- A RunicNPC API-mismatch message lost its line continuation; restored.

The bundle type still parses a bundle without `npc`, so doctor and an old pin
can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:31 -05:00
2567cf5dd3 Merge pull request 'feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)' (#37) from feat/runicnpc-stage4 into main
All checks were successful
sync-project-tree / sync (push) Successful in 20s
Release installer / release (push) Successful in 5m8s
Compose bundle / compose (push) Successful in 26s
Reviewed-on: #37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-30 19:51:58 +00:00
ab2efb62e4 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
2026-09-30 04:52:36 -05:00
9 changed files with 488 additions and 10 deletions

View File

@@ -64,12 +64,14 @@ SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
RUNICNPC_PREFIX="runicnpc"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
@@ -375,6 +377,40 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9
# (D310) — the bridge refuses every NPC an event places without it, and the
# installer refuses a Rust bundle that does not carry it. The bridge's manifest
# says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the
# latest RunicNPC release is carried only when it answers at least that. With
# none that does, the Rust bundle is not composed: the published one stays.
local npc need npc_tag npc_tarball npc_manifest npc_api
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
[ "$need" -gt 0 ] || fail \
"Rust-Plugins ${plugin_tag} declares no runicnpc_api, and RunicNPC is required (D310) — a bridge older than RunicNPC's stage 4 cannot be bundled any more."
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
[ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)."
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
[ "$npc_api" -ge "$need" ] || fail \
"RunicNPC ${npc_tag} answers API ${npc_api} and Rust-Plugins ${plugin_tag} needs ${need} — RunicNPC is required (D310), so no Rust bundle is composed until it releases one that does."
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
@@ -382,6 +418,7 @@ compose_rust() {
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
--argjson npc "$npc" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
@@ -390,7 +427,8 @@ compose_rust() {
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }' > "$WORK/rust-s2.json"
asset: $p_asset },
npc: $npc }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -403,7 +441,7 @@ compose_rust() {
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}, RunicNPC \`${npc_tag}\`."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}

View File

@@ -18,7 +18,8 @@
#
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
# and RunicNPC, required since its stage 9 (D224, D310)
#
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
@@ -56,8 +57,8 @@
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's,
# Rust-Plugins' and runicnpc-rust's release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead

View File

@@ -361,6 +361,27 @@ pub struct RustBundle {
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
/// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle
/// without it — every one before stage 4 — still PARSES, so `doctor` and an old pin can name
/// it, but `install` and `update` refuse it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>,
}
/// One exact RunicNPC release, chosen by CI to answer at least the API the bridge's
/// `runicnpc_api` asks for.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
/// The RunicNPC API it answers (`RunicNpc_ApiVersion`).
pub api: u32,
pub compat: PluginCompat,
/// One tarball: `runicnpc/{RunicNPC.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -485,6 +506,14 @@ pub fn parse_rust(body: &str) -> Result<RustBundle> {
bundle.bundle
);
}
if let Some(npc) = &bundle.npc {
if npc.asset.url.is_empty() || npc.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a RunicNPC asset with no URL or checksum",
bundle.bundle
);
}
}
Ok(bundle)
}
@@ -589,6 +618,37 @@ mod tests {
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_without_runicnpc_still_parses() {
let bundle = parse_rust(RUST).unwrap();
assert!(bundle.npc.is_none());
}
#[test]
fn a_rust_bundle_with_runicnpc_parses_and_is_checked() {
let mut value: serde_json::Value = serde_json::from_str(RUST).unwrap();
value["npc"] = serde_json::json!({
"repo": "RunicGateway/runicnpc-rust",
"tag": "v0.2.0",
"version": "0.2.0",
"commit": "abc",
"api": 3,
"compat": {
"frameworks": { "oxide": { "min_version": "2.0.7726" }, "carbon": { "min_version": "2.0.259" } },
"requires_plugins": ["Kits"]
},
"asset": { "name": "runicnpc-0.2.0.tar.gz", "url": "https://x/runicnpc-0.2.0.tar.gz", "sha256": "ab" }
});
let bundle = parse_rust(&value.to_string()).unwrap();
let npc = bundle.npc.as_ref().unwrap();
assert_eq!(
(npc.api, npc.compat.requires_plugins.clone()),
(3, vec!["Kits".to_string()])
);
value["npc"]["asset"]["sha256"] = serde_json::json!("");
assert!(parse_rust(&value.to_string()).is_err());
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();

View File

@@ -178,6 +178,34 @@ fn instance_rows(
}
}
// ── RunicNPC: ours, and required (D224, D310) ─────────────────────────────
// A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge
// refuses every NPC an event places without it. A file edited by hand still loads, so that
// stays a warning, as the bridge's own helpers are.
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::fail(&label("RunicNPC"), format!("{} is missing", file.path))
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` puts it back"),
),
}
} else {
rows.push(
Row::fail(&label("RunicNPC"), "not installed")
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` installs it from the current bundle"),
);
}
// ── 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,39 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is
// required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
// without it, so a bundle that cannot place it is refused before anything is written.
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 => bail!(
"bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \
the bridge refuses every NPC an event places. Install the current bundle (leave \
--bundle off), or pin one published with RunicNPC v1.0.0 or later.",
bundle.bundle
),
};
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
@@ -180,6 +226,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 +276,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&prepared,
&bundle,
&released,
npc_released.as_ref(),
plan,
binary_action.writes(),
prior
@@ -288,6 +336,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 +392,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 +436,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 +526,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 +549,7 @@ fn plan_instance(
plugin_action,
helper_actions,
retired_helpers,
npc_action,
game_port,
web_port,
})
@@ -536,6 +608,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 +648,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 +672,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 +848,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 +910,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 +952,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;

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

@@ -0,0 +1,172 @@
//! 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 required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
//! without it, Rust's own scientists included, so `install` and `update` refuse a bundle that does
//! not carry it and `doctor` fails a host that lacks it.
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);