All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s
Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146, D148, D149, D153). Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the schema-1 model. Schema 1 at the root is the fallback, so a pin from before schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use the contents API, because /raw/ is CDN-cached for six hours. --game rust runs install, update, doctor and uninstall for Rust servers (src/rustgame/): - the framework is detected from its marker files, which were read off both rigs; both or neither is refused; - --server-id names an instance: its own service (runicgateway-rust@<id>, or RunicGatewayRust-<id>), config, database and ports; - the plugin config is written once, with ServerId and Port only. An existing one is never rewritten, and one naming another server refuses the run; - each instance's sidecar.toml is written once with its ports and an absolute database path, and the sidecar generates the token into it; - one binary per host. update moves every instance, and a replaced binary restarts all of them; - doctor checks the plugin file hash, the plugin config's ServerId, the required uMod plugins (a warning), the service and /health, and passes when the plugin is connected; - uninstall removes our plugin and keeps its config. --purge also removes the sidecar config and database. The last instance takes the binary, the template and the record, and the shared user only when no ServUO record remains. service.rs takes the service name as a parameter internally. The ServUO public API is unchanged. Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins and carbon/configs are what the installer uses. The plan expected a moved directory to be readable there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
655 lines
26 KiB
Rust
655 lines
26 KiB
Rust
//! 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<String, Asset>,
|
|
}
|
|
|
|
#[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::<Vec<_>>()
|
|
.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 <tag>` 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<String> {
|
|
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 <tag>` 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<Bundle> {
|
|
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<String>,
|
|
#[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<String, Asset>,
|
|
/// 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<String, FrameworkFloor>,
|
|
/// Third-party plugins the features expect. Reported, never installed (D153).
|
|
pub requires_plugins: Vec<String>,
|
|
}
|
|
|
|
#[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::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
|
|
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<RustBundle> {
|
|
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"));
|
|
}
|
|
}
|