Skip to main content

botstadia_interface/
bundle.rs

1//! A bot's bundle: what a builder uploads.
2//!
3//! One deterministic ZIP holding `component.wasm` (the bot, a component for
4//! the `agent` world), the manifest `botstadia.toml`, and optional ONNX models
5//! under `artifacts/`. A trained-policy bundle's `component.wasm` is its own
6//! copy of the policy shell.
7//!
8//! ```toml
9//! format = 1
10//! arena = "dance-off"
11//! payload_schema_version = 1
12//! kind = "policy"            # policy | rust | c
13//! nonce = "second-try"       # optional: the same code as a new bot
14//!
15//! [[model]]                  # in order: its index is `infer`'s `model`
16//! name = "policy"
17//! path = "artifacts/policy.onnx"
18//! ```
19//!
20//! The manifest's model order is the `model` index the `agent` world's
21//! `infer` takes. The ZIP is built deterministically (entries in name order,
22//! fixed times and permissions, one compression), so the same files always
23//! give the same bundle digest.
24
25use serde::{Deserialize, Serialize};
26
27/// The manifest's file name inside a bundle.
28pub const MANIFEST: &str = "botstadia.toml";
29/// The bot's program inside a bundle.
30pub const COMPONENT: &str = "component.wasm";
31/// Where models sit inside a bundle.
32pub const ARTIFACTS_DIR: &str = "artifacts";
33/// The manifest format this crate reads and writes.
34pub const MANIFEST_FORMAT: u32 = 1;
35/// The largest bundle, in bytes.
36pub const MAX_BUNDLE_BYTES: u64 = 25_000_000;
37/// The most ONNX models one bundle may carry.
38pub const MAX_MODELS: usize = 8;
39
40/// How a bot was made.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum BotKind {
44    /// An ONNX policy run by the generic policy shell.
45    Policy,
46    Rust,
47    C,
48}
49
50impl BotKind {
51    pub fn name(self) -> &'static str {
52        match self {
53            Self::Policy => "policy",
54            Self::Rust => "rust",
55            Self::C => "c",
56        }
57    }
58
59    /// What the site and the CLI call it.
60    pub fn label(self) -> &'static str {
61        match self {
62            Self::Policy => "Trained policy",
63            Self::Rust => "Rust",
64            Self::C => "C",
65        }
66    }
67}
68
69/// One ONNX model in a bundle.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ModelEntry {
73    pub name: String,
74    /// Inside the bundle, under `artifacts/`.
75    pub path: String,
76}
77
78/// `botstadia.toml`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Manifest {
82    pub format: u32,
83    /// The arena's slug.
84    pub arena: String,
85    pub payload_schema_version: u32,
86    pub kind: BotKind,
87    /// Any text, so the same code can go up again as a new bot.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub nonce: Option<String>,
90    /// In order: a model's index here is `infer`'s `model` argument.
91    #[serde(default, rename = "model", skip_serializing_if = "Vec::is_empty")]
92    pub models: Vec<ModelEntry>,
93}
94
95impl Manifest {
96    pub fn new(arena: &str, payload_schema_version: u32, kind: BotKind) -> Self {
97        Self {
98            format: MANIFEST_FORMAT,
99            arena: arena.into(),
100            payload_schema_version,
101            kind,
102            nonce: None,
103            models: Vec::new(),
104        }
105    }
106
107    #[cfg(feature = "bundle")]
108    pub fn to_toml(&self) -> String {
109        toml::to_string(self).expect("a manifest always serialises")
110    }
111
112    #[cfg(feature = "bundle")]
113    pub fn from_toml(text: &str) -> Result<Self, String> {
114        toml::from_str(text).map_err(|error| format!("{MANIFEST}: {error}"))
115    }
116
117    /// Everything wrong with the manifest on its own. Empty means it's sound.
118    pub fn problems(&self) -> Vec<String> {
119        let mut problems = Vec::new();
120        if self.format != MANIFEST_FORMAT {
121            problems.push(format!(
122                "manifest format {} isn't {MANIFEST_FORMAT}",
123                self.format
124            ));
125        }
126        if !crate::declaration::is_slug(&self.arena) {
127            problems.push(format!("arena `{}` isn't a slug", self.arena));
128        }
129        if self.models.len() > MAX_MODELS {
130            problems.push(format!(
131                "{} models, but a bundle carries at most {MAX_MODELS}",
132                self.models.len()
133            ));
134        }
135        if self.kind == BotKind::Policy && self.models.is_empty() {
136            problems.push("a trained policy needs its model".into());
137        }
138        let mut seen = std::collections::HashSet::new();
139        for model in &self.models {
140            if !seen.insert(model.name.as_str()) {
141                problems.push(format!("model `{}` is listed twice", model.name));
142            }
143            if !model_path_ok(&model.path) {
144                problems.push(format!(
145                    "model `{}`: `{}` must be a plain name ending .onnx under {ARTIFACTS_DIR}/",
146                    model.name, model.path
147                ));
148            }
149        }
150        if self.nonce.as_ref().is_some_and(|nonce| nonce.len() > 128) {
151            problems.push("the nonce is longer than 128 characters".into());
152        }
153        problems
154    }
155}
156
157/// `artifacts/<name>.onnx`, with a plain file name: no directories, no `..`.
158fn model_path_ok(path: &str) -> bool {
159    let Some(name) = path.strip_prefix(&format!("{ARTIFACTS_DIR}/")) else {
160        return false;
161    };
162    name.ends_with(".onnx")
163        && name.len() > ".onnx".len()
164        && name
165            .chars()
166            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
167        && !name.starts_with('.')
168}
169
170/// Whether a ZIP entry may be in a bundle: the component, the manifest, or a
171/// model the manifest lists.
172pub fn entry_allowed(manifest: &Manifest, entry: &str) -> bool {
173    entry == COMPONENT || entry == MANIFEST || manifest.models.iter().any(|m| m.path == entry)
174}
175
176#[cfg(all(test, feature = "bundle"))]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn round_trips_and_checks_paths() {
182        let mut manifest = Manifest::new("dance-off", 1, BotKind::Policy);
183        manifest.models.push(ModelEntry {
184            name: "policy".into(),
185            path: "artifacts/policy.onnx".into(),
186        });
187        let text = manifest.to_toml();
188        assert!(text.contains("[[model]]"));
189        assert_eq!(Manifest::from_toml(&text).unwrap(), manifest);
190        assert!(manifest.problems().is_empty());
191        manifest.models[0].path = "artifacts/../x.onnx".into();
192        assert_eq!(manifest.problems().len(), 1);
193        assert!(!entry_allowed(&manifest, "extra.txt"));
194        assert!(entry_allowed(&manifest, COMPONENT));
195    }
196
197    #[test]
198    fn a_policy_needs_a_model() {
199        let manifest = Manifest::new("boxing", 1, BotKind::Policy);
200        assert!(manifest.problems()[0].contains("needs its model"));
201        assert!(
202            Manifest::new("boxing", 1, BotKind::Rust)
203                .problems()
204                .is_empty()
205        );
206    }
207}