//! 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, } /// RunicNPC's released file, read out of its tarball. #[derive(Debug, Clone)] pub struct Released { pub manifest: Manifest, pub source: Vec, 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 { 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> = None; let mut source: Option> = 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 { 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")); } }