//! `install.json` — what this host has deployed. //! //! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later //! command reasons from. Two of its properties are load-bearing rather than informational: //! //! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the //! record and the release manifest means the overlay moved on; differing from the record alone //! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and //! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on. //! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on //! a host where Phase 2 and 3 have written sidecar and patch records must give them back //! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved //! verbatim. A future field that silently vanished on a re-run would be worse than one that was //! never written. use std::collections::BTreeMap; use std::path::Path; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::util::write_atomic; /// The shape of this document. Independent of the bundle's `schema` and of any protocol version. pub const SCHEMA: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InstallRecord { pub schema: u32, pub installer: InstallerInfo, /// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one /// excluded when deciding whether a re-run has anything to record. pub updated: String, pub bundle: BundleRef, pub servuo: ServUoRef, #[serde(skip_serializing_if = "Option::is_none")] pub overlay: Option, /// The sidecar: binary, config, database, service (Phase 2). /// /// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer* /// installer — with fields this build has no name for — survives a re-run here intact. Phase 1 /// carried this section through without understanding it at all; the same tolerance now applies /// in the other direction. Read it with [`InstallRecord::link_record`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// The patch tier: one entry per feature actually in place, with the rung that applied each of /// its patches. /// /// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer /// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`]. /// Only features that are *applied* appear: a declined or refused one left no trace in the /// tree, and recording it would make `doctor` and `uninstall` report work nobody did. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub patches: Vec, /// Anything a newer installer wrote that this one has no name for. #[serde(flatten)] pub extra: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InstallerInfo { pub version: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BundleRef { pub tag: String, pub protocol: u32, /// The exact document this install resolved, so a re-install can be reproduced and a support /// question about "which bundle?" is answered by the file rather than by memory. pub url: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ServUoRef { pub path: String, /// The detected version, or `null`. Recorded even when unknown: the patch tier's support story /// follows the install (§2.2.2), and a later `doctor` must be able to show it without /// re-deriving it. pub version: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OverlayRecord { pub repo: String, pub tag: String, pub version: String, pub commit: String, pub protocol: u32, /// Keyed by ServUO-tree-relative path, always with `/` separators so a record written on /// Windows is readable on Linux and vice versa. pub files: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileRecord { /// What the release shipped for this path. pub overlay_sha256: String, /// What is on disk in the ServUO tree after this run. Equal to `overlay_sha256` except for a /// file the installer deliberately left alone (`Config/Bridge.cfg`, once edited). pub on_disk_sha256: String, /// `deployed` (the tree holds the release's copy) or `kept-operator-modified` (it holds the /// operator's). /// /// Deliberately a **state, not a verb**: recording `add` on the first run and `unchanged` on /// the next would make every re-run rewrite this file, which is exactly the "a second run /// writes nothing" promise in PLAN.md Phase 1. What later commands need to know is whose copy /// is in the tree, and that does not change just because time passed. pub state: String, } /// The sidecar half of a deployment, as `install.json` records it. /// /// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once /// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted /// into bug reports. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct LinkRecord { pub repo: String, pub tag: String, /// What the installed binary reports, not what the bundle claimed — the two agree, and if they /// ever did not, the binary is the one that will actually run. pub version: String, pub protocol: u32, pub binary: BinaryRef, pub config_path: String, /// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's /// directory rather than pinned by the service, which is why it is recorded rather than derived. pub db_path: String, /// `None` when no service was registered — a relocated test run, or a host with no service /// manager the installer can drive. `doctor` reports that as an unfinished install rather than /// as a healthy one. #[serde(skip_serializing_if = "Option::is_none")] pub service: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BinaryRef { pub path: String, pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ServiceRecord { /// `systemd` or `windows-scm`. pub kind: String, /// `runicgateway-link.service` or `RunicGatewayLink`. pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub unit_path: Option, /// The account the service runs as. #[serde(skip_serializing_if = "Option::is_none")] pub user: Option, /// The installer created that account. `uninstall` (Phase 4) removes only what it created — /// deleting a user that was already on the host is not this tool's business. pub user_created: bool, } impl InstallRecord { /// Whether a re-run would record anything new. /// /// Everything except `updated` is compared: PLAN.md Phase 1 requires that a second run with no /// upstream change reports "unchanged" and **writes nothing**, and rewriting the file purely to /// move a timestamp would break that promise in the least visible way possible — by touching a /// file whose mtime an operator may be watching. 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: Self = 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<()> { // Pretty-printed with a trailing newline: this file is read by humans during support, and // diffed by anyone who keeps /etc under version control. let mut body = serde_json::to_string_pretty(self).context("cannot serialize install.json")?; body.push('\n'); write_atomic(path, body.as_bytes()) } /// Files this installer previously deployed, for the `Bridge.cfg` comparison in `overlay::plan`. pub fn overlay_files(&self) -> Option<&BTreeMap> { self.overlay.as_ref().map(|o| &o.files) } /// The sidecar section, when it is one this build understands. /// /// A section it cannot parse yields `None` rather than an error: the raw value is still carried /// through on save, so the worst case is that this run re-derives what it needs instead of /// reading it — never that an older installer refuses to run on a newer host. pub fn link_record(&self) -> Option { serde_json::from_value(self.link.clone()?).ok() } /// The patch-tier entries this build understands. /// /// An entry it cannot parse is dropped from the returned list but still carried through on /// save, exactly as with [`Self::link_record`]. The consequence of a dropped entry is that this /// run re-derives that feature's state from the tree — which the rung ladder answers correctly /// on its own — rather than an older installer refusing to run on a newer host. pub fn patch_records(&self) -> Vec { self.patches .iter() .filter_map(|v| serde_json::from_value(v.clone()).ok()) .collect() } } pub fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; fn sample() -> InstallRecord { InstallRecord { schema: SCHEMA, installer: InstallerInfo { version: "0.1.0".into(), }, updated: "2026-08-04T18:00:00Z".into(), bundle: BundleRef { tag: "2026.08.04".into(), protocol: 3, url: "https://example/bundles/current.json".into(), }, servuo: ServUoRef { path: "/opt/ServUO".into(), version: Some("57.4".into()), }, overlay: Some(OverlayRecord { repo: "RunicGateway/servuo-plugins".into(), tag: "v0.1.1".into(), version: "0.1.1".into(), commit: "3a52abb".into(), protocol: 3, files: BTreeMap::from([( "Config/Bridge.cfg".to_string(), FileRecord { overlay_sha256: "aa".into(), on_disk_sha256: "aa".into(), state: "deployed".into(), }, )]), }), link: None, patches: Vec::new(), extra: BTreeMap::new(), } } #[test] fn a_record_round_trips() { let dir = TempDir::new("rg-test-record").unwrap(); let path = dir.path().join("install.json"); let record = sample(); record.save(&path).unwrap(); assert_eq!(InstallRecord::load(&path).unwrap().unwrap(), record); } #[test] fn a_missing_record_is_not_an_error() { let dir = TempDir::new("rg-test-record-missing").unwrap(); assert!(InstallRecord::load(&dir.path().join("nope.json")) .unwrap() .is_none()); } #[test] fn later_phases_survive_a_phase_one_rewrite() { // The scenario: Phase 2 and 3 have written sidecar and patch sections (and some future // field this build has never heard of), then an older installer re-runs. Dropping any of // it would make `doctor` and `uninstall` forget a service and a set of applied hunks. let dir = TempDir::new("rg-test-record-forward").unwrap(); let path = dir.path().join("install.json"); let body = r#"{ "schema": 1, "installer": { "version": "0.9.0" }, "updated": "2026-09-01T00:00:00Z", "bundle": { "tag": "2026.09.01", "protocol": 3, "url": "https://example/current.json" }, "servuo": { "path": "/opt/ServUO", "version": "57.4" }, "overlay": null, "link": { "version": "1.1.0", "service": "runicgateway-link.service" }, "patches": [ { "name": "commandlogging-event", "rung": "region-match" } ], "future_section": { "kept": true } }"#; std::fs::write(&path, body).unwrap(); let loaded = InstallRecord::load(&path).unwrap().unwrap(); loaded.save(&path).unwrap(); let text = std::fs::read_to_string(&path).unwrap(); assert!(text.contains("runicgateway-link.service"), "{text}"); assert!(text.contains("region-match"), "{text}"); assert!(text.contains("future_section"), "{text}"); } #[test] fn only_the_timestamp_is_ignored_when_deciding_to_rewrite() { let a = sample(); let mut b = a.clone(); b.updated = "2027-01-01T00:00:00Z".into(); assert!(a.same_deployment_as(&b)); // Anything that actually describes the deployment must count as a change. let mut c = a.clone(); c.bundle.tag = "2026.09.01".into(); assert!(!a.same_deployment_as(&c)); let mut d = a.clone(); if let Some(overlay) = d.overlay.as_mut() { overlay.files.get_mut("Config/Bridge.cfg").unwrap().state = "kept-operator-modified".into(); } assert!(!a.same_deployment_as(&d)); } #[test] fn the_link_section_round_trips_and_holds_no_secret() { let link = LinkRecord { repo: "RunicGateway/link".into(), tag: "v1.1.0".into(), version: "1.1.0".into(), protocol: 3, binary: BinaryRef { path: "/usr/bin/runicgateway-link".into(), sha256: "27d491ef".repeat(8), }, config_path: "/etc/runicgateway/sidecar.toml".into(), db_path: "/var/lib/runicgateway/uo-link.db".into(), service: Some(ServiceRecord { kind: "systemd".into(), name: "runicgateway-link.service".into(), unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()), user: Some("runicgateway".into()), user_created: true, }), }; let mut record = sample(); record.link = Some(serde_json::to_value(&link).unwrap()); assert_eq!(record.link_record().unwrap(), link); // install.json is pasted into bug reports. The token lives in sidecar.toml and on the // operator's terminal; there is no field here for it to arrive in. let text = serde_json::to_string(&record).unwrap(); assert!(!text.contains("auth_token"), "{text}"); assert!(!text.contains("token"), "{text}"); } #[test] fn an_unreadable_link_section_is_ignored_rather_than_fatal() { // A record written by a future installer must not stop this one from running. let mut record = sample(); record.link = Some(serde_json::json!({ "shape": "from a newer installer" })); assert!(record.link_record().is_none()); assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok()); } #[test] fn timestamps_are_utc_rfc3339() { let now = now_rfc3339(); assert!(now.ends_with('Z'), "{now}"); assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}"); } }