use serde::{Deserialize, Serialize};
pub const MANIFEST: &str = "botstadia.toml";
pub const COMPONENT: &str = "component.wasm";
pub const ARTIFACTS_DIR: &str = "artifacts";
pub const MANIFEST_FORMAT: u32 = 1;
pub const MAX_BUNDLE_BYTES: u64 = 25_000_000;
pub const MAX_MODELS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BotKind {
Policy,
Rust,
C,
}
impl BotKind {
pub fn name(self) -> &'static str {
match self {
Self::Policy => "policy",
Self::Rust => "rust",
Self::C => "c",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Policy => "Trained policy",
Self::Rust => "Rust",
Self::C => "C",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelEntry {
pub name: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format: u32,
pub arena: String,
pub payload_schema_version: u32,
pub kind: BotKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
#[serde(default, rename = "model", skip_serializing_if = "Vec::is_empty")]
pub models: Vec<ModelEntry>,
}
impl Manifest {
pub fn new(arena: &str, payload_schema_version: u32, kind: BotKind) -> Self {
Self {
format: MANIFEST_FORMAT,
arena: arena.into(),
payload_schema_version,
kind,
nonce: None,
models: Vec::new(),
}
}
#[cfg(feature = "bundle")]
pub fn to_toml(&self) -> String {
toml::to_string(self).expect("a manifest always serialises")
}
#[cfg(feature = "bundle")]
pub fn from_toml(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|error| format!("{MANIFEST}: {error}"))
}
pub fn problems(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.format != MANIFEST_FORMAT {
problems.push(format!(
"manifest format {} isn't {MANIFEST_FORMAT}",
self.format
));
}
if !crate::declaration::is_slug(&self.arena) {
problems.push(format!("arena `{}` isn't a slug", self.arena));
}
if self.models.len() > MAX_MODELS {
problems.push(format!(
"{} models, but a bundle carries at most {MAX_MODELS}",
self.models.len()
));
}
if self.kind == BotKind::Policy && self.models.is_empty() {
problems.push("a trained policy needs its model".into());
}
let mut seen = std::collections::HashSet::new();
for model in &self.models {
if !seen.insert(model.name.as_str()) {
problems.push(format!("model `{}` is listed twice", model.name));
}
if !model_path_ok(&model.path) {
problems.push(format!(
"model `{}`: `{}` must be a plain name ending .onnx under {ARTIFACTS_DIR}/",
model.name, model.path
));
}
}
if self.nonce.as_ref().is_some_and(|nonce| nonce.len() > 128) {
problems.push("the nonce is longer than 128 characters".into());
}
problems
}
}
fn model_path_ok(path: &str) -> bool {
let Some(name) = path.strip_prefix(&format!("{ARTIFACTS_DIR}/")) else {
return false;
};
name.ends_with(".onnx")
&& name.len() > ".onnx".len()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
&& !name.starts_with('.')
}
pub fn entry_allowed(manifest: &Manifest, entry: &str) -> bool {
entry == COMPONENT || entry == MANIFEST || manifest.models.iter().any(|m| m.path == entry)
}
#[cfg(all(test, feature = "bundle"))]
mod tests {
use super::*;
#[test]
fn round_trips_and_checks_paths() {
let mut manifest = Manifest::new("dance-off", 1, BotKind::Policy);
manifest.models.push(ModelEntry {
name: "policy".into(),
path: "artifacts/policy.onnx".into(),
});
let text = manifest.to_toml();
assert!(text.contains("[[model]]"));
assert_eq!(Manifest::from_toml(&text).unwrap(), manifest);
assert!(manifest.problems().is_empty());
manifest.models[0].path = "artifacts/../x.onnx".into();
assert_eq!(manifest.problems().len(), 1);
assert!(!entry_allowed(&manifest, "extra.txt"));
assert!(entry_allowed(&manifest, COMPONENT));
}
#[test]
fn a_policy_needs_a_model() {
let manifest = Manifest::new("boxing", 1, BotKind::Policy);
assert!(manifest.problems()[0].contains("needs its model"));
assert!(
Manifest::new("boxing", 1, BotKind::Rust)
.problems()
.is_empty()
);
}
}