diff --git a/.gitea/scripts/compose-bundles.sh b/.gitea/scripts/compose-bundles.sh index f40dfae..e4e2b39 100755 --- a/.gitea/scripts/compose-bundles.sh +++ b/.gitea/scripts/compose-bundles.sh @@ -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" } diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index 695549b..76b577a 100644 --- a/.gitea/workflows/bundle.yml +++ b/.gitea/workflows/bundle.yml @@ -18,7 +18,8 @@ # # current.json, bundle-.json schema 1, ServUO: link + overlay # v2/servuo/current.json, bundle-.json schema 2, game "servuo" -# v2/rust/current.json, bundle-.json schema 2, game "rust": Rust-Link + Rust-Plugins +# v2/rust/current.json, bundle-.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 diff --git a/src/bundle.rs b/src/bundle.rs index 8486b23..439e387 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -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, +} + +/// 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 { 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(); diff --git a/src/rustgame/doctor.rs b/src/rustgame/doctor.rs index 326a8c2..22ea06b 100644 --- a/src/rustgame/doctor.rs +++ b/src/rustgame/doctor.rs @@ -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( diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 3bec6ac..0b1cf3e 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -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, /// Helpers this instance's record holds that the release no longer ships: removed. retired_helpers: Vec, + /// RunicNPC, when the bundle carries it (D224): absent, identical or replaced. + npc_action: Option, 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 { 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(), diff --git a/src/rustgame/mod.rs b/src/rustgame/mod.rs index c7b51d1..290e27f 100644 --- a/src/rustgame/mod.rs +++ b/src/rustgame/mod.rs @@ -13,6 +13,7 @@ mod doctor; mod install; +mod npc; mod plugin; mod record; mod server; diff --git a/src/rustgame/npc.rs b/src/rustgame/npc.rs new file mode 100644 index 0000000..a35b74f --- /dev/null +++ b/src/rustgame/npc.rs @@ -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, +} + +/// 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")); + } +} diff --git a/src/rustgame/record.rs b/src/rustgame/record.rs index 7947d9f..4089166 100644 --- a/src/rustgame/record.rs +++ b/src/rustgame/record.rs @@ -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, /// 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, + /// `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, /// 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. diff --git a/src/rustgame/uninstall.rs b/src/rustgame/uninstall.rs index 5073e2a..bf750e5 100644 --- a/src/rustgame/uninstall.rs +++ b/src/rustgame/uninstall.rs @@ -62,6 +62,12 @@ pub fn run(cli: &Cli) -> Result { 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 { 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);