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
177#[cfg(test)]
178mod tests {
179 use std::io::Write;
180
181 use zip::write::SimpleFileOptions;
182
183 use super::*;
184
185 fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
186 let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
187 {
188 let mut zip = zip::ZipWriter::new(&mut cursor);
189 let options = SimpleFileOptions::default();
190 for (name, bytes) in files {
191 zip.start_file(*name, options).unwrap();
192 zip.write_all(bytes).unwrap();
193 }
194 zip.finish().unwrap();
195 }
196 cursor.into_inner()
197 }
198
199 #[test]
200 fn prefers_capsule_wasm_when_present() {
201 let archive = archive_with_files(&[
202 ("tool.wasm", b"tool"),
203 ("capsule.wasm", b"capsule"),
204 (PACKED_MANIFEST_ENTRY, b"name: demo"),
205 ]);
206
207 let wasm = extract_root_wasm("demo", "0.0.1", &archive).unwrap();
208 assert_eq!(wasm, b"capsule");
209 }
210
211 #[test]
212 fn extracts_manifest_yaml_from_archive() {
213 let archive = archive_with_files(&[
214 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
215 ("tool.wasm", b"tool"),
216 ]);
217
218 let manifest = extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
219 assert!(manifest.contains("name: demo"));
220 assert!(manifest.contains("version: 0.1.0"));
221 }
222
223 #[test]
224 fn rejects_path_traversal_entry_as_root_wasm_candidate() {
225 let archive = archive_with_files(&[
226 ("../../evil.wasm", b"evil"),
227 (PACKED_MANIFEST_ENTRY, b"name: demo"),
228 ]);
229
230 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
231 match err {
232 RuntimeError::ArtifactArchive { message, .. } => {
233 assert!(message.contains("missing root .wasm file"));
234 }
235 other => panic!("expected ArtifactArchive error, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn rejects_leading_slash_entry_as_root_wasm_candidate() {
241 let archive = archive_with_files(&[
242 ("/capsule.wasm", b"evil"),
243 (PACKED_MANIFEST_ENTRY, b"name: demo"),
244 ]);
245
246 let err = extract_root_wasm("demo", "0.0.1", &archive).unwrap_err();
247 match err {
248 RuntimeError::ArtifactArchive { message, .. } => {
249 assert!(message.contains("missing root .wasm file"));
250 }
251 other => panic!("expected ArtifactArchive error, got {other:?}"),
252 }
253 }
254
255 #[test]
256 fn extract_manifest_yaml_rejects_entry_past_ceiling() {
257 let big = vec![b'a'; 1024];
258 let archive = archive_with_files(&[(PACKED_MANIFEST_ENTRY, &big)]);
259
260 let err = extract_manifest_yaml_capped("demo", "0.0.1", &archive, 16).unwrap_err();
261 match err {
262 RuntimeError::ArtifactArchive { message, .. } => {
263 assert!(message.contains("decompression ceiling"));
264 }
265 other => panic!("expected ArtifactArchive error, got {other:?}"),
266 }
267 }
268
269 #[test]
270 fn extract_root_wasm_rejects_entry_past_ceiling() {
271 let big = vec![b'a'; 1024];
272 let archive = archive_with_files(&[("capsule.wasm", &big)]);
273
274 let err = extract_root_wasm_capped("demo", "0.0.1", &archive, 16).unwrap_err();
275 match err {
276 RuntimeError::ArtifactArchive { message, .. } => {
277 assert!(message.contains("decompression ceiling"));
278 }
279 other => panic!("expected ArtifactArchive error, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn extract_native_binary_rejects_entry_past_ceiling() {
285 let big = vec![b'a'; 1024];
286 let archive = archive_with_files(&[("bin/demo", &big)]);
287
288 let err = extract_native_binary_capped("demo", "0.0.1", &archive, 16).unwrap_err();
289 match err {
290 RuntimeError::ArtifactArchive { message, .. } => {
291 assert!(message.contains("decompression ceiling"));
292 }
293 other => panic!("expected ArtifactArchive error, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn extract_skill_md_rejects_entry_past_ceiling() {
299 let big = vec![b'a'; 1024];
300 let archive = archive_with_files(&[("skill.md", &big)]);
301
302 let err = extract_skill_md_capped("demo", "0.0.1", &archive, 16).unwrap_err();
303 match err {
304 RuntimeError::ArtifactArchive { message, .. } => {
305 assert!(message.contains("decompression ceiling"));
306 }
307 other => panic!("expected ArtifactArchive error, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn default_ceiling_does_not_reject_small_fixtures() {
313 let archive = archive_with_files(&[
314 (PACKED_MANIFEST_ENTRY, b"name: demo\nversion: 0.1.0\n"),
315 ("capsule.wasm", b"small wasm bytes"),
316 ]);
317
318 extract_manifest_yaml("demo", "0.1.0", &archive).unwrap();
319 extract_root_wasm("demo", "0.1.0", &archive).unwrap();
320 }
321}