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
173 lines
6.3 KiB
Rust
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"));
|
|
}
|
|
}
|