1use murmur_artifact::zip_guard::{self, sanitize_entry_path};
2use murmur_artifact::PACKED_MANIFEST_ENTRY;
3use zip::ZipArchive;
4
5use crate::errors::RuntimeError;
6
7pub fn extract_root_wasm(
8 artifact_name: &str,
9 artifact_version: &str,
10 artifact_bytes: &[u8],
11) -> Result<Vec<u8>, RuntimeError> {
12 extract_root_wasm_capped(
13 artifact_name,
14 artifact_version,
15 artifact_bytes,
16 zip_guard::max_artifact_decompressed_bytes(),
17 )
18}
19
20fn extract_root_wasm_capped(
21 artifact_name: &str,
22 artifact_version: &str,
23 artifact_bytes: &[u8],
24 max_bytes: u64,
25) -> Result<Vec<u8>, RuntimeError> {
26 let cursor = std::io::Cursor::new(artifact_bytes);
27 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
28 name: artifact_name.to_string(),
29 version: artifact_version.to_string(),
30 message: err.to_string(),
31 })?;
32
33 let mut root_wasm_candidates = Vec::new();
34 for idx in 0..archive.len() {
35 let Ok(file) = archive.by_index(idx) else {
36 continue;
37 };
38
39 let raw_name = file.name().to_string();
40 drop(file);
41
42 if !raw_name.ends_with(".wasm") {
43 continue;
44 }
45
46 let Ok(sanitized) = sanitize_entry_path(&raw_name) else {
51 continue;
52 };
53 if raw_name.starts_with('/') || sanitized.components().count() != 1 {
54 continue;
55 }
56
57 root_wasm_candidates.push(raw_name);
58 }
59
60 let selected_name = if root_wasm_candidates
61 .iter()
62 .any(|name| name == "capsule.wasm")
63 {
64 "capsule.wasm".to_string()
65 } else {
66 match root_wasm_candidates.len() {
67 1 => root_wasm_candidates.remove(0),
68 0 => {
69 return Err(RuntimeError::ArtifactArchive {
70 name: artifact_name.to_string(),
71 version: artifact_version.to_string(),
72 message: "missing root .wasm file (expected capsule.wasm or one root *.wasm)"
73 .to_string(),
74 })
75 }
76 _ => {
77 root_wasm_candidates.sort();
78 return Err(RuntimeError::ArtifactArchive {
79 name: artifact_name.to_string(),
80 version: artifact_version.to_string(),
81 message: format!(
82 "multiple root .wasm files found: {}",
83 root_wasm_candidates.join(", ")
84 ),
85 });
86 }
87 }
88 };
89
90 zip_guard::read_zip_entry_capped(&mut archive, &selected_name, max_bytes).map_err(|err| {
91 RuntimeError::ArtifactArchive {
92 name: artifact_name.to_string(),
93 version: artifact_version.to_string(),
94 message: err.to_string(),
95 }
96 })
97}
98
99pub fn extract_native_binary(
105 artifact_name: &str,
106 artifact_version: &str,
107 artifact_bytes: &[u8],
108) -> Result<Vec<u8>, RuntimeError> {
109 extract_native_binary_capped(
110 artifact_name,
111 artifact_version,
112 artifact_bytes,
113 zip_guard::max_artifact_decompressed_bytes(),
114 )
115}
116
117fn extract_native_binary_capped(
118 artifact_name: &str,
119 artifact_version: &str,
120 artifact_bytes: &[u8],
121 max_bytes: u64,
122) -> Result<Vec<u8>, RuntimeError> {
123 let cursor = std::io::Cursor::new(artifact_bytes);
124 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
125 name: artifact_name.to_string(),
126 version: artifact_version.to_string(),
127 message: err.to_string(),
128 })?;
129
130 let bin_path = format!("bin/{artifact_name}");
131 zip_guard::read_zip_entry_capped(&mut archive, &bin_path, max_bytes).map_err(|err| {
132 RuntimeError::ArtifactArchive {
133 name: artifact_name.to_string(),
134 version: artifact_version.to_string(),
135 message: format!(
136 "failed to read native binary at '{bin_path}' from archive (ensure the binary \
137 is at bin/{artifact_name} inside the .mur.zip): {err}"
138 ),
139 }
140 })
141}
142
143pub fn extract_skill_md(
149 artifact_name: &str,
150 artifact_version: &str,
151 artifact_bytes: &[u8],
152) -> Result<Vec<u8>, RuntimeError> {
153 extract_skill_md_capped(
154 artifact_name,
155 artifact_version,
156 artifact_bytes,
157 zip_guard::max_artifact_decompressed_bytes(),
158 )
159}
160
161fn extract_skill_md_capped(
162 artifact_name: &str,
163 artifact_version: &str,
164 artifact_bytes: &[u8],
165 max_bytes: u64,
166) -> Result<Vec<u8>, RuntimeError> {
167 let cursor = std::io::Cursor::new(artifact_bytes);
168 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
169 name: artifact_name.to_string(),
170 version: artifact_version.to_string(),
171 message: err.to_string(),
172 })?;
173
174 zip_guard::read_zip_entry_capped(&mut archive, "skill.md", max_bytes).map_err(|err| {
175 RuntimeError::ArtifactArchive {
176 name: artifact_name.to_string(),
177 version: artifact_version.to_string(),
178 message: format!(
179 "skill.md not found or unreadable at archive root (ensure skill.md is at the \
180 root of the .mur.zip): {err}"
181 ),
182 }
183 })
184}
185
186pub fn extract_manifest_yaml(
187 artifact_name: &str,
188 artifact_version: &str,
189 artifact_bytes: &[u8],
190) -> Result<String, RuntimeError> {
191 extract_manifest_yaml_capped(
192 artifact_name,
193 artifact_version,
194 artifact_bytes,
195 zip_guard::max_artifact_decompressed_bytes(),
196 )
197}
198
199fn extract_manifest_yaml_capped(
200 artifact_name: &str,
201 artifact_version: &str,
202 artifact_bytes: &[u8],
203 max_bytes: u64,
204) -> Result<String, RuntimeError> {
205 let cursor = std::io::Cursor::new(artifact_bytes);
206 let mut archive = ZipArchive::new(cursor).map_err(|err| RuntimeError::ArtifactArchive {
207 name: artifact_name.to_string(),
208 version: artifact_version.to_string(),
209 message: err.to_string(),
210 })?;
211
212 zip_guard::read_zip_entry_to_string_capped(&mut archive, PACKED_MANIFEST_ENTRY, max_bytes)
213 .map_err(|err| RuntimeError::ArtifactArchive {
214 name: artifact_name.to_string(),
215 version: artifact_version.to_string(),
216 message: format!(
217 "missing or unreadable {PACKED_MANIFEST_ENTRY} in artifact archive: {err}"
218 ),
219 })
220}
221
222#[cfg(test)]
223mod tests {
224 use std::io::Write;
225
226 use zip::write::SimpleFileOptions;
227
228 use super::*;
229
230 fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
231 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
232 {
233 let mut zip = zip::ZipWriter::new(&mut cursor);
234 let options = SimpleFileOptions::default();
235 for (name, bytes) in files {
236 zip.start_file(*name, options).unwrap();
237 zip.write_all(bytes).unwrap();
238 }
239 zip.finish().unwrap();
240 }
241 cursor.into_inner()
242 }
243
244 #[test]
245 fn prefers_capsule_wasm_when_present() {
246 let archive = archive_with_files(&[
247 ("tool.wasm", b"tool"),
248 ("capsule.wasm", b"capsule"),
249 (PACKED_MANIFEST_ENTRY, b"name: demo"),
250 ]);
251
252 let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
253 assert_eq!(wasm, b"capsule");
254 }
255
256 #[test]
257 fn extracts_manifest_yaml_from_archive() {
258 let archive = archive_with_files(&[
259 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
260 ("tool.wasm", b"tool"),
261 ]);
262
263 let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
264 assert!(manifest.contains("name: demo"));
265 assert!(manifest.contains("version: 0.1.0"));
266 }
267
268 #[test]
269 fn rejects_path_traversal_entry_as_root_wasm_candidate() {
270 let archive = archive_with_files(&[
271 ("../../evil.wasm", b"evil"),
272 (PACKED_MANIFEST_ENTRY, b"name: demo"),
273 ]);
274
275 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
276 match err {
277 RuntimeError::ArtifactArchive { message, .. } => {
278 assert!(message.contains("missing root .wasm file"));
279 }
280 other => panic!("expected ArtifactArchive error, got {other:?}"),
281 }
282 }
283
284 #[test]
285 fn rejects_leading_slash_entry_as_root_wasm_candidate() {
286 let archive = archive_with_files(&[
287 ("/capsule.wasm", b"evil"),
288 (PACKED_MANIFEST_ENTRY, b"name: demo"),
289 ]);
290
291 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
292 match err {
293 RuntimeError::ArtifactArchive { message, .. } => {
294 assert!(message.contains("missing root .wasm file"));
295 }
296 other => panic!("expected ArtifactArchive error, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn extract_manifest_yaml_rejects_entry_past_ceiling() {
302 let big = vec![b'a'; 1024];
303 let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
304
305 let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
306 match err {
307 RuntimeError::ArtifactArchive { message, .. } => {
308 assert!(message.contains("decompression ceiling"));
309 }
310 other => panic!("expected ArtifactArchive error, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn extract_root_wasm_rejects_entry_past_ceiling() {
316 let big = vec![b'a'; 1024];
317 let archive = archive_with_files(&[("capsule.wasm", &big)]);
318
319 let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
320 match err {
321 RuntimeError::ArtifactArchive { message, .. } => {
322 assert!(message.contains("decompression ceiling"));
323 }
324 other => panic!("expected ArtifactArchive error, got {other:?}"),
325 }
326 }
327
328 #[test]
329 fn extract_native_binary_rejects_entry_past_ceiling() {
330 let big = vec![b'a'; 1024];
331 let archive = archive_with_files(&[("bin/demo", &big)]);
332
333 let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
334 match err {
335 RuntimeError::ArtifactArchive { message, .. } => {
336 assert!(message.contains("decompression ceiling"));
337 }
338 other => panic!("expected ArtifactArchive error, got {other:?}"),
339 }
340 }
341
342 #[test]
343 fn extract_skill_md_rejects_entry_past_ceiling() {
344 let big = vec![b'a'; 1024];
345 let archive = archive_with_files(&[("skill.md", &big)]);
346
347 let err = extract_skill_md_capped("demo", "0.0.1", &archive, 16).unwrap_err();
348 match err {
349 RuntimeError::ArtifactArchive { message, .. } => {
350 assert!(message.contains("decompression ceiling"));
351 }
352 other => panic!("expected ArtifactArchive error, got {other:?}"),
353 }
354 }
355
356 #[test]
357 fn default_ceiling_does_not_reject_small_fixtures() {
358 let archive = archive_with_files(&[
359 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
360 ("capsule.wasm", b"small wasm bytes"),
361 ]);
362
363 extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
364 extract_root_wasm("demo", "0.1.0", &archive).unwrap();
365 }
366}