botstadia_interface/
bundle.rs1use serde::{Deserialize, Serialize};
26
27pub const MANIFEST: &str = "botstadia.toml";
29pub const COMPONENT: &str = "component.wasm";
31pub const ARTIFACTS_DIR: &str = "artifacts";
33pub const MANIFEST_FORMAT: u32 = 1;
35pub const MAX_BUNDLE_BYTES: u64 = 25_000_000;
37pub const MAX_MODELS: usize = 8;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum BotKind {
44 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ModelEntry {
73 pub name: String,
74 pub path: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Manifest {
82 pub format: u32,
83 pub arena: String,
85 pub payload_schema_version: u32,
86 pub kind: BotKind,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub nonce: Option<String>,
90 #[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 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
157fn 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
170pub 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}