//! `rust/install.json` — what this host has deployed for Rust. //! //! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md //! §34.4): a host running both games has two records that cannot corrupt each other, and an //! installer that predates Rust never sees this one at all. //! //! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every //! instance together because they share the binary (§34.4), which is why the bundle is recorded once //! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in //! each instance's `sidecar.toml` — and fields this build has no name for are carried through. use std::collections::BTreeMap; use std::path::Path; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord}; use crate::util::write_atomic; /// The shape of this document. pub const SCHEMA: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RustRecord { pub schema: u32, pub installer: InstallerInfo, pub updated: String, pub bundle: BundleRef, pub sidecar: ComponentRecord, pub binary: BinaryRef, pub plugin: ComponentRecord, /// 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. #[serde(default)] pub service_user_created: bool, pub instances: BTreeMap, #[serde(flatten)] pub extra: BTreeMap, } /// A released component, as installed. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ComponentRecord { pub repo: String, pub tag: String, pub version: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub commit: String, pub protocol: u32, } /// One Rust server and its sidecar. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Instance { pub server_root: String, /// `oxide` or `carbon`, as detected when this was recorded. pub framework: String, pub plugin_path: String, /// What was deployed, so `doctor` can tell an edited plugin file from the release's. pub plugin_sha256: String, /// 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. #[serde(default)] pub plugin_config_written: bool, /// The loopback port the plugin dials — the plugin config's `Port`. pub game_port: u16, /// The port the website reaches this instance's sidecar on. pub web_port: u16, pub config_path: String, pub db_path: String, #[serde(skip_serializing_if = "Option::is_none")] pub service: Option, } impl RustRecord { /// Everything but the timestamp, so a second run with nothing to do writes nothing. pub fn same_deployment_as(&self, other: &Self) -> bool { let mut a = self.clone(); let mut b = other.clone(); a.updated.clear(); b.updated.clear(); a == b } pub fn load(path: &Path) -> Result> { if !path.exists() { return Ok(None); } let body = std::fs::read_to_string(path) .with_context(|| format!("cannot read {}", path.display()))?; let record = serde_json::from_str(&body).with_context(|| { format!( "{} exists but is not a record this installer understands. \ Move it aside to start over, or install a newer installer.", path.display() ) })?; Ok(Some(record)) } pub fn save(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; } let mut body = serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?; body.push('\n'); write_atomic(path, body.as_bytes()) } /// Game ports held by instances other than `except`. pub fn game_ports_except(&self, except: &str) -> Vec { self.instances .iter() .filter(|(id, _)| id.as_str() != except) .map(|(_, i)| i.game_port) .collect() } /// Web ports held by instances other than `except`. pub fn web_ports_except(&self, except: &str) -> Vec { self.instances .iter() .filter(|(id, _)| id.as_str() != except) .map(|(_, i)| i.web_port) .collect() } } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; pub fn sample() -> RustRecord { let instance = |root: &str, game: u16, web: u16| Instance { server_root: root.into(), framework: "oxide".into(), plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"), plugin_sha256: "ab".repeat(32), plugin_config: format!("{root}/oxide/config/RunicGateway.json"), plugin_config_written: true, game_port: game, web_port: web, config_path: "/etc/runicgateway/rust/x.toml".into(), db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(), service: None, }; RustRecord { schema: SCHEMA, installer: InstallerInfo { version: "0.3.0".into(), }, updated: "2026-09-26T00:00:00Z".into(), bundle: BundleRef { tag: "2026.09.26".into(), protocol: 12, url: "https://example/v2/rust/current.json".into(), }, sidecar: ComponentRecord { repo: "RunicGateway/Rust-Link".into(), tag: "v0.1.0".into(), version: "0.1.0".into(), commit: String::new(), protocol: 12, }, binary: BinaryRef { path: "/usr/bin/runicgateway-rust-link".into(), sha256: "cd".repeat(32), }, plugin: ComponentRecord { repo: "RunicGateway/Rust-Plugins".into(), tag: "v0.1.0".into(), version: "0.1.0".into(), commit: "abc".into(), protocol: 12, }, service_user_created: true, instances: BTreeMap::from([ ("alpha".to_string(), instance("/srv/a", 7799, 8090)), ("beta".to_string(), instance("/srv/b", 7800, 8091)), ]), extra: BTreeMap::new(), } } #[test] fn a_record_round_trips_and_holds_no_token() { let dir = TempDir::new("rg-rust-record").unwrap(); let path = dir.path().join("rust").join("install.json"); let record = sample(); record.save(&path).unwrap(); assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record); let text = std::fs::read_to_string(&path).unwrap(); assert!(!text.contains("auth_token") && !text.contains("token\"")); } #[test] fn ports_held_by_other_instances_exclude_the_one_being_installed() { let record = sample(); assert_eq!(record.game_ports_except("alpha"), [7800]); assert_eq!(record.web_ports_except("beta"), [8090]); } }