Skip to main content

capsule_runtime/
artifact.rs

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        // Root-wasm candidates must be a single top-level path component: reject entries
47        // with an unsafe path (e.g. a `..` component) and entries nested in a subdirectory
48        // or carrying a leading '/' (both of which sanitize_entry_path would otherwise
49        // normalize away, but which must never be treated as "the root wasm file").
50        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
99/// Extract the native binary from `bin/<artifact_name>` inside a `.mur.zip`.
100///
101/// Canonical native artifact layout:
102///   murmur.yaml            — artifact manifest at zip root
103///   bin/<artifact_name>    — compiled binary with executable permissions
104pub 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
143/// Extract `skill.md` from a skill artifact's `.mur.zip`.
144///
145/// Canonical skill artifact layout:
146///   murmur.yaml      — artifact manifest at zip root
147///   skill.md         — guidance content at zip root (primary payload)
148pub 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}