1use murmur_artifact::payload_shape::{
2 native_binary_entry, select_root_wasm_in_archive, SKILL_MD_ENTRY,
3};
4use murmur_artifact::zip_guard;
5use murmur_artifact::PACKED_MANIFEST_ENTRY;
6use zip::ZipArchive;
7
8use crate::errors::RuntimeError;
9
10pub fn extract_root_wasm(
11 artifact_name: &str,
12 artifact_version: &str,
13 artifact_bytes: &[u8],
14) -> Result<Vec<u8>, RuntimeError> {
15 extract_root_wasm_capped(
16 artifact_name,
17 artifact_version,
18 artifact_bytes,
19 zip_guard::max_artifact_decompressed_bytes(),
20 )
21}
22
23fn extract_root_wasm_capped(
24 artifact_name: &str,
25 artifact_version: &str,
26 artifact_bytes: &[u8],
27 max_bytes: u64,
28) -> Result<Vec<u8>, RuntimeError> {
29 let cursor = std::io::Cursor::new(artifact_bytes);
30 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
31 name: artifact_name.to_string(),
32 version: artifact_version.to_string(),
33 message: err.to_string(),
34 })?;
35
36 let selected_name =
39 select_root_wasm_in_archive(&mut archive).map_err(|err| RuntimeError::ArtifactArchive {
40 name: artifact_name.to_string(),
41 version: artifact_version.to_string(),
42 message: err.to_string(),
43 })?;
44
45 zip_guard::read_zip_entry_capped(&mut archive, &selected_name, max_bytes).map_err(|err| {
46 RuntimeError::ArtifactArchive {
47 name: artifact_name.to_string(),
48 version: artifact_version.to_string(),
49 message: err.to_string(),
50 }
51 })
52}
53
54pub fn extract_native_binary(
60 artifact_name: &str,
61 artifact_version: &str,
62 artifact_bytes: &[u8],
63) -> Result<Vec<u8>, RuntimeError> {
64 extract_native_binary_capped(
65 artifact_name,
66 artifact_version,
67 artifact_bytes,
68 zip_guard::max_artifact_decompressed_bytes(),
69 )
70}
71
72fn extract_native_binary_capped(
73 artifact_name: &str,
74 artifact_version: &str,
75 artifact_bytes: &[u8],
76 max_bytes: u64,
77) -> Result<Vec<u8>, RuntimeError> {
78 let cursor = std::io::Cursor::new(artifact_bytes);
79 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
80 name: artifact_name.to_string(),
81 version: artifact_version.to_string(),
82 message: err.to_string(),
83 })?;
84
85 let bin_path = native_binary_entry(artifact_name);
86 zip_guard::read_zip_entry_capped(&mut archive, &bin_path, max_bytes).map_err(|err| {
87 RuntimeError::ArtifactArchive {
88 name: artifact_name.to_string(),
89 version: artifact_version.to_string(),
90 message: format!(
91 "failed to read native binary at '{bin_path}' from archive (ensure the binary \
92 is at {bin_path} inside the .mur.zip): {err}"
93 ),
94 }
95 })
96}
97
98pub fn extract_skill_md(
104 artifact_name: &str,
105 artifact_version: &str,
106 artifact_bytes: &[u8],
107) -> Result<Vec<u8>, RuntimeError> {
108 extract_skill_md_capped(
109 artifact_name,
110 artifact_version,
111 artifact_bytes,
112 zip_guard::max_artifact_decompressed_bytes(),
113 )
114}
115
116fn extract_skill_md_capped(
117 artifact_name: &str,
118 artifact_version: &str,
119 artifact_bytes: &[u8],
120 max_bytes: u64,
121) -> Result<Vec<u8>, RuntimeError> {
122 let cursor = std::io::Cursor::new(artifact_bytes);
123 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
124 name: artifact_name.to_string(),
125 version: artifact_version.to_string(),
126 message: err.to_string(),
127 })?;
128
129 zip_guard::read_zip_entry_capped(&mut archive, SKILL_MD_ENTRY, max_bytes).map_err(|err| {
130 RuntimeError::ArtifactArchive {
131 name: artifact_name.to_string(),
132 version: artifact_version.to_string(),
133 message: format!(
134 "{SKILL_MD_ENTRY} not found or unreadable at archive root (ensure \
135 {SKILL_MD_ENTRY} is at the root of the .mur.zip): {err}"
136 ),
137 }
138 })
139}
140
141pub fn extract_manifest_yaml(
142 artifact_name: &str,
143 artifact_version: &str,
144 artifact_bytes: &[u8],
145) -> Result<String, RuntimeError> {
146 extract_manifest_yaml_capped(
147 artifact_name,
148 artifact_version,
149 artifact_bytes,
150 zip_guard::max_artifact_decompressed_bytes(),
151 )
152}
153
154fn extract_manifest_yaml_capped(
155 artifact_name: &str,
156 artifact_version: &str,
157 artifact_bytes: &[u8],
158 max_bytes: u64,
159) -> Result<String, RuntimeError> {
160 let cursor = std::io::Cursor::new(artifact_bytes);
161 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
162 name: artifact_name.to_string(),
163 version: artifact_version.to_string(),
164 message: err.to_string(),
165 })?;
166
167 zip_guard::read_zip_entry_to_string_capped(&mut archive, PACKED_MANIFEST_ENTRY, max_bytes)
168 .map_err(|err| RuntimeError::ArtifactArchive {
169 name: artifact_name.to_string(),
170 version: artifact_version.to_string(),
171 message: format!(
172 "missing or unreadable {PACKED_MANIFEST_ENTRY} in artifact archive: {err}"
173 ),
174 })
175}
176
177pub fn extract_declared_env_allow(
189 artifact_name: &str,
190 artifact_version: &str,
191 manifest_yaml: &str,
192) -> Result<Vec<String>, RuntimeError> {
193 let declared: DeclaredAllows =
194 serde_yaml::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
195 name: artifact_name.to_string(),
196 version: artifact_version.to_string(),
197 message: format!(
198 "cannot read capabilities.env.allow from {PACKED_MANIFEST_ENTRY}: {err}"
199 ),
200 })?;
201 Ok(declared
202 .capabilities
203 .and_then(|capabilities| capabilities.env)
204 .map(|env| env.allow)
205 .unwrap_or_default())
206}
207
208pub fn extract_declared_spawn_allow(
218 artifact_name: &str,
219 artifact_version: &str,
220 manifest_yaml: &str,
221) -> Result<Vec<String>, RuntimeError> {
222 let declared: DeclaredAllows =
223 serde_yaml::from_str(manifest_yaml).map_err(|err| RuntimeError::ArtifactArchive {
224 name: artifact_name.to_string(),
225 version: artifact_version.to_string(),
226 message: format!(
227 "cannot read capabilities.spawn.allow from {PACKED_MANIFEST_ENTRY}: {err}"
228 ),
229 })?;
230 Ok(declared
231 .capabilities
232 .and_then(|capabilities| capabilities.spawn)
233 .map(|spawn| spawn.allow)
234 .unwrap_or_default())
235}
236
237#[derive(serde::Deserialize)]
241struct DeclaredAllows {
242 #[serde(default)]
243 capabilities: Option<DeclaredCapabilities>,
244}
245
246#[derive(serde::Deserialize)]
247struct DeclaredCapabilities {
248 #[serde(default)]
249 env: Option<DeclaredAllowList>,
250 #[serde(default)]
251 spawn: Option<DeclaredAllowList>,
252}
253
254#[derive(serde::Deserialize)]
255struct DeclaredAllowList {
256 #[serde(default)]
257 allow: Vec<String>,
258}
259
260#[cfg(test)]
261mod tests {
262 use std::io::Write;
263
264 use zip::write::SimpleFileOptions;
265
266 use super::*;
267
268 fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
269 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
270 {
271 let mut zip = zip::ZipWriter::new(&mut cursor);
272 let options = SimpleFileOptions::default();
273 for (name, bytes) in files {
274 zip.start_file(*name, options).unwrap();
275 zip.write_all(bytes).unwrap();
276 }
277 zip.finish().unwrap();
278 }
279 cursor.into_inner()
280 }
281
282 #[test]
283 fn prefers_capsule_wasm_when_present() {
284 let archive = archive_with_files(&[
285 ("tool.wasm", b"tool"),
286 ("capsule.wasm", b"capsule"),
287 (PACKED_MANIFEST_ENTRY, b"name: demo"),
288 ]);
289
290 let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
291 assert_eq!(wasm, b"capsule");
292 }
293
294 #[test]
295 fn extracts_manifest_yaml_from_archive() {
296 let archive = archive_with_files(&[
297 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
298 ("tool.wasm", b"tool"),
299 ]);
300
301 let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
302 assert!(manifest.contains("name: demo"));
303 assert!(manifest.contains("version: 0.1.0"));
304 }
305
306 #[test]
307 fn reads_the_declared_env_allow_out_of_a_manifest() {
308 let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n network:\n \
309 allow: [127.0.0.1]\n env:\n allow: [OPENAI_API_KEY, HTTPS_PROXY]\n";
310
311 assert_eq!(
312 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
313 vec!["OPENAI_API_KEY".to_string(), "HTTPS_PROXY".to_string()]
314 );
315 }
316
317 #[test]
321 fn an_unresolvable_inference_key_does_not_stop_the_read() {
322 let manifest = "name: worker\nversion: 0.1.0\ninference:\n transport: http\n \
323 api_key: ${MURMUR_A_VARIABLE_NOBODY_EXPORTED}\ncapabilities:\n env:\n \
324 allow: [MURMUR_A_VARIABLE_NOBODY_EXPORTED]\n";
325
326 assert_eq!(
327 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
328 vec!["MURMUR_A_VARIABLE_NOBODY_EXPORTED".to_string()]
329 );
330 }
331
332 #[test]
333 fn a_manifest_declaring_no_env_allow_declares_nothing() {
334 for manifest in [
335 "name: worker\nversion: 0.1.0\n",
336 "name: worker\nversion: 0.1.0\ncapabilities:\n shell:\n allow: [bash]\n",
337 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: []\n",
338 ] {
339 assert_eq!(
340 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
341 Vec::<String>::new(),
342 "{manifest}"
343 );
344 }
345 }
346
347 #[test]
348 fn an_unreadable_env_allow_is_an_error_naming_the_artifact() {
349 for manifest in [
350 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: OPENAI_API_KEY\n",
351 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: [[OPENAI_API_KEY]]\n",
352 "name: worker\nversion: 0.1.0\ncapabilities:\n\tenv: broken\n",
353 ] {
354 let err = extract_declared_env_allow("worker", "0.1.0", manifest).unwrap_err();
355 match err {
356 RuntimeError::ArtifactArchive {
357 name,
358 version,
359 message,
360 } => {
361 assert_eq!(name, "worker");
362 assert_eq!(version, "0.1.0");
363 assert!(message.contains("capabilities.env.allow"), "{message}");
364 }
365 other => panic!("expected ArtifactArchive error, got {other:?}"),
366 }
367 }
368 }
369
370 #[test]
371 fn reads_the_declared_spawn_allow_out_of_a_manifest() {
372 let manifest = "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n \
373 allow: [OPENAI_API_KEY]\n spawn:\n allow: [deep-worker, sibling]\n";
374
375 assert_eq!(
376 extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
377 vec!["deep-worker".to_string(), "sibling".to_string()]
378 );
379 assert_eq!(
380 extract_declared_env_allow("worker", "0.1.0", manifest).unwrap(),
381 vec!["OPENAI_API_KEY".to_string()]
382 );
383 }
384
385 #[test]
386 fn a_manifest_declaring_no_spawn_allow_declares_nothing() {
387 for manifest in [
388 "name: worker\nversion: 0.1.0\n",
389 "name: worker\nversion: 0.1.0\ncapabilities:\n env:\n allow: [A_KEY]\n",
390 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: []\n",
391 ] {
392 assert_eq!(
393 extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap(),
394 Vec::<String>::new(),
395 "{manifest}"
396 );
397 }
398 }
399
400 #[test]
401 fn an_unreadable_spawn_allow_is_an_error_naming_the_artifact() {
402 for manifest in [
403 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: deep-worker\n",
404 "name: worker\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [[deep]]\n",
405 "name: worker\nversion: 0.1.0\ncapabilities:\n\tspawn: broken\n",
406 ] {
407 match extract_declared_spawn_allow("worker", "0.1.0", manifest).unwrap_err() {
408 RuntimeError::ArtifactArchive {
409 name,
410 version,
411 message,
412 } => {
413 assert_eq!(name, "worker");
414 assert_eq!(version, "0.1.0");
415 assert!(message.contains("capabilities.spawn.allow"), "{message}");
416 }
417 other => panic!("expected ArtifactArchive error, got {other:?}"),
418 }
419 }
420 }
421
422 #[test]
423 fn rejects_path_traversal_entry_as_root_wasm_candidate() {
424 let archive = archive_with_files(&[
425 ("../../evil.wasm", b"evil"),
426 (PACKED_MANIFEST_ENTRY, b"name: demo"),
427 ]);
428
429 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
430 match err {
431 RuntimeError::ArtifactArchive { message, .. } => {
432 assert!(message.contains("missing root .wasm file"));
433 }
434 other => panic!("expected ArtifactArchive error, got {other:?}"),
435 }
436 }
437
438 #[test]
439 fn rejects_leading_slash_entry_as_root_wasm_candidate() {
440 let archive = archive_with_files(&[
441 ("/capsule.wasm", b"evil"),
442 (PACKED_MANIFEST_ENTRY, b"name: demo"),
443 ]);
444
445 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
446 match err {
447 RuntimeError::ArtifactArchive { message, .. } => {
448 assert!(message.contains("missing root .wasm file"));
449 }
450 other => panic!("expected ArtifactArchive error, got {other:?}"),
451 }
452 }
453
454 #[test]
455 fn extract_manifest_yaml_rejects_entry_past_ceiling() {
456 let big = vec![b'a'; 1024];
457 let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
458
459 let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
460 match err {
461 RuntimeError::ArtifactArchive { message, .. } => {
462 assert!(message.contains("decompression ceiling"));
463 }
464 other => panic!("expected ArtifactArchive error, got {other:?}"),
465 }
466 }
467
468 #[test]
469 fn extract_root_wasm_rejects_entry_past_ceiling() {
470 let big = vec![b'a'; 1024];
471 let archive = archive_with_files(&[("capsule.wasm", &big)]);
472
473 let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
474 match err {
475 RuntimeError::ArtifactArchive { message, .. } => {
476 assert!(message.contains("decompression ceiling"));
477 }
478 other => panic!("expected ArtifactArchive error, got {other:?}"),
479 }
480 }
481
482 #[test]
483 fn extract_native_binary_rejects_entry_past_ceiling() {
484 let big = vec![b'a'; 1024];
485 let archive = archive_with_files(&[("bin/demo", &big)]);
486
487 let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
488 match err {
489 RuntimeError::ArtifactArchive { message, .. } => {
490 assert!(message.contains("decompression ceiling"));
491 }
492 other => panic!("expected ArtifactArchive error, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn extract_skill_md_rejects_entry_past_ceiling() {
498 let big = vec![b'a'; 1024];
499 let archive = archive_with_files(&[("skill.md", &big)]);
500
501 let err = extract_skill_md_capped("demo", "0.0.1", &archive, 16).unwrap_err();
502 match err {
503 RuntimeError::ArtifactArchive { message, .. } => {
504 assert!(message.contains("decompression ceiling"));
505 }
506 other => panic!("expected ArtifactArchive error, got {other:?}"),
507 }
508 }
509
510 #[test]
511 fn default_ceiling_does_not_reject_small_fixtures() {
512 let archive = archive_with_files(&[
513 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
514 ("capsule.wasm", b"small wasm bytes"),
515 ]);
516
517 extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
518 extract_root_wasm("demo", "0.1.0", &archive).unwrap();
519 }
520}