Files
installer/src/rustgame/npc.rs
wtclaude 04bab67778
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
- 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

173 lines
6.3 KiB
Rust

//! 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"));
}
}