3 Commits

Author SHA1 Message Date
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
c68a7d9d32 Merge pull request 'feat(rust): doctor says what a missing ZoneDomes helper costs' (#36) from feat/rust-domes-helper into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
Release installer / release (push) Successful in 6m0s
Compose bundle / compose (push) Successful in 22s
Reviewed-on: #36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-29 16:28:32 +00:00
939dd0465d feat(rust): doctor says what a missing ZoneDomes helper costs
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m4s
RunicGatewayDomes.cs (PLAN_REDESIGNS §3.2, D194) ships beside the bridge
from the next plugin release; the installer already places every helper
the manifest lists. doctor now names its absence in the operator's words:
zones still open, but the site offers no dome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:44 -05:00
9 changed files with 481 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,45 @@ 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, optional until its stage 9.
# The bridge's manifest says which RunicNPC API its npc.* commands need
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
# only when it answers at least that. A bridge that needs none carries none: a
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
if [ "$rc" -eq 2 ]; then
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
else
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}"
if [ "$npc_api" -lt "$need" ]; then
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
else
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"
npc_carried="$npc_tag"
fi
fi
fi
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
@@ -382,6 +423,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 +432,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 } }
+ (if $npc == null then {} else { npc: $npc } end)' > "$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 +446,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}${npc_carried:+, 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 when the bridge needs it (D224)
#
# 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,26 @@ 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. Optional until RunicNPC's stage 9 makes it
/// required, so a bundle without it — every one before stage 4 — still parses and installs.
#[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 +505,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 +617,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,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(
@@ -314,6 +335,11 @@ fn helper_absence(name: &str) -> String {
own flags miss anybody already standing in a zone when it is created or restored"
.to_string()
}
"RunicGatewayDomes.cs" => {
"the bridge still runs and its zones still open, but the site offers no dome over \
an event zone, because ZoneDomes cannot be called without it"
.to_string()
}
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
}
}

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);