1use 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
17pub const MANIFEST_BASENAME: &str = "aardvark.manifest.json";
19pub const MANIFEST_SCHEMA_VERSION: &str = "1.0";
21pub const MANIFEST_SCHEMA: &str = include_str!("../schemas/aardvark.bundle-manifest.schema.json");
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct BundleManifest {
28 pub schema_version: String,
30 pub entrypoint: String,
32 #[serde(default)]
35 pub packages: Vec<String>,
36 #[serde(default)]
38 pub runtime: Option<ManifestRuntime>,
39 #[serde(default)]
41 pub resources: Option<ManifestResources>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ManifestRuntime {
48 #[serde(default)]
50 pub language: Option<RuntimeLanguage>,
51 #[serde(default)]
54 pub pyodide: Option<ManifestPyodide>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ManifestPyodide {
61 #[serde(default)]
63 pub version: Option<String>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ManifestResources {
70 #[serde(default)]
72 pub cpu: Option<ManifestCpuResources>,
73 #[serde(default)]
75 pub network: Option<ManifestNetworkResources>,
76 #[serde(default)]
78 pub filesystem: Option<ManifestFilesystemResources>,
79 #[serde(default)]
81 pub host_capabilities: Vec<String>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct ManifestCpuResources {
88 #[serde(default)]
90 pub default_limit_ms: Option<u64>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct ManifestNetworkResources {
97 #[serde(default)]
99 pub allow: Vec<String>,
100 #[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")]
122pub struct ManifestFilesystemResources {
124 #[serde(default)]
126 pub mode: Option<ManifestFilesystemMode>,
127 #[serde(default)]
129 pub quota_bytes: Option<u64>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub enum ManifestFilesystemMode {
136 Read,
138 ReadWrite,
140}
141
142impl BundleManifest {
143 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 pub fn entrypoint(&self) -> &str {
154 &self.entrypoint
155 }
156
157 pub fn packages(&self) -> &[String] {
159 &self.packages
160 }
161
162 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}