Skip to main content

capsule_runtime/
artifact.rs

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