//! 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 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). const BUNDLE_BASE: &str = "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles"; /// The only `schema` this build understands. const SUPPORTED_SCHEMA: u32 = 1; #[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`, `windows-x86_64`) — link publishes a binary per OS and /// the installer runs on both, so a single hash could only ever describe one of them. 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: {})", 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"), ("windows", "x86_64") => Ok("windows-x86_64"), // arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so 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 and windows-x86_64." ), } } /// URL of the current bundle, or of a specific one when `--bundle ` pins it. pub fn url_for(tag: Option<&str>) -> String { match tag { Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"), None => format!("{BUNDLE_BASE}/current.json"), } } /// Fetches and validates a bundle. pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> { let url = url_for(tag); let body = crate::net::get_text(&url).with_context(|| match tag { Some(tag) => format!( "cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \ against {BUNDLE_BASE}/" ), None => "cannot read the current bundle manifest".to_string(), })?; let bundle = parse(&body)?; Ok((bundle, url)) } /// Parses a bundle document and applies the checks that must hold before anything is downloaded. pub fn parse(body: &str) -> Result { let bundle: Bundle = serde_json::from_str(body) .context("the bundle manifest is not in the shape this installer understands")?; if bundle.schema != SUPPORTED_SCHEMA { bail!( "bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \ Update the installer — the bundle format changed.", bundle.bundle, bundle.schema ); } // 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) } #[cfg(test)] mod tests { use super::*; /// The first published bundle, verbatim from `bundles/current.json`. Using the real document /// rather than a hand-written stand-in is the point: it is what CI actually emits. const CURRENT: &str = include_str!("../bundles/current.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_eq!(bundle.link.assets.len(), 2); assert!(bundle.overlay.asset.name.ends_with(".tar.gz")); } #[test] fn both_platforms_have_a_sidecar_binary() { // Whichever of the two this test runs on, the lookup must resolve — a bundle missing the // host's binary would fail an install after the overlay had already been deployed. let bundle = parse(CURRENT).unwrap(); let asset = bundle.sidecar_asset().unwrap(); assert_eq!(asset.sha256.len(), 64); assert!(asset.url.contains(&bundle.link.tag)); } #[test] fn a_newer_schema_is_refused_rather_than_guessed_at() { let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2"); let err = parse(&body).unwrap_err().to_string(); assert!(err.contains("schema 2"), "{err}"); } #[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")); } }