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
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 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
133pub type SkillFiles = Vec<(String, Vec<u8>)>;
136
137pub 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
261pub 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
292pub 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
321pub 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#[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#[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 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 #[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}