//! 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`, `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 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!(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\": 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")); } }