Skip to main content

capsule_runtime/
artifact.rs

1use murmur_artifact::payload_shape::{
2    native_binary_entry, select_root_wasm_in_archive, SKILL_MD_ENTRY,
3};
4use murmur_artifact::zip_guard;
5use murmur_artifact::PACKED_MANIFEST_ENTRY;
6use zip::ZipArchive;
7
8use crate::errors::RuntimeError;
9
10pub fn extract_root_wasm(
11    artifact_name: &str,
12    artifact_version: &str,
13    artifact_bytes: &[u8],
14) -> Result<Vec<u8>, RuntimeError> {
15    extract_root_wasm_capped(
16        artifact_name,
17        artifact_version,
18        artifact_bytes,
19        zip_guard::max_artifact_decompressed_bytes(),
20    )
21}
22
23fn extract_root_wasm_capped(
24    artifact_name: &str,
25    artifact_version: &str,
26    artifact_bytes: &[u8],
27    max_bytes: u64,
28) -> Result<Vec<u8>, RuntimeError> {
29    let cursor = std::io::Cursor::new(artifact_bytes);
30    let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
31        name: artifact_name.to_string(),
32        version: artifact_version.to_string(),
33        message: err.to_string(),
34    })?;
35
36    // Which root entry counts as the wasm payload is the shared payload-shape contract; the
37    // selector (and its error text) lives in murmur_artifact::payload_shape.
38    let selected_name =
39        select_root_wasm_in_archive(&mut archive).map_err(|err| RuntimeError::ArtifactArchive {
40            name: artifact_name.to_string(),
41            version: artifact_version.to_string(),
42            message: err.to_string(),
43        })?;
44
45    zip_guard::read_zip_entry_capped(&mut archive, &selected_name, max_bytes).map_err(|err| {
46        RuntimeError::ArtifactArchive {
47            name: artifact_name.to_string(),
48            version: artifact_version.to_string(),
49            message: err.to_string(),
50        }
51    })
52}
53
54/// Extract the native binary from `bin/<artifact_name>` inside a `.mur.zip`.
55///
56/// Canonical native artifact layout:
57///   murmur.yaml            — artifact manifest at zip root
58///   bin/<artifact_name>    — compiled binary with executable permissions
59pub fn extract_native_binary(
60    artifact_name: &str,
61    artifact_version: &str,
62    artifact_bytes: &[u8],
63) -> Result<Vec<u8>, RuntimeError> {
64    extract_native_binary_capped(
65        artifact_name,
66        artifact_version,
67        artifact_bytes,
68        zip_guard::max_artifact_decompressed_bytes(),
69    )
70}
71
72fn extract_native_binary_capped(
73    artifact_name: &str,
74    artifact_version: &str,
75    artifact_bytes: &[u8],
76    max_bytes: u64,
77) -> Result<Vec<u8>, RuntimeError> {
78    let cursor = std::io::Cursor::new(artifact_bytes);
79    let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
80        name: artifact_name.to_string(),
81        version: artifact_version.to_string(),
82        message: err.to_string(),
83    })?;
84
85    let bin_path = native_binary_entry(artifact_name);
86    zip_guard::read_zip_entry_capped(&mut archive, &bin_path, max_bytes).map_err(|err| {
87        RuntimeError::ArtifactArchive {
88            name: artifact_name.to_string(),
89            version: artifact_version.to_string(),
90            message: format!(
91                "failed to read native binary at '{bin_path}' from archive (ensure the binary \
92                 is at {bin_path} inside the .mur.zip): {err}"
93            ),
94        }
95    })
96}
97
98/// Extract `skill.md` from a skill artifact's `.mur.zip`.
99///
100/// Canonical skill artifact layout:
101///   murmur.yaml      — artifact manifest at zip root
102///   skill.md         — guidance content at zip root (primary payload)
103pub fn extract_skill_md(
104    artifact_name: &str,
105    artifact_version: &str,
106    artifact_bytes: &[u8],
107) -> Result<Vec<u8>, RuntimeError> {
108    extract_skill_md_capped(
109        artifact_name,
110        artifact_version,
111        artifact_bytes,
112        zip_guard::max_artifact_decompressed_bytes(),
113    )
114}
115
116fn extract_skill_md_capped(
117    artifact_name: &str,
118    artifact_version: &str,
119    artifact_bytes: &[u8],
120    max_bytes: u64,
121) -> Result<Vec<u8>, RuntimeError> {
122    let cursor = std::io::Cursor::new(artifact_bytes);
123    let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
124        name: artifact_name.to_string(),
125        version: artifact_version.to_string(),
126        message: err.to_string(),
127    })?;
128
129    zip_guard::read_zip_entry_capped(&mut archive, SKILL_MD_ENTRY, max_bytes).map_err(|err| {
130        RuntimeError::ArtifactArchive {
131            name: artifact_name.to_string(),
132            version: artifact_version.to_string(),
133            message: format!(
134                "{SKILL_MD_ENTRY} not found or unreadable at archive root (ensure \
135                 {SKILL_MD_ENTRY} is at the root of the .mur.zip): {err}"
136            ),
137        }
138    })
139}
140
141pub fn extract_manifest_yaml(
142    artifact_name: &str,
143    artifact_version: &str,
144    artifact_bytes: &[u8],
145) -> Result<String, RuntimeError> {
146    extract_manifest_yaml_capped(
147        artifact_name,
148        artifact_version,
149        artifact_bytes,
150        zip_guard::max_artifact_decompressed_bytes(),
151    )
152}
153
154fn extract_manifest_yaml_capped(
155    artifact_name: &str,
156    artifact_version: &str,
157    artifact_bytes: &[u8],
158    max_bytes: u64,
159) -> Result<String, RuntimeError> {
160    let cursor = std::io::Cursor::new(artifact_bytes);
161    let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
162        name: artifact_name.to_string(),
163        version: artifact_version.to_string(),
164        message: err.to_string(),
165    })?;
166
167    zip_guard::read_zip_entry_to_string_capped(&mut archive, PACKED_MANIFEST_ENTRY, max_bytes)
168        .map_err(|err| RuntimeError::ArtifactArchive {
169            name: artifact_name.to_string(),
170            version: artifact_version.to_string(),
171            message: format!(
172                "missing or unreadable {PACKED_MANIFEST_ENTRY} in artifact archive: {err}"
173            ),
174        })
175}
176
177/// The `capabilities.env.allow` a packed manifest declares, read on its own.
178///
179/// Narrow on purpose. `murmur_artifact::RuntimeManifest::from_yaml_str` validates the whole
180/// manifest, and that resolves every `${VAR}` in a `gateway.api_key` against the reading
181/// process's environment — so a parent asking what its child declares would have to already hold
182/// the child's variables before it could read which ones they are. Reading the one key answers
183/// that without the circle.
184///
185/// A manifest with no `capabilities:` block, or none under `env:`, declares nothing and yields an
186/// empty list. Malformed YAML, or an `env.allow` that is not a list of strings, is an error
187/// naming the artifact and version.
188pub fn extract_declared_env_allow(
189    artifact_name: &str,
190    artifact_version: &str,
191    manifest_yaml: &str,
192) -> Result<Vec<String>, RuntimeError> {
193    let declared: DeclaredAllows =
194        serde_yaml::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
195            name: artifact_name.to_string(),
196            version: artifact_version.to_string(),
197            message: format!(
198                "cannot read capabilities.env.allow from {PACKED_MANIFEST_ENTRY}: {err}"
199            ),
200        })?;
201    Ok(declared
202        .capabilities
203        .and_then(|capabilities| capabilities.env)
204        .map(|env| env.allow)
205        .unwrap_or_default())
206}
207
208/// The `capabilities.spawn.allow` a packed manifest declares, read on its own.
209///
210/// The sibling of [`extract_declared_env_allow`], and narrow for the same reason: walking a
211/// formation's delegation graph means reading which capsules a child may in turn spawn, and a
212/// whole-manifest parse would demand that child's own `${VAR}` references be resolvable first.
213///
214/// A manifest with no `capabilities:` block, or none under `spawn:`, declares nothing and yields
215/// an empty list. Malformed YAML, or a `spawn.allow` that is not a list of strings, is an error
216/// naming the artifact and version.
217pub fn extract_declared_spawn_allow(
218    artifact_name: &str,
219    artifact_version: &str,
220    manifest_yaml: &str,
221) -> Result<Vec<String>, RuntimeError> {
222    let declared: DeclaredAllows =
223        serde_yaml::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
224            name: artifact_name.to_string(),
225            version: artifact_version.to_string(),
226            message: format!(
227                "cannot read capabilities.spawn.allow from {PACKED_MANIFEST_ENTRY}: {err}"
228            ),
229        })?;
230    Ok(declared
231        .capabilities
232        .and_then(|capabilities| capabilities.spawn)
233        .map(|spawn| spawn.allow)
234        .unwrap_or_default())
235}
236
237/// The only keys [`extract_declared_env_allow`] and [`extract_declared_spawn_allow`] read. Every
238/// other manifest key deserializes into nothing here, so a child declaring anything at all is
239/// still readable.
240#[derive(serde::Deserialize)]
241struct DeclaredAllows {
242    #[serde(default)]
243    capabilities: Option<DeclaredCapabilities>,
244}
245
246#[derive(serde::Deserialize)]
247struct DeclaredCapabilities {
248    #[serde(default)]
249    env: Option<DeclaredAllowList>,
250    #[serde(default)]
251    spawn: Option<DeclaredAllowList>,
252}
253
254#[derive(serde::Deserialize)]
255struct DeclaredAllowList {
256    #[serde(default)]
257    allow: Vec<String>,
258}
259
260#[cfg(test)]
261mod tests {
262    use std::io::Write;
263
264    use zip::write::SimpleFileOptions;
265
266    use super::*;
267
268    fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
269        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
270        {
271            let mut zip = zip::ZipWriter::new(&mut cursor);
272            let options = SimpleFileOptions::default();
273            for (name, bytes) in files {
274                zip.start_file(*name, options).unwrap();
275                zip.write_all(bytes).unwrap();
276            }
277            zip.finish().unwrap();
278        }
279        cursor.into_inner()
280    }
281
282    #[test]
283    fn prefers_capsule_wasm_when_present() {
284        let archive = archive_with_files(&[
285            ("tool.wasm", b"tool"),
286            ("capsule.wasm", b"capsule"),
287            (PACKED_MANIFEST_ENTRY, b"name: demo"),
288        ]);
289
290        let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
291        assert_eq!(wasm, b"capsule");
292    }
293
294    #[test]
295    fn extracts_manifest_yaml_from_archive() {
296        let archive = archive_with_files(&[
297            (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
298            ("tool.wasm", b"tool"),
299        ]);
300
301        let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
302        assert!(manifest.contains("name: demo"));
303        assert!(manifest.contains("version: 0.1.0"));
304    }
305
306    #[test]
307    fn reads_the_declared_env_allow_out_of_a_manifest() {
308        let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n  network:\n    \
309                        allow: [127.0.0.1]\n  env:\n    allow: [OPENAI_API_KEY, HTTPS_PROXY]\n";
310
311        assert_eq!(
312            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
313            vec!["OPENAI_API_KEY".to_string(), "HTTPS_PROXY".to_string()]
314        );
315    }
316
317    /// The read answers what the child declares without validating the rest of the manifest: a
318    /// `${VAR}` the reading process does not hold would fail a full parse, and the whole point is
319    /// to learn which variables the child needs before holding any of them.
320    #[test]
321    fn an_unresolvable_inference_key_does_not_stop_the_read() {
322        let manifest = "name: worker\nversion: 0.1.0\ninference:\n  transport: http\n  \
323                        api_key: ${MURMUR_A_VARIABLE_NOBODY_EXPORTED}\ncapabilities:\n  env:\n    \
324                        allow: [MURMUR_A_VARIABLE_NOBODY_EXPORTED]\n";
325
326        assert_eq!(
327            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
328            vec!["MURMUR_A_VARIABLE_NOBODY_EXPORTED".to_string()]
329        );
330    }
331
332    #[test]
333    fn a_manifest_declaring_no_env_allow_declares_nothing() {
334        for manifest in [
335            "name: worker\nversion: 0.1.0\n",
336            "name: worker\nversion: 0.1.0\ncapabilities:\n  shell:\n    allow: [bash]\n",
337            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: []\n",
338        ] {
339            assert_eq!(
340                extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
341                Vec::<String>::new(),
342                "{manifest}"
343            );
344        }
345    }
346
347    #[test]
348    fn an_unreadable_env_allow_is_an_error_naming_the_artifact() {
349        for manifest in [
350            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: OPENAI_API_KEY\n",
351            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: [[OPENAI_API_KEY]]\n",
352            "name: worker\nversion: 0.1.0\ncapabilities:\n\tenv: broken\n",
353        ] {
354            let err = extract_declared_env_allow("worker", "0.1.0", manifest).unwrap_err();
355            match err {
356                RuntimeError::ArtifactArchive {
357                    name,
358                    version,
359                    message,
360                } => {
361                    assert_eq!(name, "worker");
362                    assert_eq!(version, "0.1.0");
363                    assert!(message.contains("capabilities.env.allow"), "{message}");
364                }
365                other => panic!("expected ArtifactArchive error, got {other:?}"),
366            }
367        }
368    }
369
370    #[test]
371    fn reads_the_declared_spawn_allow_out_of_a_manifest() {
372        let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    \
373                        allow: [OPENAI_API_KEY]\n  spawn:\n    allow: [deep-worker, sibling]\n";
374
375        assert_eq!(
376            extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
377            vec!["deep-worker".to_string(), "sibling".to_string()]
378        );
379        assert_eq!(
380            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
381            vec!["OPENAI_API_KEY".to_string()]
382        );
383    }
384
385    #[test]
386    fn a_manifest_declaring_no_spawn_allow_declares_nothing() {
387        for manifest in [
388            "name: worker\nversion: 0.1.0\n",
389            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: [A_KEY]\n",
390            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: []\n",
391        ] {
392            assert_eq!(
393                extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
394                Vec::<String>::new(),
395                "{manifest}"
396            );
397        }
398    }
399
400    #[test]
401    fn an_unreadable_spawn_allow_is_an_error_naming_the_artifact() {
402        for manifest in [
403            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: deep-worker\n",
404            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: [[deep]]\n",
405            "name: worker\nversion: 0.1.0\ncapabilities:\n\tspawn: broken\n",
406        ] {
407            match extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap_err() {
408                RuntimeError::ArtifactArchive {
409                    name,
410                    version,
411                    message,
412                } => {
413                    assert_eq!(name, "worker");
414                    assert_eq!(version, "0.1.0");
415                    assert!(message.contains("capabilities.spawn.allow"), "{message}");
416                }
417                other => panic!("expected ArtifactArchive error, got {other:?}"),
418            }
419        }
420    }
421
422    #[test]
423    fn rejects_path_traversal_entry_as_root_wasm_candidate() {
424        let archive = archive_with_files(&[
425            ("../../evil.wasm", b"evil"),
426            (PACKED_MANIFEST_ENTRY, b"name: demo"),
427        ]);
428
429        let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
430        match err {
431            RuntimeError::ArtifactArchive { message, .. } => {
432                assert!(message.contains("missing root .wasm file"));
433            }
434            other => panic!("expected ArtifactArchive error, got {other:?}"),
435        }
436    }
437
438    #[test]
439    fn rejects_leading_slash_entry_as_root_wasm_candidate() {
440        let archive = archive_with_files(&[
441            ("/capsule.wasm", b"evil"),
442            (PACKED_MANIFEST_ENTRY, b"name: demo"),
443        ]);
444
445        let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
446        match err {
447            RuntimeError::ArtifactArchive { message, .. } => {
448                assert!(message.contains("missing root .wasm file"));
449            }
450            other => panic!("expected ArtifactArchive error, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn extract_manifest_yaml_rejects_entry_past_ceiling() {
456        let big = vec![b'a'; 1024];
457        let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
458
459        let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
460        match err {
461            RuntimeError::ArtifactArchive { message, .. } => {
462                assert!(message.contains("decompression ceiling"));
463            }
464            other => panic!("expected ArtifactArchive error, got {other:?}"),
465        }
466    }
467
468    #[test]
469    fn extract_root_wasm_rejects_entry_past_ceiling() {
470        let big = vec![b'a'; 1024];
471        let archive = archive_with_files(&[("capsule.wasm", &big)]);
472
473        let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
474        match err {
475            RuntimeError::ArtifactArchive { message, .. } => {
476                assert!(message.contains("decompression ceiling"));
477            }
478            other => panic!("expected ArtifactArchive error, got {other:?}"),
479        }
480    }
481
482    #[test]
483    fn extract_native_binary_rejects_entry_past_ceiling() {
484        let big = vec![b'a'; 1024];
485        let archive = archive_with_files(&[("bin/demo", &big)]);
486
487        let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
488        match err {
489            RuntimeError::ArtifactArchive { message, .. } => {
490                assert!(message.contains("decompression ceiling"));
491            }
492            other => panic!("expected ArtifactArchive error, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn extract_skill_md_rejects_entry_past_ceiling() {
498        let big = vec![b'a'; 1024];
499        let archive = archive_with_files(&[("skill.md", &big)]);
500
501        let err = extract_skill_md_capped("demo", "0.0.1", &archive, 16).unwrap_err();
502        match err {
503            RuntimeError::ArtifactArchive { message, .. } => {
504                assert!(message.contains("decompression ceiling"));
505            }
506            other => panic!("expected ArtifactArchive error, got {other:?}"),
507        }
508    }
509
510    #[test]
511    fn default_ceiling_does_not_reject_small_fixtures() {
512        let archive = archive_with_files(&[
513            (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
514            ("capsule.wasm", b"small wasm bytes"),
515        ]);
516
517        extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
518        extract_root_wasm("demo", "0.1.0", &archive).unwrap();
519    }
520}