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::{BUILD_INFO_FILE, 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/// One skill's installable files: each as its `/`-separated path under `tools/<name>/` and its
134/// bytes, `skill.md` first.
135pub type SkillFiles = Vec<(String, Vec<u8>)>;
136
137/// The files of a skill artifact's `.mur.zip` that install under `tools/<name>/`: each as its
138/// `/`-separated path inside the archive and its bytes, `skill.md` first.
139///
140/// Skill artifact layout:
141///   murmur.yaml        — artifact manifest at zip root (not returned; staging writes its own)
142///   skill.md           — guidance content at zip root (required)
143///   murmur-build.json  — build record at zip root (not returned)
144///   anything else      — supporting files the guidance points at, at their packed paths
145///
146/// An entry name that escapes the archive fails the extraction, and so does a total past the
147/// artifact decompression ceiling.
148pub fn extract_skill_files(
149    artifact_name: &str,
150    artifact_version: &str,
151    artifact_bytes: &[u8],
152) -> Result<SkillFiles, RuntimeError> {
153    extract_skill_files_capped(
154        artifact_name,
155        artifact_version,
156        artifact_bytes,
157        zip_guard::max_artifact_decompressed_bytes(),
158    )
159}
160
161fn extract_skill_files_capped(
162    artifact_name: &str,
163    artifact_version: &str,
164    artifact_bytes: &[u8],
165    max_bytes: u64,
166) -> Result<SkillFiles, RuntimeError> {
167    let archive_error = |message: String| RuntimeError::ArtifactArchive {
168        name: artifact_name.to_string(),
169        version: artifact_version.to_string(),
170        message,
171    };
172    let cursor = std::io::Cursor::new(artifact_bytes);
173    let mut archive = ZipArchive::new(cursor).map_err(|err| archive_error(err.to_string()))?;
174
175    let skill_md = zip_guard::read_zip_entry_capped(&mut archive, SKILL_MD_ENTRY, max_bytes)
176        .map_err(|err| {
177            archive_error(format!(
178                "{SKILL_MD_ENTRY} not found or unreadable at archive root (ensure \
179                 {SKILL_MD_ENTRY} is at the root of the .mur.zip): {err}"
180            ))
181        })?;
182    let mut total = skill_md.len() as u64;
183    let mut files = vec![(SKILL_MD_ENTRY.to_string(), skill_md)];
184
185    let mut supporting = Vec::new();
186    for index in 0..archive.len() {
187        let entry = archive
188            .by_index_raw(index)
189            .map_err(|err| archive_error(err.to_string()))?;
190        if entry.is_dir() {
191            continue;
192        }
193        let raw_name = entry
194            .name()
195            .map_err(|err| archive_error(err.to_string()))?
196            .into_owned();
197        let relative = zip_guard::sanitize_entry_path(&raw_name)
198            .map_err(|err| archive_error(err.to_string()))?;
199        let relative = relative
200            .components()
201            .filter_map(|component| match component {
202                std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
203                _ => None,
204            })
205            .collect::<Vec<_>>()
206            .join("/");
207        if relative.is_empty()
208            || [SKILL_MD_ENTRY, PACKED_MANIFEST_ENTRY, BUILD_INFO_FILE].contains(&relative.as_str())
209        {
210            continue;
211        }
212        supporting.push((raw_name, relative));
213    }
214
215    for (raw_name, relative) in supporting {
216        let remaining = max_bytes.saturating_sub(total);
217        let bytes = zip_guard::read_zip_entry_capped(&mut archive, &raw_name, remaining)
218            .map_err(|err| archive_error(format!("failed to read '{raw_name}': {err}")))?;
219        total += bytes.len() as u64;
220        files.push((relative, bytes));
221    }
222    Ok(files)
223}
224
225pub fn extract_manifest_yaml(
226    artifact_name: &str,
227    artifact_version: &str,
228    artifact_bytes: &[u8],
229) -> Result<String, RuntimeError> {
230    extract_manifest_yaml_capped(
231        artifact_name,
232        artifact_version,
233        artifact_bytes,
234        zip_guard::max_artifact_decompressed_bytes(),
235    )
236}
237
238fn extract_manifest_yaml_capped(
239    artifact_name: &str,
240    artifact_version: &str,
241    artifact_bytes: &[u8],
242    max_bytes: u64,
243) -> Result<String, RuntimeError> {
244    let cursor = std::io::Cursor::new(artifact_bytes);
245    let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
246        name: artifact_name.to_string(),
247        version: artifact_version.to_string(),
248        message: err.to_string(),
249    })?;
250
251    zip_guard::read_zip_entry_to_string_capped(&mut archive, PACKED_MANIFEST_ENTRY, max_bytes)
252        .map_err(|err| RuntimeError::ArtifactArchive {
253            name: artifact_name.to_string(),
254            version: artifact_version.to_string(),
255            message: format!(
256                "missing or unreadable {PACKED_MANIFEST_ENTRY} in artifact archive: {err}"
257            ),
258        })
259}
260
261/// The `capabilities.env.allow` a packed manifest declares, read on its own.
262///
263/// Narrow on purpose. `murmur_artifact::RuntimeManifest::from_yaml_str` validates the whole
264/// manifest, and that resolves every `${VAR}` in a `gateway.api_key` against the reading
265/// process's environment — so a parent asking what its child declares would have to already hold
266/// the child's variables before it could read which ones they are. Reading the one key answers
267/// that without the circle.
268///
269/// A manifest with no `capabilities:` block, or none under `env:`, declares nothing and yields an
270/// empty list. Malformed YAML, or an `env.allow` that is not a list of strings, is an error
271/// naming the artifact and version.
272pub fn extract_declared_env_allow(
273    artifact_name: &str,
274    artifact_version: &str,
275    manifest_yaml: &str,
276) -> Result<Vec<String>, RuntimeError> {
277    let declared: DeclaredAllows =
278        serde_norway::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
279            name: artifact_name.to_string(),
280            version: artifact_version.to_string(),
281            message: format!(
282                "cannot read capabilities.env.allow from {PACKED_MANIFEST_ENTRY}: {err}"
283            ),
284        })?;
285    Ok(declared
286        .capabilities
287        .and_then(|capabilities| capabilities.env)
288        .map(|env| env.allow)
289        .unwrap_or_default())
290}
291
292/// The `capabilities.spawn.allow` a packed manifest declares, read on its own.
293///
294/// The sibling of [`extract_declared_env_allow`], and narrow for the same reason: walking a
295/// `spawn.allow` closure means reading which capsules a child may in turn spawn, and a
296/// whole-manifest parse would demand that child's own `${VAR}` references be resolvable first.
297///
298/// A manifest with no `capabilities:` block, or none under `spawn:`, declares nothing and yields
299/// an empty list. Malformed YAML, or a `spawn.allow` that is not a list of strings, is an error
300/// naming the artifact and version.
301pub fn extract_declared_spawn_allow(
302    artifact_name: &str,
303    artifact_version: &str,
304    manifest_yaml: &str,
305) -> Result<Vec<String>, RuntimeError> {
306    let declared: DeclaredAllows =
307        serde_norway::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
308            name: artifact_name.to_string(),
309            version: artifact_version.to_string(),
310            message: format!(
311                "cannot read capabilities.spawn.allow from {PACKED_MANIFEST_ENTRY}: {err}"
312            ),
313        })?;
314    Ok(declared
315        .capabilities
316        .and_then(|capabilities| capabilities.spawn)
317        .map(|spawn| spawn.allow)
318        .unwrap_or_default())
319}
320
321/// The `capabilities.resources.workdir_max_bytes` a packed manifest declares, read on its own:
322/// `None` when it declares none, which is [`crate::resources::DEFAULT_WORKDIR_MAX_BYTES`].
323///
324/// The sibling of [`extract_declared_env_allow`], and narrow for the same reason: a delegating
325/// parent bounds the copy of its work folder into a child's by the child's own ceiling, and a
326/// whole-manifest parse would demand that child's `${VAR}` references be resolvable first.
327/// Malformed YAML, or a value that is not a non-negative integer, is an error naming the artifact
328/// and version.
329pub fn extract_declared_workdir_max_bytes(
330    artifact_name: &str,
331    artifact_version: &str,
332    manifest_yaml: &str,
333) -> Result<Option<u64>, RuntimeError> {
334    let declared: DeclaredResourcesManifest =
335        serde_norway::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
336            name: artifact_name.to_string(),
337            version: artifact_version.to_string(),
338            message: format!(
339                "cannot read capabilities.resources.workdir_max_bytes from \
340                 {PACKED_MANIFEST_ENTRY}: {err}"
341            ),
342        })?;
343    Ok(declared
344        .capabilities
345        .and_then(|capabilities| capabilities.resources)
346        .and_then(|resources| resources.workdir_max_bytes))
347}
348
349/// The only keys [`extract_declared_env_allow`] and [`extract_declared_spawn_allow`] read. Every
350/// other manifest key deserializes into nothing here, so a child declaring anything at all is
351/// still readable.
352#[derive(serde::Deserialize)]
353struct DeclaredAllows {
354    #[serde(default)]
355    capabilities: Option<DeclaredCapabilities>,
356}
357
358#[derive(serde::Deserialize)]
359struct DeclaredCapabilities {
360    #[serde(default)]
361    env: Option<DeclaredAllowList>,
362    #[serde(default)]
363    spawn: Option<DeclaredAllowList>,
364}
365
366/// The only key [`extract_declared_workdir_max_bytes`] reads, apart from the allows so a
367/// malformed `resources:` block never stops a child's `env.allow` being read.
368#[derive(serde::Deserialize)]
369struct DeclaredResourcesManifest {
370    #[serde(default)]
371    capabilities: Option<DeclaredResourcesCapabilities>,
372}
373
374#[derive(serde::Deserialize)]
375struct DeclaredResourcesCapabilities {
376    #[serde(default)]
377    resources: Option<DeclaredResources>,
378}
379
380#[derive(serde::Deserialize)]
381struct DeclaredResources {
382    #[serde(default)]
383    workdir_max_bytes: Option<u64>,
384}
385
386#[derive(serde::Deserialize)]
387struct DeclaredAllowList {
388    #[serde(default)]
389    allow: Vec<String>,
390}
391
392#[cfg(test)]
393pub(crate) mod test_support {
394    use std::io::Write;
395
396    use zip::write::SimpleFileOptions;
397
398    /// A `.mur.zip` payload holding `files`, each `(path in the archive, bytes)`.
399    pub(crate) fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
400        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
401        {
402            let mut zip = zip::ZipWriter::new(&mut cursor);
403            let options = SimpleFileOptions::default();
404            for (name, bytes) in files {
405                zip.start_file(*name, options).unwrap();
406                zip.write_all(bytes).unwrap();
407            }
408            zip.finish().unwrap();
409        }
410        cursor.into_inner()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::test_support::archive_with_files;
417    use super::*;
418
419    #[test]
420    fn prefers_capsule_wasm_when_present() {
421        let archive = archive_with_files(&[
422            ("tool.wasm", b"tool"),
423            ("capsule.wasm", b"capsule"),
424            (PACKED_MANIFEST_ENTRY, b"name: demo"),
425        ]);
426
427        let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
428        assert_eq!(wasm, b"capsule");
429    }
430
431    #[test]
432    fn extracts_manifest_yaml_from_archive() {
433        let archive = archive_with_files(&[
434            (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
435            ("tool.wasm", b"tool"),
436        ]);
437
438        let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
439        assert!(manifest.contains("name: demo"));
440        assert!(manifest.contains("version: 0.1.0"));
441    }
442
443    #[test]
444    fn reads_the_declared_env_allow_out_of_a_manifest() {
445        let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n  network:\n    \
446                        allow: [127.0.0.1]\n  env:\n    allow: [OPENAI_API_KEY, HTTPS_PROXY]\n";
447
448        assert_eq!(
449            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
450            vec!["OPENAI_API_KEY".to_string(), "HTTPS_PROXY".to_string()]
451        );
452    }
453
454    /// The read answers what the child declares without validating the rest of the manifest: a
455    /// `${VAR}` the reading process does not hold would fail a full parse, and the whole point is
456    /// to learn which variables the child needs before holding any of them.
457    #[test]
458    fn an_unresolvable_inference_key_does_not_stop_the_read() {
459        let manifest = "name: worker\nversion: 0.1.0\ninference:\n  transport: http\n  \
460                        api_key: ${MURMUR_A_VARIABLE_NOBODY_EXPORTED}\ncapabilities:\n  env:\n    \
461                        allow: [MURMUR_A_VARIABLE_NOBODY_EXPORTED]\n";
462
463        assert_eq!(
464            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
465            vec!["MURMUR_A_VARIABLE_NOBODY_EXPORTED".to_string()]
466        );
467    }
468
469    #[test]
470    fn a_manifest_declaring_no_env_allow_declares_nothing() {
471        for manifest in [
472            "name: worker\nversion: 0.1.0\n",
473            "name: worker\nversion: 0.1.0\ncapabilities:\n  shell:\n    allow: [bash]\n",
474            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: []\n",
475        ] {
476            assert_eq!(
477                extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
478                Vec::<String>::new(),
479                "{manifest}"
480            );
481        }
482    }
483
484    #[test]
485    fn an_unreadable_env_allow_is_an_error_naming_the_artifact() {
486        for manifest in [
487            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: OPENAI_API_KEY\n",
488            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: [[OPENAI_API_KEY]]\n",
489            "name: worker\nversion: 0.1.0\ncapabilities:\n\tenv: broken\n",
490        ] {
491            let err = extract_declared_env_allow("worker", "0.1.0", manifest).unwrap_err();
492            match err {
493                RuntimeError::ArtifactArchive {
494                    name,
495                    version,
496                    message,
497                } => {
498                    assert_eq!(name, "worker");
499                    assert_eq!(version, "0.1.0");
500                    assert!(message.contains("capabilities.env.allow"), "{message}");
501                }
502                other => panic!("expected ArtifactArchive error, got {other:?}"),
503            }
504        }
505    }
506
507    #[test]
508    fn reads_the_declared_spawn_allow_out_of_a_manifest() {
509        let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    \
510                        allow: [OPENAI_API_KEY]\n  spawn:\n    allow: [deep-worker, sibling]\n";
511
512        assert_eq!(
513            extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
514            vec!["deep-worker".to_string(), "sibling".to_string()]
515        );
516        assert_eq!(
517            extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
518            vec!["OPENAI_API_KEY".to_string()]
519        );
520    }
521
522    #[test]
523    fn a_manifest_declaring_no_spawn_allow_declares_nothing() {
524        for manifest in [
525            "name: worker\nversion: 0.1.0\n",
526            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: [A_KEY]\n",
527            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: []\n",
528        ] {
529            assert_eq!(
530                extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
531                Vec::<String>::new(),
532                "{manifest}"
533            );
534        }
535    }
536
537    #[test]
538    fn an_unreadable_spawn_allow_is_an_error_naming_the_artifact() {
539        for manifest in [
540            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: deep-worker\n",
541            "name: worker\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: [[deep]]\n",
542            "name: worker\nversion: 0.1.0\ncapabilities:\n\tspawn: broken\n",
543        ] {
544            match extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap_err() {
545                RuntimeError::ArtifactArchive {
546                    name,
547                    version,
548                    message,
549                } => {
550                    assert_eq!(name, "worker");
551                    assert_eq!(version, "0.1.0");
552                    assert!(message.contains("capabilities.spawn.allow"), "{message}");
553                }
554                other => panic!("expected ArtifactArchive error, got {other:?}"),
555            }
556        }
557    }
558
559    #[test]
560    fn reads_the_declared_workdir_max_bytes_out_of_a_manifest() {
561        let declared = "name: worker\nversion: 0.1.0\ncapabilities:\n  resources:\n    \
562                        workdir_max_bytes: 1024\n    cgroup_pids_max: 64\n";
563        assert_eq!(
564            extract_declared_workdir_max_bytes("worker", "0.1.0", declared).unwrap(),
565            Some(1024)
566        );
567        for manifest in [
568            "name: worker\nversion: 0.1.0\n",
569            "name: worker\nversion: 0.1.0\ncapabilities:\n  env:\n    allow: [A_KEY]\n",
570            "name: worker\nversion: 0.1.0\ncapabilities:\n  resources:\n    cgroup_pids_max: 8\n",
571        ] {
572            assert_eq!(
573                extract_declared_workdir_max_bytes("worker", "0.1.0", manifest).unwrap(),
574                None,
575                "{manifest}"
576            );
577        }
578        let malformed = "name: worker\nversion: 0.1.0\ncapabilities:\n  resources:\n    \
579                         workdir_max_bytes: lots\n  env:\n    allow: [A_KEY]\n";
580        match extract_declared_workdir_max_bytes("worker", "0.1.0", malformed).unwrap_err() {
581            RuntimeError::ArtifactArchive { name, message, .. } => {
582                assert_eq!(name, "worker");
583                assert!(
584                    message.contains("capabilities.resources.workdir_max_bytes"),
585                    "{message}"
586                );
587            }
588            other => panic!("expected ArtifactArchive error, got {other:?}"),
589        }
590        assert_eq!(
591            extract_declared_env_allow("worker", "0.1.0", malformed).unwrap(),
592            vec!["A_KEY".to_string()],
593            "a malformed resources block does not stop env.allow being read"
594        );
595    }
596
597    #[test]
598    fn rejects_path_traversal_entry_as_root_wasm_candidate() {
599        let archive = archive_with_files(&[
600            ("../../evil.wasm", b"evil"),
601            (PACKED_MANIFEST_ENTRY, b"name: demo"),
602        ]);
603
604        let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
605        match err {
606            RuntimeError::ArtifactArchive { message, .. } => {
607                assert!(message.contains("missing root .wasm file"));
608            }
609            other => panic!("expected ArtifactArchive error, got {other:?}"),
610        }
611    }
612
613    #[test]
614    fn rejects_leading_slash_entry_as_root_wasm_candidate() {
615        let archive = archive_with_files(&[
616            ("/capsule.wasm", b"evil"),
617            (PACKED_MANIFEST_ENTRY, b"name: demo"),
618        ]);
619
620        let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
621        match err {
622            RuntimeError::ArtifactArchive { message, .. } => {
623                assert!(message.contains("missing root .wasm file"));
624            }
625            other => panic!("expected ArtifactArchive error, got {other:?}"),
626        }
627    }
628
629    #[test]
630    fn extract_manifest_yaml_rejects_entry_past_ceiling() {
631        let big = vec![b'a'; 1024];
632        let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
633
634        let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
635        match err {
636            RuntimeError::ArtifactArchive { message, .. } => {
637                assert!(message.contains("decompression ceiling"));
638            }
639            other => panic!("expected ArtifactArchive error, got {other:?}"),
640        }
641    }
642
643    #[test]
644    fn extract_root_wasm_rejects_entry_past_ceiling() {
645        let big = vec![b'a'; 1024];
646        let archive = archive_with_files(&[("capsule.wasm", &big)]);
647
648        let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
649        match err {
650            RuntimeError::ArtifactArchive { message, .. } => {
651                assert!(message.contains("decompression ceiling"));
652            }
653            other => panic!("expected ArtifactArchive error, got {other:?}"),
654        }
655    }
656
657    #[test]
658    fn extract_native_binary_rejects_entry_past_ceiling() {
659        let big = vec![b'a'; 1024];
660        let archive = archive_with_files(&[("bin/demo", &big)]);
661
662        let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
663        match err {
664            RuntimeError::ArtifactArchive { message, .. } => {
665                assert!(message.contains("decompression ceiling"));
666            }
667            other => panic!("expected ArtifactArchive error, got {other:?}"),
668        }
669    }
670
671    #[test]
672    fn extract_skill_files_rejects_entry_past_ceiling() {
673        let big = vec![b'a'; 1024];
674        let archive = archive_with_files(&[("skill.md", &big)]);
675
676        let err = extract_skill_files_capped("demo", "0.0.1", &archive, 16).unwrap_err();
677        match err {
678            RuntimeError::ArtifactArchive { message, .. } => {
679                assert!(message.contains("decompression ceiling"));
680            }
681            other => panic!("expected ArtifactArchive error, got {other:?}"),
682        }
683    }
684
685    #[test]
686    fn extract_skill_files_rejects_a_total_past_ceiling() {
687        let archive =
688            archive_with_files(&[("skill.md", &[b'a'; 10]), ("references/a.md", &[b'b'; 10])]);
689
690        let err = extract_skill_files_capped("demo", "0.0.1", &archive, 16).unwrap_err();
691        match err {
692            RuntimeError::ArtifactArchive { message, .. } => {
693                assert!(message.contains("references/a.md"), "{message}");
694                assert!(message.contains("decompression ceiling"), "{message}");
695            }
696            other => panic!("expected ArtifactArchive error, got {other:?}"),
697        }
698    }
699
700    #[test]
701    fn extract_skill_files_returns_supporting_files_without_manifest_or_build_record() {
702        let archive = archive_with_files(&[
703            (PACKED_MANIFEST_ENTRY, b"name: demo"),
704            ("references/forms.md", b"# Forms\n"),
705            ("skill.md", b"# Demo\n"),
706            (BUILD_INFO_FILE, b"{}"),
707            ("scripts/fill.py", b"print(1)\n"),
708        ]);
709
710        let files = extract_skill_files("demo", "0.0.1", &archive).unwrap();
711        assert_eq!(
712            files,
713            vec![
714                ("skill.md".to_string(), b"# Demo\n".to_vec()),
715                ("references/forms.md".to_string(), b"# Forms\n".to_vec()),
716                ("scripts/fill.py".to_string(), b"print(1)\n".to_vec()),
717            ]
718        );
719    }
720
721    #[test]
722    fn extract_skill_files_requires_skill_md() {
723        let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, b"name: demo")]);
724
725        let err = extract_skill_files("demo", "0.0.1", &archive).unwrap_err();
726        match err {
727            RuntimeError::ArtifactArchive { message, .. } => {
728                assert!(
729                    message.contains("skill.md not found or unreadable at archive root"),
730                    "{message}"
731                );
732            }
733            other => panic!("expected ArtifactArchive error, got {other:?}"),
734        }
735    }
736
737    #[test]
738    fn extract_skill_files_refuses_an_escaping_entry() {
739        let archive = archive_with_files(&[("skill.md", b"# s"), ("../escape.md", b"x")]);
740
741        let err = extract_skill_files("demo", "0.0.1", &archive).unwrap_err();
742        match err {
743            RuntimeError::ArtifactArchive { message, .. } => {
744                assert!(message.contains("../escape.md"), "{message}");
745            }
746            other => panic!("expected ArtifactArchive error, got {other:?}"),
747        }
748    }
749
750    #[test]
751    fn check_root_wasm_refuses_exactly_as_extract_root_wasm() {
752        let no_root_wasm = archive_with_files(&[(PACKED_MANIFEST_ENTRY, b"name: demo")]);
753        let two_root_wasms = archive_with_files(&[
754            ("alpha.wasm", b"a"),
755            ("zeta.wasm", b"z"),
756            (PACKED_MANIFEST_ENTRY, b"name: demo"),
757        ]);
758        for payload in [b"not a zip".to_vec(), no_root_wasm, two_root_wasms] {
759            let checked = check_root_wasm("demo", "0.0.1", &payload).unwrap_err();
760            let extracted = extract_root_wasm("demo", "0.0.1", &payload).unwrap_err();
761            assert_eq!(checked.to_string(), extracted.to_string());
762        }
763
764        let valid = archive_with_files(&[
765            (PACKED_MANIFEST_ENTRY, b"name: demo"),
766            ("tool.wasm", b"tool"),
767        ]);
768        check_root_wasm("demo", "0.0.1", &valid).unwrap();
769        extract_root_wasm("demo", "0.0.1", &valid).unwrap();
770    }
771
772    #[test]
773    fn check_root_wasm_does_not_inflate() {
774        let big = vec![b'a'; 1024];
775        let archive = archive_with_files(&[("tool.wasm", &big)]);
776
777        let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
778        assert!(
779            err.to_string()
780                .contains("exceeds the 16-byte decompression ceiling"),
781            "{err}"
782        );
783        check_root_wasm("demo", "0.0.1", &archive).unwrap();
784    }
785
786    #[test]
787    fn default_ceiling_does_not_reject_small_fixtures() {
788        let archive = archive_with_files(&[
789            (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
790            ("capsule.wasm", b"small wasm bytes"),
791        ]);
792
793        extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
794        extract_root_wasm("demo", "0.1.0", &archive).unwrap();
795    }
796}