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
12pub 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
33pub 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
45fn 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 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
89pub type SkillFiles = Vec<(String, Vec<u8>)>;
92
93pub 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
220pub 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
251pub 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
280pub 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#[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#[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 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 #[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}