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
172 lines
6.2 KiB
Rust
172 lines
6.2 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 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"));
|
|
}
|
|
}
|