Skip to main content

capsule_runtime/
artifact.rs

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