Skip to main content

capsule_runtime/
artifact.rs

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