//! The plugin: its released tarball, and its config in the server root. //! //! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3): //! //! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle //! moves, and is removed by `uninstall`. //! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the //! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist //! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose //! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the //! ServUO overlay follows for `Bridge.cfg`. 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 the plugin tarball (Rust-Plugins' release.yml). const PREFIX: &str = "runicgateway-rust-plugin"; /// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`. #[derive(Debug, Clone, Deserialize)] pub struct Manifest { pub version: String, pub protocol: u32, #[serde(default)] pub files: BTreeMap, } /// The released plugin, read out of its tarball. #[derive(Debug, Clone)] pub struct Released { pub manifest: Manifest, pub source: Vec, pub sha256: String, } /// Reads the plugin and its manifest out of a downloaded tarball, and checks that 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 plugin tarball is not a tar.gz")? { let mut entry = entry.context("the plugin tarball is truncated")?; let name = entry.path()?.to_string_lossy().replace('\\', "/"); let slot = if name == format!("{PREFIX}/manifest.json") { &mut manifest } else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) { &mut source } else { continue; }; let mut bytes = Vec::new(); entry.read_to_end(&mut bytes)?; *slot = Some(bytes); } let manifest = manifest .ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?; let source = source.ok_or_else(|| { anyhow::anyhow!( "the plugin tarball has no {PREFIX}/{}", super::server::PLUGIN_FILE ) })?; let manifest: Manifest = serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?; let sha256 = sha256_bytes(&source); if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) { if !declared.eq_ignore_ascii_case(&sha256) { bail!( "the plugin 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." ); } } Ok(Released { manifest, source, sha256, }) } /// What the plugin's config says, as far as the installer cares. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigView { pub server_id: Option, pub port: Option, } /// Reads `RunicGateway.json`, or `None` when the plugin has never written one. pub fn read_config(path: &Path) -> Result> { if !path.exists() { return Ok(None); } let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; let value: serde_json::Value = serde_json::from_str(&text).with_context(|| { format!( "{} is not valid JSON. The plugin will not load it either; fix or remove it.", path.display() ) })?; Ok(Some(ConfigView { server_id: value .get("ServerId") .and_then(|v| v.as_str()) .map(str::to_string), port: value .get("Port") .and_then(|v| v.as_u64()) .and_then(|p| u16::try_from(p).ok()), })) } /// The config the installer writes when there is none: just the two keys it decides. The plugin /// fills in every other key on its first load, with its own defaults (§34.2.3). pub fn initial_config(server_id: &str, port: u16) -> String { let doc = serde_json::json!({ "ServerId": server_id, "Port": port }); let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default(); text.push('\n'); text } /// Refuses an existing config that names another server. pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> { match view.server_id.as_deref() { Some(id) if id == server_id => Ok(()), Some(id) => bail!( "{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \ The website locks a server's id once it has seen it, and this installer never rewrites \ the plugin's config. Run again with --server-id {id}, or — if this server really is \ new to the site — remove that file and run again.", path.display() ), // A config with no ServerId gets the plugin's default ("main") on load. Treat that as what // it will become rather than as agreement. None => { if server_id == "main" { Ok(()) } else { bail!( "{} has no ServerId, so the plugin will call this server \"main\"; this run was \ asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \ with --server-id main.", path.display() ) } } } } /// Which of `required` are missing from a plugins directory. Reported, never installed (D153). pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec { required .iter() .filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file()) .cloned() .collect() } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; #[test] fn the_first_config_holds_only_what_the_installer_decides() { let text = initial_config("alpha", 7800); let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(value.as_object().unwrap().len(), 2); assert_eq!(value["ServerId"], "alpha"); assert_eq!(value["Port"], 7800); } #[test] fn an_existing_config_for_another_server_refuses_the_run() { let path = Path::new("oxide/config/RunicGateway.json"); let view = ConfigView { server_id: Some("rust-oxide".into()), port: Some(7799), }; assert!(check_server_id(&view, "rust-oxide", path).is_ok()); let err = check_server_id(&view, "alpha", path) .unwrap_err() .to_string(); assert!( err.contains("\"rust-oxide\"") && err.contains("\"alpha\""), "{err}" ); } #[test] fn a_config_without_an_id_is_main() { let path = Path::new("x.json"); let view = ConfigView { server_id: None, port: None, }; assert!(check_server_id(&view, "main", path).is_ok()); assert!(check_server_id(&view, "alpha", path).is_err()); } #[test] fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() { let dir = TempDir::new("rg-rust-cfg").unwrap(); let path = dir.path().join("RunicGateway.json"); assert_eq!(read_config(&path).unwrap(), None); // What the rig's plugin actually wrote, 2026-09-26. std::fs::write( &path, r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide", "EventsEnabled": true, "MapMaxBases": 2000 }"#, ) .unwrap(); let view = read_config(&path).unwrap().unwrap(); assert_eq!(view.server_id.as_deref(), Some("rust-oxide")); assert_eq!(view.port, Some(7799)); } #[test] fn a_missing_required_plugin_is_named() { let dir = TempDir::new("rg-rust-req").unwrap(); std::fs::write(dir.path().join("Kits.cs"), b"").unwrap(); let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]); assert_eq!(missing, ["ZoneManager"]); } }