//! The bundle manifest — "what to install", resolved at run time. //! //! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of //! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches //! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's //! newest release and hoping the two agree. //! //! Two consequences show up directly in this module: //! //! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and //! cross-checked against the overlay's own `manifest.json` at deploy time. //! - **`schema` is not `protocol`.** It versions the shape of this document and moves //! independently of both components' versions; a bundle from a newer CI is refused rather than //! half-understood. use anyhow::{bail, Context, Result}; use base64::Engine as _; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS — /// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1). /// /// They live on a **branch of their own**, not on `main`, and at its root. `main` is protected, so /// the unattended compose job cannot push there — a pre-receive hook declines it, which is not /// something a nightly cron can resolve. Everything the original choice was for survives the move: /// a reviewable diff, a git history of the compat matrix, and a plain anonymous URL. const BUNDLE_BASE: &str = "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Bundle { pub schema: u32, /// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two. pub bundle: String, pub generated: String, /// The wire protocol both halves were checked to agree on. pub protocol: u32, pub link: LinkComponent, pub overlay: OverlayComponent, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct LinkComponent { pub repo: String, pub tag: String, pub version: String, pub protocol: u32, /// Keyed by platform (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`) — link publishes a /// binary per target and the installer runs on each, so a single hash could only ever describe /// one of them. The set grows over time, so a bundle is not expected to carry every key this /// binary knows about: an older one pinned with `--bundle` predates arm64 entirely. pub assets: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OverlayComponent { pub repo: String, pub tag: String, pub version: String, pub commit: String, pub protocol: u32, pub servuo: ServUoCompat, /// One tarball, platform-independent: the overlay is C# source that ServUO compiles at boot. pub asset: Asset, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ServUoCompat { /// The oldest ServUO the *base* overlay is known good on. It only adds files. pub min_version: String, /// The single ServUO version the *patch tier* was written and verified against (§2.2). pub patches_verified_against: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Asset { pub name: String, pub url: String, pub sha256: String, } impl Bundle { /// The bundle's own asset for the platform this binary is running on. /// /// Installing the sidecar is Phase 2, but the lookup lives here so that a run on a platform the /// bundle has no binary for fails while resolving — before anything has been written into a /// ServUO tree — rather than after the overlay is already deployed. pub fn sidecar_asset(&self) -> Result<&Asset> { let key = platform_key()?; self.link.assets.get(key).ok_or_else(|| { anyhow::anyhow!( "bundle {} has no uo-link binary for {key} (it has: {}).\n\ Bundles published before uo-link built for this platform cannot gain one \ retroactively — they are kept unchanged so `--bundle` stays reproducible. \ Run without `--bundle` to take the current one.", self.bundle, self.link .assets .keys() .cloned() .collect::>() .join(", ") ) }) } } /// The platform key used by `link.assets`, matching the names the bundle CI assigns. pub fn platform_key() -> Result<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("linux", "x86_64") => Ok("linux-x86_64"), // Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the // sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a // Windows-on-arm box or a Mac. ("linux", "aarch64") => Ok("linux-aarch64"), ("windows", "x86_64") => Ok("windows-x86_64"), // Naming the platforms that do exist beats failing later with a missing-key error that // reads like a corrupt bundle. (os, arch) => bail!( "no Runic Gateway build exists for {os}/{arch}. \ The released components target linux-x86_64, linux-aarch64 and windows-x86_64." ), } } /// URL of a schema-1 bundle: the current one, or a specific one when `--bundle ` pins it. /// /// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md /// §34.4). This build reads it only as a fallback — see [`fetch`]. pub fn url_for(tag: Option<&str>) -> String { match tag { Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"), None => format!("{BUNDLE_BASE}/current.json"), } } /// Which game a schema-2 bundle describes, and so which stream it is read from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Game { ServUo, Rust, } impl Game { /// The `game` discriminant in the document, and the directory under `v2/`. pub fn as_str(self) -> &'static str { match self { Self::ServUo => "servuo", Self::Rust => "rust", } } } /// The Gitea **contents** API for the `bundles` branch. /// /// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with /// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the /// branch — an `install` run right after a release would install the release before it. The /// contents API is `private, must-revalidate` and always answers with the branch as it is. It /// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps. const CONTENTS_BASE: &str = "https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents"; /// The file name of a bundle in its stream. fn file_name(tag: Option<&str>) -> String { match tag { Some(tag) => format!("bundle-{tag}.json"), None => "current.json".to_string(), } } /// The address a person opens to read a schema-2 bundle — what `install.json` records. The same /// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens. pub fn v2_url(game: Game, tag: Option<&str>) -> String { format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag)) } /// Fetches a schema-2 document's text from its game's stream. fn fetch_v2(game: Game, tag: Option<&str>) -> Result { let url = format!( "{CONTENTS_BASE}/v2/{}/{}?ref=bundles", game.as_str(), file_name(tag) ); let envelope = crate::net::get_text(&url)?; #[derive(Deserialize)] struct Contents { content: String, } let contents: Contents = serde_json::from_str(&envelope) .context("the bundles branch answered with something that is not a file")?; // Gitea wraps the base64 at 76 columns; the decoder wants it whole. let packed: String = contents .content .chars() .filter(|c| !c.is_whitespace()) .collect(); let bytes = base64::engine::general_purpose::STANDARD .decode(packed) .context("the bundle file is not valid base64")?; String::from_utf8(bytes).context("the bundle file is not UTF-8") } /// Fetches and validates a ServUO bundle. /// /// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any /// pin published since schema 2 are read from `v2/servuo/`; a `--bundle ` from before it exists /// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody /// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing /// while both are composed and means this build never depends on the new stream alone. /// /// The returned URL is the document that was actually used. pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> { let v2 = fetch_v2(Game::ServUo, tag); let (body, url) = match v2 { Ok(body) => (body, v2_url(Game::ServUo, tag)), Err(v2_error) => { let url = url_for(tag); let body = crate::net::get_text(&url) .map_err(|_| v2_error) .with_context(|| match tag { Some(tag) => format!( "cannot read bundle {tag}. Every published bundle is kept forever, so \ check the tag against {BUNDLE_BASE}/v2/servuo/" ), None => "cannot read the current bundle manifest".to_string(), })?; (body, url) } }; let bundle = parse(&body)?; Ok((bundle, url)) } /// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is /// downloaded. /// /// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO /// code path, and `install.json`, already speaks it, and the two documents carry exactly the same /// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO /// deployment therefore records the same thing whichever schema it was resolved from. pub fn parse(body: &str) -> Result { let head: SchemaHead = serde_json::from_str(body) .context("the bundle manifest is not in the shape this installer understands")?; let bundle: Bundle = match head.schema { 1 => serde_json::from_str(body) .context("the bundle manifest is not in the shape this installer understands")?, 2 => { if head.game.as_deref() != Some(Game::ServUo.as_str()) { bail!( "bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \ Rust bundle.", head.bundle, head.game.unwrap_or_default() ); } let v2: ServUoBundleV2 = serde_json::from_str(body).context( "the schema-2 ServUO bundle is not in the shape this installer understands", )?; v2.lower() } other => bail!( "bundle {} declares schema {other} and this installer understands 1 and 2. \ Update the installer — the bundle format changed.", head.bundle, ), }; // Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all. // Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated // manifest that never went through the compose job. if bundle.link.protocol != bundle.overlay.protocol || bundle.protocol != bundle.link.protocol { bail!( "bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, overlay {}. \ A mismatched pair is rejected by the sidecar with 409 rather than mis-parsed, so this \ is refused here.", bundle.bundle, bundle.protocol, bundle.link.protocol, bundle.overlay.protocol ); } if bundle.overlay.asset.url.is_empty() || bundle.overlay.asset.sha256.is_empty() { bail!( "bundle {} names an overlay asset with no URL or checksum", bundle.bundle ); } Ok(bundle) } /// Just enough of any bundle to decide how to read the rest of it. #[derive(Deserialize)] struct SchemaHead { schema: u32, #[serde(default)] game: Option, #[serde(default)] bundle: String, } /// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower). #[derive(Deserialize)] struct ServUoBundleV2 { bundle: String, generated: String, protocol: u32, sidecar: LinkComponent, payload: OverlayPayloadV2, } #[derive(Deserialize)] struct OverlayPayloadV2 { kind: String, repo: String, tag: String, version: String, commit: String, protocol: u32, compat: ServUoCompat, asset: Asset, } impl ServUoBundleV2 { fn lower(self) -> Bundle { // `kind` is informational here: `game` already said ServUO, and a ServUO payload is an // overlay by definition. Kept in the struct so a document missing it fails to parse. let _ = self.payload.kind; Bundle { schema: 2, bundle: self.bundle, generated: self.generated, protocol: self.protocol, link: self.sidecar, overlay: OverlayComponent { repo: self.payload.repo, tag: self.payload.tag, version: self.payload.version, commit: self.payload.commit, protocol: self.payload.protocol, servuo: self.payload.compat, asset: self.payload.asset, }, } } } // ── Rust ───────────────────────────────────────────────────────────────────── /// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI /// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RustBundle { pub schema: u32, pub game: String, pub bundle: String, pub generated: String, pub protocol: u32, pub sidecar: RustSidecar, pub payload: PluginPayload, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RustSidecar { pub repo: String, pub tag: String, pub version: String, pub protocol: u32, /// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149). pub assets: BTreeMap, /// The egg's launcher. The installer does not use it — a host runs the sidecar as a service — /// but it is part of the pair CI checked, so it is carried rather than dropped. pub launcher: Asset, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PluginPayload { pub kind: String, pub repo: String, pub tag: String, pub version: String, pub commit: String, pub protocol: u32, pub compat: PluginCompat, /// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`. pub asset: Asset, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PluginCompat { /// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by /// `doctor` beside a failure; never measured from a DLL (§34.2.3). pub frameworks: BTreeMap, /// Third-party plugins the features expect. Reported, never installed (D153). pub requires_plugins: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FrameworkFloor { pub min_version: String, } impl RustBundle { /// The sidecar binary for this host. Resolved before anything is written, like ServUO's. pub fn sidecar_asset(&self) -> Result<&Asset> { let key = platform_key()?; self.sidecar.assets.get(key).ok_or_else(|| { anyhow::anyhow!( "Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \ itself runs on linux-x86_64 and windows-x86_64 only.", self.bundle, self.sidecar .assets .keys() .cloned() .collect::>() .join(", ") ) }) } } /// Fetches and validates a Rust bundle: the current one, or `--bundle `. pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> { let body = fetch_v2(Game::Rust, tag).with_context(|| match tag { Some(tag) => format!( "cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \ tag against {BUNDLE_BASE}/v2/rust/" ), None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \ released, there is none yet." .to_string(), })?; Ok((parse_rust(&body)?, v2_url(Game::Rust, tag))) } /// Parses a Rust bundle and applies the checks that must hold before anything is downloaded. pub fn parse_rust(body: &str) -> Result { let head: SchemaHead = serde_json::from_str(body) .context("the Rust bundle is not in the shape this installer understands")?; if head.schema != 2 { bail!( "Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2", head.bundle, head.schema ); } if head.game.as_deref() != Some(Game::Rust.as_str()) { bail!( "bundle {} describes game {:?}, not Rust", head.bundle, head.game.unwrap_or_default() ); } let bundle: RustBundle = serde_json::from_str(body) .context("the Rust bundle is not in the shape this installer understands")?; // Gate 1 again, for a document that never went through CI. It matters more here than for // ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being // refused. if bundle.sidecar.protocol != bundle.payload.protocol || bundle.protocol != bundle.sidecar.protocol { bail!( "Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}", bundle.bundle, bundle.protocol, bundle.sidecar.protocol, bundle.payload.protocol ); } if bundle.payload.kind != "plugin" { bail!( "Rust bundle {} carries a {:?} payload; this installer deploys a plugin", bundle.bundle, bundle.payload.kind ); } if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() { bail!( "Rust bundle {} names a plugin asset with no URL or checksum", bundle.bundle ); } Ok(bundle) } #[cfg(test)] mod tests { use super::*; /// The first published bundle, verbatim. Using a real document rather than a hand-written /// stand-in is the point: it is what CI actually emits. /// /// It is a frozen copy rather than a live include, because published bundles moved off `main` /// onto a branch this checkout does not carry. Frozen is the honest shape anyway — a test that /// silently re-targeted whatever CI published last would change meaning without a commit. const CURRENT: &str = include_str!("../tests/fixtures/published-bundle.json"); #[test] fn the_published_bundle_parses() { let bundle = parse(CURRENT).unwrap(); assert_eq!(bundle.schema, 1); assert_eq!(bundle.bundle, "2026.08.04"); assert_eq!(bundle.protocol, 3); assert_eq!(bundle.link.version, "1.1.0"); assert_eq!(bundle.overlay.version, "0.1.1"); assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4"); assert!(bundle.overlay.asset.name.ends_with(".tar.gz")); } #[test] fn every_platform_the_bundle_names_is_well_formed() { // A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release // (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it // rather than on anything being wrong. let bundle = parse(CURRENT).unwrap(); for required in ["linux-x86_64", "windows-x86_64"] { let asset = bundle .link .assets .get(required) .unwrap_or_else(|| panic!("bundle carries no {required} binary")); assert_eq!(asset.sha256.len(), 64); assert!(asset.url.contains(&bundle.link.tag)); } } #[test] fn the_hosts_binary_either_resolves_or_says_why_not() { // On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise // fail an install after the overlay had already been deployed. On a host whose platform // postdates the bundle (an arm64 box reading the first published one), it must fail with // the reason, since every bundle is kept unchanged forever so `--bundle` stays // reproducible and therefore cannot gain a key retroactively. let bundle = parse(CURRENT).unwrap(); match bundle.sidecar_asset() { Ok(asset) => { assert_eq!(asset.sha256.len(), 64); assert!(asset.url.contains(&bundle.link.tag)); } Err(e) => { let msg = e.to_string(); assert!(msg.contains(platform_key().unwrap()), "{msg}"); assert!(msg.contains("--bundle"), "{msg}"); } } } #[test] fn the_host_is_a_platform_the_components_are_built_for() { // `cargo test` running at all means the host is one the crate compiles on, so a refusal // here is a build target the release workflows have not caught up with. let key = platform_key().unwrap(); assert!( ["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key), "unexpected platform key {key}" ); } #[test] fn a_newer_schema_is_refused_rather_than_guessed_at() { let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3"); let err = parse(&body).unwrap_err().to_string(); assert!(err.contains("schema 3"), "{err}"); } /// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as /// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag. const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json"); const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json"); /// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with /// the mock host's URLs pointed back at Gitea. const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json"); #[test] fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() { // The lowering is the whole claim that a ServUO host records the same deployment whichever // stream it read. Only `schema` and `generated` describe the document rather than the pair. let mut v2 = parse(V2_SERVUO).unwrap(); let mut v1 = parse(V1_SAME).unwrap(); assert_eq!(v2.schema, 2); v2.schema = 1; v2.generated.clear(); v1.generated.clear(); assert_eq!(v2, v1); } #[test] fn a_rust_bundle_is_refused_by_the_servuo_reader() { let err = parse(RUST).unwrap_err().to_string(); assert!(err.contains("--game rust"), "{err}"); } #[test] fn the_rust_bundle_parses() { let bundle = parse_rust(RUST).unwrap(); assert_eq!(bundle.game, "rust"); assert_eq!(bundle.protocol, 12); assert_eq!(bundle.payload.kind, "plugin"); assert_eq!( bundle.payload.compat.frameworks["carbon"].min_version, "2.0.259" ); assert_eq!( bundle.payload.compat.requires_plugins, ["Kits", "ZoneManager"] ); assert!(!bundle.sidecar.assets.contains_key("linux-aarch64")); assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh"); } #[test] fn a_servuo_bundle_is_refused_by_the_rust_reader() { let err = parse_rust(V2_SERVUO).unwrap_err().to_string(); assert!(err.contains("not Rust"), "{err}"); let err = parse_rust(V1_SAME).unwrap_err().to_string(); assert!(err.contains("schema 1"), "{err}"); } #[test] fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() { let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap(); doc["payload"]["protocol"] = 11.into(); let err = parse_rust(&doc.to_string()).unwrap_err().to_string(); assert!(err.contains("internally inconsistent"), "{err}"); } #[test] fn schema_2_urls_name_their_game() { assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json")); assert!( v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json") ); } #[test] fn a_protocol_disagreement_inside_one_bundle_is_refused() { // Exactly what CI's gate 1 exists to prevent; re-checked here for documents that never // went through it. let body = CURRENT.replacen("\"protocol\": 3", "\"protocol\": 4", 2); let err = parse(&body).unwrap_err().to_string(); assert!(err.contains("internally inconsistent"), "{err}"); } #[test] fn the_pinned_and_current_urls_differ() { assert!(url_for(None).ends_with("/current.json")); assert!(url_for(Some("2026.08.04")).ends_with("/bundle-2026.08.04.json")); } }