aardvark_core/
bundle_manifest.rs

1//! Bundle manifest structures exposed to host integrations and bundle authors.
2//!
3//! The manifest travels inside the ZIP archive shipped by users. It augments the
4//! runtime descriptor with package hints, resource policies, and (eventually)
5//! additional metadata for other host features. All parsing goes through
6//! [`BundleManifest::from_bytes`], which normalises whitespace, deduplicates
7//! lists, and validates schema rules.
8
9use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12
13use crate::assets::PYODIDE_VERSION;
14use crate::error::{PyRunnerError, Result};
15use crate::runtime_language::RuntimeLanguage;
16
17/// Canonical filename for the manifest within the bundle archive.
18pub const MANIFEST_BASENAME: &str = "aardvark.manifest.json";
19/// Current schema version supported by the runtime.
20pub const MANIFEST_SCHEMA_VERSION: &str = "1.0";
21/// Embedded JSON schema used by tooling for validation.
22pub const MANIFEST_SCHEMA: &str = include_str!("../schemas/aardvark.bundle-manifest.schema.json");
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26/// Normalised view of `aardvark.manifest.json`.
27pub struct BundleManifest {
28    /// Schema version string (must equal [`MANIFEST_SCHEMA_VERSION`]).
29    pub schema_version: String,
30    /// Entrypoint formatted as `module:function`.
31    pub entrypoint: String,
32    /// Optional packages that the runtime should preload inside Pyodide.
33    /// Ignored when the selected language is JavaScript.
34    #[serde(default)]
35    pub packages: Vec<String>,
36    /// Optional runtime selection and language-specific constraints.
37    #[serde(default)]
38    pub runtime: Option<ManifestRuntime>,
39    /// Optional sandbox resource policies.
40    #[serde(default)]
41    pub resources: Option<ManifestResources>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46/// Runtime-specific manifest configuration selected per bundle.
47pub struct ManifestRuntime {
48    /// Desired guest language runtime.
49    #[serde(default)]
50    pub language: Option<RuntimeLanguage>,
51    /// Optional Pyodide configuration block. Only respected when `language`
52    /// resolves to [`RuntimeLanguage::Python`].
53    #[serde(default)]
54    pub pyodide: Option<ManifestPyodide>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59/// Pyodide-specific overrides applied when Python is selected.
60pub struct ManifestPyodide {
61    /// Optional Pyodide version requirement.
62    #[serde(default)]
63    pub version: Option<String>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68/// Resource policy hints the runtime should enforce per invocation.
69pub struct ManifestResources {
70    /// CPU budget applied when the descriptor omits one.
71    #[serde(default)]
72    pub cpu: Option<ManifestCpuResources>,
73    /// Network allowlist and HTTPS setting.
74    #[serde(default)]
75    pub network: Option<ManifestNetworkResources>,
76    /// Filesystem access mode and quota.
77    #[serde(default)]
78    pub filesystem: Option<ManifestFilesystemResources>,
79    /// Host capability names required by the bundle.
80    #[serde(default)]
81    pub host_capabilities: Vec<String>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86/// CPU-related defaults.
87pub struct ManifestCpuResources {
88    /// Optional per-invocation CPU budget in milliseconds.
89    #[serde(default)]
90    pub default_limit_ms: Option<u64>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95/// Network sandbox configuration.
96pub struct ManifestNetworkResources {
97    /// Allowed hosts (exact or wildcard suffix, with optional port).
98    #[serde(default)]
99    pub allow: Vec<String>,
100    /// Whether HTTPS is required for outbound requests (defaults to `true`).
101    #[serde(default = "ManifestNetworkResources::default_https_only")]
102    pub https_only: bool,
103}
104
105impl Default for ManifestNetworkResources {
106    fn default() -> Self {
107        Self {
108            allow: Vec::new(),
109            https_only: true,
110        }
111    }
112}
113
114impl ManifestNetworkResources {
115    const fn default_https_only() -> bool {
116        true
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default)]
121#[serde(rename_all = "camelCase")]
122/// Filesystem sandbox configuration.
123pub struct ManifestFilesystemResources {
124    /// Writable mode (defaults to read-only).
125    #[serde(default)]
126    pub mode: Option<ManifestFilesystemMode>,
127    /// Optional byte quota enforced when write mode is enabled.
128    #[serde(default)]
129    pub quota_bytes: Option<u64>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134/// Filesystem access level requested by the bundle.
135pub enum ManifestFilesystemMode {
136    /// Mount the session directory read-only.
137    Read,
138    /// Allow writes under `/session` (subject to quota enforcement).
139    ReadWrite,
140}
141
142impl BundleManifest {
143    /// Parse the manifest from raw bytes, performing normalisation and validation.
144    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
145        let mut manifest: BundleManifest = serde_json::from_slice(bytes).map_err(|err| {
146            PyRunnerError::Manifest(format!("failed to parse manifest JSON: {err}"))
147        })?;
148        manifest.normalize()?;
149        Ok(manifest)
150    }
151
152    /// Returns the canonical entrypoint (`module:function`).
153    pub fn entrypoint(&self) -> &str {
154        &self.entrypoint
155    }
156
157    /// Returns the list of packages requested by the manifest.
158    pub fn packages(&self) -> &[String] {
159        &self.packages
160    }
161
162    /// Returns optional resource policies.
163    pub fn resources(&self) -> Option<&ManifestResources> {
164        self.resources.as_ref()
165    }
166
167    fn normalize(&mut self) -> Result<()> {
168        if self.schema_version.trim() != MANIFEST_SCHEMA_VERSION {
169            return Err(PyRunnerError::Manifest(format!(
170                "unsupported manifest schema version '{}'; expected {}",
171                self.schema_version, MANIFEST_SCHEMA_VERSION
172            )));
173        }
174
175        let trimmed_entrypoint = self.entrypoint.trim();
176        let (module, function) = trimmed_entrypoint.split_once(':').ok_or_else(|| {
177            PyRunnerError::Manifest(format!(
178                "entrypoint '{}' must be formatted as module:function",
179                trimmed_entrypoint
180            ))
181        })?;
182        if module.trim().is_empty() || function.trim().is_empty() {
183            return Err(PyRunnerError::Manifest(
184                "entrypoint must include both module and function names".into(),
185            ));
186        }
187        self.entrypoint = format!("{}:{}", module.trim(), function.trim());
188
189        if let Some(runtime) = &self.runtime {
190            if matches!(runtime.language, Some(RuntimeLanguage::JavaScript)) {
191                if !self.packages.is_empty() {
192                    return Err(PyRunnerError::Manifest(
193                        "javascript runtime bundles must inline dependencies; 'packages' is not supported".into(),
194                    ));
195                }
196                if runtime.pyodide.is_some() {
197                    return Err(PyRunnerError::Manifest(
198                        "pyodide configuration is unsupported when runtime.language is 'javascript'".into(),
199                    ));
200                }
201            }
202        }
203
204        let mut seen = HashSet::new();
205        let mut normalized = Vec::with_capacity(self.packages.len());
206        for pkg in self.packages.iter() {
207            let trimmed = pkg.trim();
208            if trimmed.is_empty() {
209                return Err(PyRunnerError::Manifest(
210                    "package names cannot be empty strings".into(),
211                ));
212            }
213            let lowered = trimmed.to_ascii_lowercase();
214            if seen.insert(lowered.clone()) {
215                normalized.push(trimmed.to_string());
216            }
217        }
218        self.packages = normalized;
219
220        if let Some(runtime) = &self.runtime {
221            if let Some(pyodide) = &runtime.pyodide {
222                if let Some(version) = pyodide.version.as_ref() {
223                    if version.trim() != PYODIDE_VERSION {
224                        return Err(PyRunnerError::Manifest(format!(
225                            "manifest targets Pyodide {}, but runtime is bundled with {}",
226                            version.trim(),
227                            PYODIDE_VERSION
228                        )));
229                    }
230                }
231            }
232        }
233
234        if let Some(resources) = &mut self.resources {
235            resources.normalize()?;
236        }
237
238        Ok(())
239    }
240}
241
242impl ManifestResources {
243    fn normalize(&mut self) -> Result<()> {
244        if let Some(cpu) = &self.cpu {
245            if matches!(cpu.default_limit_ms, Some(0)) {
246                return Err(PyRunnerError::Manifest(
247                    "resources.cpu.defaultLimitMs must be greater than zero".into(),
248                ));
249            }
250        }
251
252        if let Some(network) = &mut self.network {
253            let mut dedup = HashSet::new();
254            let mut normalized = Vec::with_capacity(network.allow.len());
255            for host in network.allow.iter() {
256                let trimmed = host.trim();
257                if trimmed.is_empty() {
258                    return Err(PyRunnerError::Manifest(
259                        "resources.network.allow entries cannot be empty".into(),
260                    ));
261                }
262                let lowered = trimmed.to_ascii_lowercase();
263                if dedup.insert(lowered) {
264                    normalized.push(trimmed.to_string());
265                }
266            }
267            network.allow = normalized;
268        }
269
270        if let Some(filesystem) = &self.filesystem {
271            if matches!(filesystem.quota_bytes, Some(0)) {
272                return Err(PyRunnerError::Manifest(
273                    "resources.filesystem.quotaBytes must be positive when specified".into(),
274                ));
275            }
276        }
277
278        if !self.host_capabilities.is_empty() {
279            let mut dedup = HashSet::new();
280            let mut normalized = Vec::with_capacity(self.host_capabilities.len());
281            for capability in self.host_capabilities.iter() {
282                let trimmed = capability.trim();
283                if trimmed.is_empty() {
284                    return Err(PyRunnerError::Manifest(
285                        "resources.hostCapabilities entries cannot be empty".into(),
286                    ));
287                }
288                let lowered = trimmed.to_ascii_lowercase();
289                if dedup.insert(lowered) {
290                    normalized.push(trimmed.to_string());
291                }
292            }
293            self.host_capabilities = normalized;
294        }
295
296        Ok(())
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn manifest_round_trip() {
306        let json = format!(
307            "{{\n            \"schemaVersion\": \"1.0\",\n            \"entrypoint\": \"main:run\",\n            \"packages\": [\"Pandas\", \"numpy\"],\n            \"runtime\": {{\"language\": \"python\", \"pyodide\": {{\"version\": \"{}\"}}}},\n            \"resources\": {{\n                \"cpu\": {{\"defaultLimitMs\": 5000}},\n                \"network\": {{\"allow\": [\"Example.com\", \"api.example.com\"], \"httpsOnly\": true}},\n                \"filesystem\": {{\"mode\": \"readWrite\", \"quotaBytes\": 1048576}},\n                \"hostCapabilities\": [\"rawctx_buffers\", \"rawctx_buffers\"]\n            }}\n        }}",
308            PYODIDE_VERSION
309        );
310
311        let manifest = BundleManifest::from_bytes(json.as_bytes()).expect("manifest parses");
312        assert_eq!(manifest.entrypoint(), "main:run");
313        assert_eq!(
314            manifest.packages(),
315            &["Pandas".to_string(), "numpy".to_string()]
316        );
317        let resources = manifest.resources().expect("resources present");
318        assert_eq!(
319            resources.cpu.as_ref().and_then(|cpu| cpu.default_limit_ms),
320            Some(5_000)
321        );
322        let network = resources.network.as_ref().expect("network present");
323        assert_eq!(
324            network.allow,
325            vec!["Example.com".to_string(), "api.example.com".to_string()]
326        );
327        assert!(network.https_only);
328        let filesystem = resources.filesystem.as_ref().expect("filesystem present");
329        assert_eq!(filesystem.mode, Some(ManifestFilesystemMode::ReadWrite));
330        assert_eq!(filesystem.quota_bytes, Some(1_048_576));
331        assert_eq!(
332            resources.host_capabilities,
333            vec!["rawctx_buffers".to_string()]
334        );
335        let runtime = manifest.runtime.as_ref().expect("runtime present");
336        assert_eq!(runtime.language, Some(RuntimeLanguage::Python));
337    }
338
339    #[test]
340    fn manifest_rejects_bad_entrypoint() {
341        let json = r#"{"schemaVersion":"1.0","entrypoint":"invalid","packages":[]}"#;
342        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
343        assert!(matches!(err, PyRunnerError::Manifest(_)));
344    }
345
346    #[test]
347    fn manifest_rejects_wrong_version() {
348        let json = r#"{"schemaVersion":"9.9","entrypoint":"main:run"}"#;
349        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
350        assert!(matches!(err, PyRunnerError::Manifest(_)));
351    }
352
353    #[test]
354    fn manifest_rejects_empty_resource_entries() {
355        let json = r#"{
356            "schemaVersion": "1.0",
357            "entrypoint": "main:run",
358            "resources": {
359                "network": {
360                    "allow": [""]
361                }
362            }
363        }"#;
364        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
365        assert!(matches!(err, PyRunnerError::Manifest(_)));
366    }
367
368    #[test]
369    fn manifest_rejects_zero_cpu_limit() {
370        let json = r#"{
371            "schemaVersion": "1.0",
372            "entrypoint": "main:run",
373            "resources": {
374                "cpu": {
375                    "defaultLimitMs": 0
376                }
377            }
378        }"#;
379        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
380        assert!(matches!(err, PyRunnerError::Manifest(_)));
381    }
382
383    #[test]
384    fn manifest_rejects_packages_for_js_runtime() {
385        let json = r#"{
386            "schemaVersion": "1.0",
387            "entrypoint": "app:main",
388            "packages": ["leftpad"],
389            "runtime": { "language": "javascript" }
390        }"#;
391        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
392        assert!(
393            matches!(err, PyRunnerError::Manifest(_)),
394            "expected manifest error, got {err:?}"
395        );
396    }
397
398    #[test]
399    fn manifest_rejects_pyodide_block_for_js_runtime() {
400        let json = r#"{
401            "schemaVersion": "1.0",
402            "entrypoint": "app:main",
403            "runtime": { "language": "javascript", "pyodide": { "version": "0.23.0" } }
404        }"#;
405        let err = BundleManifest::from_bytes(json.as_bytes()).unwrap_err();
406        assert!(
407            matches!(err, PyRunnerError::Manifest(_)),
408            "expected manifest error, got {err:?}"
409        );
410    }
411
412    #[test]
413    fn manifest_allows_minimal_js_bundle() {
414        let json = r#"{
415            "schemaVersion": "1.0",
416            "entrypoint": "main:default",
417            "runtime": { "language": "javascript" }
418        }"#;
419        let manifest = BundleManifest::from_bytes(json.as_bytes()).expect("manifest parses");
420        assert!(manifest.packages().is_empty());
421        assert_eq!(manifest.entrypoint(), "main:default");
422        assert_eq!(
423            manifest.runtime.as_ref().and_then(|rt| rt.language),
424            Some(RuntimeLanguage::JavaScript)
425        );
426    }
427}