Skip to main content

capsule_runtime/
artifact.rs

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    // Which root entry counts as the wasm payload is the shared payload-shape contract; the
37    // selector (and its error text) lives in murmur_artifact::payload_shape.
38    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
54/// Extract the native binary from `bin/<artifact_name>` inside a `.mur.zip`.
55///
56/// Canonical native artifact layout:
57///   murmur.yaml            — artifact manifest at zip root
58///   bin/<artifact_name>    — compiled binary with executable permissions
59pub 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
98/// Extract `skill.md` from a skill artifact's `.mur.zip`.
99///
100/// Canonical skill artifact layout:
101///   murmur.yaml      — artifact manifest at zip root
102///   skill.md         — guidance content at zip root (primary payload)
103pub 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}