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::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 fn extract_skill_md(
139 artifact_name: &str,
140 artifact_version: &str,
141 artifact_bytes: &[u8],
142) -> Result<Vec<u8>, RuntimeError> {
143 extract_skill_md_capped(
144 artifact_name,
145 artifact_version,
146 artifact_bytes,
147 zip_guard::max_artifact_decompressed_bytes(),
148 )
149}
150
151fn extract_skill_md_capped(
152 artifact_name: &str,
153 artifact_version: &str,
154 artifact_bytes: &[u8],
155 max_bytes: u64,
156) -> Result<Vec<u8>, RuntimeError> {
157 let cursor = std::io::Cursor::new(artifact_bytes);
158 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
159 name: artifact_name.to_string(),
160 version: artifact_version.to_string(),
161 message: err.to_string(),
162 })?;
163
164 zip_guard::read_zip_entry_capped(&mut archive, SKILL_MD_ENTRY, max_bytes).map_err(|err| {
165 RuntimeError::ArtifactArchive {
166 name: artifact_name.to_string(),
167 version: artifact_version.to_string(),
168 message: format!(
169 "{SKILL_MD_ENTRY} not found or unreadable at archive root (ensure \
170 {SKILL_MD_ENTRY} is at the root of the .mur.zip): {err}"
171 ),
172 }
173 })
174}
175
176pub fn extract_manifest_yaml(
177 artifact_name: &str,
178 artifact_version: &str,
179 artifact_bytes: &[u8],
180) -> Result<String, RuntimeError> {
181 extract_manifest_yaml_capped(
182 artifact_name,
183 artifact_version,
184 artifact_bytes,
185 zip_guard::max_artifact_decompressed_bytes(),
186 )
187}
188
189fn extract_manifest_yaml_capped(
190 artifact_name: &str,
191 artifact_version: &str,
192 artifact_bytes: &[u8],
193 max_bytes: u64,
194) -> Result<String, RuntimeError> {
195 let cursor = std::io::Cursor::new(artifact_bytes);
196 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
197 name: artifact_name.to_string(),
198 version: artifact_version.to_string(),
199 message: err.to_string(),
200 })?;
201
202 zip_guard::read_zip_entry_to_string_capped(&mut archive, PACKED_MANIFEST_ENTRY, max_bytes)
203 .map_err(|err| RuntimeError::ArtifactArchive {
204 name: artifact_name.to_string(),
205 version: artifact_version.to_string(),
206 message: format!(
207 "missing or unreadable {PACKED_MANIFEST_ENTRY} in artifact archive: {err}"
208 ),
209 })
210}
211
212pub fn extract_declared_env_allow(
224 artifact_name: &str,
225 artifact_version: &str,
226 manifest_yaml: &str,
227) -> Result<Vec<String>, RuntimeError> {
228 let declared: DeclaredAllows =
229 serde_norway::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
230 name: artifact_name.to_string(),
231 version: artifact_version.to_string(),
232 message: format!(
233 "cannot read capabilities.env.allow from {PACKED_MANIFEST_ENTRY}: {err}"
234 ),
235 })?;
236 Ok(declared
237 .capabilities
238 .and_then(|capabilities| capabilities.env)
239 .map(|env| env.allow)
240 .unwrap_or_default())
241}
242
243pub fn extract_declared_spawn_allow(
253 artifact_name: &str,
254 artifact_version: &str,
255 manifest_yaml: &str,
256) -> Result<Vec<String>, RuntimeError> {
257 let declared: DeclaredAllows =
258 serde_norway::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
259 name: artifact_name.to_string(),
260 version: artifact_version.to_string(),
261 message: format!(
262 "cannot read capabilities.spawn.allow from {PACKED_MANIFEST_ENTRY}: {err}"
263 ),
264 })?;
265 Ok(declared
266 .capabilities
267 .and_then(|capabilities| capabilities.spawn)
268 .map(|spawn| spawn.allow)
269 .unwrap_or_default())
270}
271
272#[derive(serde::Deserialize)]
276struct DeclaredAllows {
277 #[serde(default)]
278 capabilities: Option<DeclaredCapabilities>,
279}
280
281#[derive(serde::Deserialize)]
282struct DeclaredCapabilities {
283 #[serde(default)]
284 env: Option<DeclaredAllowList>,
285 #[serde(default)]
286 spawn: Option<DeclaredAllowList>,
287}
288
289#[derive(serde::Deserialize)]
290struct DeclaredAllowList {
291 #[serde(default)]
292 allow: Vec<String>,
293}
294
295#[cfg(test)]
296pub(crate) mod test_support {
297 use std::io::Write;
298
299 use zip::write::SimpleFileOptions;
300
301 pub(crate) fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
303 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
304 {
305 let mut zip = zip::ZipWriter::new(&mut cursor);
306 let options = SimpleFileOptions::default();
307 for (name, bytes) in files {
308 zip.start_file(*name, options).unwrap();
309 zip.write_all(bytes).unwrap();
310 }
311 zip.finish().unwrap();
312 }
313 cursor.into_inner()
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::test_support::archive_with_files;
320 use super::*;
321
322 #[test]
323 fn prefers_capsule_wasm_when_present() {
324 let archive = archive_with_files(&[
325 ("tool.wasm", b"tool"),
326 ("capsule.wasm", b"capsule"),
327 (PACKED_MANIFEST_ENTRY, b"name: demo"),
328 ]);
329
330 let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
331 assert_eq!(wasm, b"capsule");
332 }
333
334 #[test]
335 fn extracts_manifest_yaml_from_archive() {
336 let archive = archive_with_files(&[
337 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
338 ("tool.wasm", b"tool"),
339 ]);
340
341 let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
342 assert!(manifest.contains("name: demo"));
343 assert!(manifest.contains("version: 0.1.0"));
344 }
345
346 #[test]
347 fn reads_the_declared_env_allow_out_of_a_manifest() {
348 let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n network:\n \
349 allow: [127.0.0.1]\n env:\n allow: [OPENAI_API_KEY, HTTPS_PROXY]\n";
350
351 assert_eq!(
352 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
353 vec!["OPENAI_API_KEY".to_string(), "HTTPS_PROXY".to_string()]
354 );
355 }
356
357 #[test]
361 fn an_unresolvable_inference_key_does_not_stop_the_read() {
362 let manifest = "name: worker\nversion: 0.1.0\ninference:\n transport: http\n \
363 api_key: ${MURMUR_A_VARIABLE_NOBODY_EXPORTED}\ncapabilities:\n env:\n \
364 allow: [MURMUR_A_VARIABLE_NOBODY_EXPORTED]\n";
365
366 assert_eq!(
367 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
368 vec!["MURMUR_A_VARIABLE_NOBODY_EXPORTED".to_string()]
369 );
370 }
371
372 #[test]
373 fn a_manifest_declaring_no_env_allow_declares_nothing() {
374 for manifest in [
375 "name: worker\nversion: 0.1.0\n",
376 "name: worker\nversion: 0.1.0\ncapabilities:\n shell:\n allow: [bash]\n",
377 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: []\n",
378 ] {
379 assert_eq!(
380 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
381 Vec::<String>::new(),
382 "{manifest}"
383 );
384 }
385 }
386
387 #[test]
388 fn an_unreadable_env_allow_is_an_error_naming_the_artifact() {
389 for manifest in [
390 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: OPENAI_API_KEY\n",
391 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: [[OPENAI_API_KEY]]\n",
392 "name: worker\nversion: 0.1.0\ncapabilities:\n\tenv: broken\n",
393 ] {
394 let err = extract_declared_env_allow("worker", "0.1.0", manifest).unwrap_err();
395 match err {
396 RuntimeError::ArtifactArchive {
397 name,
398 version,
399 message,
400 } => {
401 assert_eq!(name, "worker");
402 assert_eq!(version, "0.1.0");
403 assert!(message.contains("capabilities.env.allow"), "{message}");
404 }
405 other => panic!("expected ArtifactArchive error, got {other:?}"),
406 }
407 }
408 }
409
410 #[test]
411 fn reads_the_declared_spawn_allow_out_of_a_manifest() {
412 let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n \
413 allow: [OPENAI_API_KEY]\n spawn:\n allow: [deep-worker, sibling]\n";
414
415 assert_eq!(
416 extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
417 vec!["deep-worker".to_string(), "sibling".to_string()]
418 );
419 assert_eq!(
420 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
421 vec!["OPENAI_API_KEY".to_string()]
422 );
423 }
424
425 #[test]
426 fn a_manifest_declaring_no_spawn_allow_declares_nothing() {
427 for manifest in [
428 "name: worker\nversion: 0.1.0\n",
429 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: [A_KEY]\n",
430 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: []\n",
431 ] {
432 assert_eq!(
433 extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
434 Vec::<String>::new(),
435 "{manifest}"
436 );
437 }
438 }
439
440 #[test]
441 fn an_unreadable_spawn_allow_is_an_error_naming_the_artifact() {
442 for manifest in [
443 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: deep-worker\n",
444 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [[deep]]\n",
445 "name: worker\nversion: 0.1.0\ncapabilities:\n\tspawn: broken\n",
446 ] {
447 match extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap_err() {
448 RuntimeError::ArtifactArchive {
449 name,
450 version,
451 message,
452 } => {
453 assert_eq!(name, "worker");
454 assert_eq!(version, "0.1.0");
455 assert!(message.contains("capabilities.spawn.allow"), "{message}");
456 }
457 other => panic!("expected ArtifactArchive error, got {other:?}"),
458 }
459 }
460 }
461
462 #[test]
463 fn rejects_path_traversal_entry_as_root_wasm_candidate() {
464 let archive = archive_with_files(&[
465 ("../../evil.wasm", b"evil"),
466 (PACKED_MANIFEST_ENTRY, b"name: demo"),
467 ]);
468
469 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
470 match err {
471 RuntimeError::ArtifactArchive { message, .. } => {
472 assert!(message.contains("missing root .wasm file"));
473 }
474 other => panic!("expected ArtifactArchive error, got {other:?}"),
475 }
476 }
477
478 #[test]
479 fn rejects_leading_slash_entry_as_root_wasm_candidate() {
480 let archive = archive_with_files(&[
481 ("/capsule.wasm", b"evil"),
482 (PACKED_MANIFEST_ENTRY, b"name: demo"),
483 ]);
484
485 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
486 match err {
487 RuntimeError::ArtifactArchive { message, .. } => {
488 assert!(message.contains("missing root .wasm file"));
489 }
490 other => panic!("expected ArtifactArchive error, got {other:?}"),
491 }
492 }
493
494 #[test]
495 fn extract_manifest_yaml_rejects_entry_past_ceiling() {
496 let big = vec![b'a'; 1024];
497 let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
498
499 let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
500 match err {
501 RuntimeError::ArtifactArchive { message, .. } => {
502 assert!(message.contains("decompression ceiling"));
503 }
504 other => panic!("expected ArtifactArchive error, got {other:?}"),
505 }
506 }
507
508 #[test]
509 fn extract_root_wasm_rejects_entry_past_ceiling() {
510 let big = vec![b'a'; 1024];
511 let archive = archive_with_files(&[("capsule.wasm", &big)]);
512
513 let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
514 match err {
515 RuntimeError::ArtifactArchive { message, .. } => {
516 assert!(message.contains("decompression ceiling"));
517 }
518 other => panic!("expected ArtifactArchive error, got {other:?}"),
519 }
520 }
521
522 #[test]
523 fn extract_native_binary_rejects_entry_past_ceiling() {
524 let big = vec![b'a'; 1024];
525 let archive = archive_with_files(&[("bin/demo", &big)]);
526
527 let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
528 match err {
529 RuntimeError::ArtifactArchive { message, .. } => {
530 assert!(message.contains("decompression ceiling"));
531 }
532 other => panic!("expected ArtifactArchive error, got {other:?}"),
533 }
534 }
535
536 #[test]
537 fn extract_skill_md_rejects_entry_past_ceiling() {
538 let big = vec![b'a'; 1024];
539 let archive = archive_with_files(&[("skill.md", &big)]);
540
541 let err = extract_skill_md_capped("demo", "0.0.1", &archive, 16).unwrap_err();
542 match err {
543 RuntimeError::ArtifactArchive { message, .. } => {
544 assert!(message.contains("decompression ceiling"));
545 }
546 other => panic!("expected ArtifactArchive error, got {other:?}"),
547 }
548 }
549
550 #[test]
551 fn check_root_wasm_refuses_exactly_as_extract_root_wasm() {
552 let no_root_wasm = archive_with_files(&[(PACKED_MANIFEST_ENTRY, b"name: demo")]);
553 let two_root_wasms = archive_with_files(&[
554 ("alpha.wasm", b"a"),
555 ("zeta.wasm", b"z"),
556 (PACKED_MANIFEST_ENTRY, b"name: demo"),
557 ]);
558 for payload in [b"not a zip".to_vec(), no_root_wasm, two_root_wasms] {
559 let checked = check_root_wasm("demo", "0.0.1", &payload).unwrap_err();
560 let extracted = extract_root_wasm("demo", "0.0.1", &payload).unwrap_err();
561 assert_eq!(checked.to_string(), extracted.to_string());
562 }
563
564 let valid = archive_with_files(&[
565 (PACKED_MANIFEST_ENTRY, b"name: demo"),
566 ("tool.wasm", b"tool"),
567 ]);
568 check_root_wasm("demo", "0.0.1", &valid).unwrap();
569 extract_root_wasm("demo", "0.0.1", &valid).unwrap();
570 }
571
572 #[test]
573 fn check_root_wasm_does_not_inflate() {
574 let big = vec![b'a'; 1024];
575 let archive = archive_with_files(&[("tool.wasm", &big)]);
576
577 let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
578 assert!(
579 err.to_string()
580 .contains("exceeds the 16-byte decompression ceiling"),
581 "{err}"
582 );
583 check_root_wasm("demo", "0.0.1", &archive).unwrap();
584 }
585
586 #[test]
587 fn default_ceiling_does_not_reject_small_fixtures() {
588 let archive = archive_with_files(&[
589 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
590 ("capsule.wasm", b"small wasm bytes"),
591 ]);
592
593 extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
594 extract_root_wasm("demo", "0.1.0", &archive).unwrap();
595 }
596}