Skip to main content

shape_runtime/stdlib/
archive.rs

1//! Native `archive` module for creating and extracting zip/tar archives.
2//!
3//! Exports: archive.zip_create, archive.zip_extract, archive.tar_create, archive.tar_extract
4
5use crate::module_exports::{ModuleContext, ModuleExports, ModuleFunction, ModuleParam};
6use shape_value::ValueWord;
7use shape_value::heap_value::HeapValue;
8use std::sync::Arc;
9use super::byte_utils::{bytes_from_array, bytes_to_array};
10
11/// Extract entries from an Array of {name: string, data: string} objects.
12/// Supports both TypedObject and HashMap representations.
13fn extract_entries(val: &ValueWord) -> Result<Vec<(String, String)>, String> {
14    let arr = val
15        .as_any_array()
16        .ok_or_else(|| "expected an Array of entry objects".to_string())?
17        .to_generic();
18
19    let mut entries = Vec::with_capacity(arr.len());
20    for (i, item) in arr.iter().enumerate() {
21        let (name, data) =
22            extract_entry_fields(item).map_err(|e| format!("entry [{}]: {}", i, e))?;
23        entries.push((name, data));
24    }
25    Ok(entries)
26}
27
28/// Extract `name` and `data` fields from a single entry (TypedObject or HashMap).
29fn extract_entry_fields(val: &ValueWord) -> Result<(String, String), String> {
30    // Try TypedObject first
31    if let Some(HeapValue::TypedObject {
32        slots, heap_mask, ..
33    }) = val.as_heap_ref()
34    {
35        // Convention: slot 0 = name, slot 1 = data (both heap/string)
36        if slots.len() >= 2 {
37            let name_nb = if heap_mask & 1 != 0 {
38                slots[0].as_heap_nb()
39            } else {
40                unsafe { ValueWord::clone_from_bits(slots[0].raw()) }
41            };
42            let data_nb = if heap_mask & 2 != 0 {
43                slots[1].as_heap_nb()
44            } else {
45                unsafe { ValueWord::clone_from_bits(slots[1].raw()) }
46            };
47            if let (Some(name), Some(data)) = (name_nb.as_str(), data_nb.as_str()) {
48                return Ok((name.to_string(), data.to_string()));
49            }
50        }
51    }
52
53    // Try HashMap
54    if let Some((keys, values, _)) = val.as_hashmap() {
55        let mut name = None;
56        let mut data = None;
57        for (k, v) in keys.iter().zip(values.iter()) {
58            if let Some(key_str) = k.as_str() {
59                match key_str {
60                    "name" => name = v.as_str().map(|s| s.to_string()),
61                    "data" => data = v.as_str().map(|s| s.to_string()),
62                    _ => {}
63                }
64            }
65        }
66        if let (Some(n), Some(d)) = (name, data) {
67            return Ok((n, d));
68        }
69    }
70
71    Err("entry must have 'name' (string) and 'data' (string) fields".to_string())
72}
73
74/// Build an entry object as a HashMap with `name` and `data` keys.
75fn make_entry(name: &str, data: &str) -> ValueWord {
76    let keys = vec![
77        ValueWord::from_string(Arc::new("name".to_string())),
78        ValueWord::from_string(Arc::new("data".to_string())),
79    ];
80    let values = vec![
81        ValueWord::from_string(Arc::new(name.to_string())),
82        ValueWord::from_string(Arc::new(data.to_string())),
83    ];
84    ValueWord::from_hashmap_pairs(keys, values)
85}
86
87/// Create the `archive` module with zip/tar creation and extraction functions.
88pub fn create_archive_module() -> ModuleExports {
89    let mut module = ModuleExports::new("std::core::archive");
90    module.description = "Archive creation and extraction (zip, tar)".to_string();
91
92    // archive.zip_create(entries: Array<{name: string, data: string}>) -> Array<int>
93    module.add_function_with_schema(
94        "zip_create",
95        |args: &[ValueWord], _ctx: &ModuleContext| {
96            use std::io::{Cursor, Write};
97
98            let entries_val = args
99                .first()
100                .ok_or_else(|| "archive.zip_create() requires an entries array".to_string())?;
101            let entries =
102                extract_entries(entries_val).map_err(|e| format!("archive.zip_create(): {}", e))?;
103
104            let buf = Cursor::new(Vec::new());
105            let mut zip_writer = zip::ZipWriter::new(buf);
106
107            let options = zip::write::SimpleFileOptions::default()
108                .compression_method(zip::CompressionMethod::Deflated);
109
110            for (name, data) in &entries {
111                zip_writer.start_file(name.as_str(), options).map_err(|e| {
112                    format!(
113                        "archive.zip_create() failed to start file '{}': {}",
114                        name, e
115                    )
116                })?;
117                zip_writer.write_all(data.as_bytes()).map_err(|e| {
118                    format!("archive.zip_create() failed to write '{}': {}", name, e)
119                })?;
120            }
121
122            let cursor = zip_writer
123                .finish()
124                .map_err(|e| format!("archive.zip_create() failed to finish: {}", e))?;
125
126            Ok(bytes_to_array(&cursor.into_inner()))
127        },
128        ModuleFunction {
129            description: "Create a zip archive in memory from an array of entries".to_string(),
130            params: vec![ModuleParam {
131                name: "entries".to_string(),
132                type_name: "Array<{name: string, data: string}>".to_string(),
133                required: true,
134                description: "Array of objects with 'name' and 'data' fields".to_string(),
135                ..Default::default()
136            }],
137            return_type: Some("Array<int>".to_string()),
138        },
139    );
140
141    // archive.zip_extract(data: Array<int>) -> Array<{name: string, data: string}>
142    module.add_function_with_schema(
143        "zip_extract",
144        |args: &[ValueWord], _ctx: &ModuleContext| {
145            use std::io::{Cursor, Read};
146
147            let input = args.first().ok_or_else(|| {
148                "archive.zip_extract() requires an Array<int> argument".to_string()
149            })?;
150            let bytes =
151                bytes_from_array(input).map_err(|e| format!("archive.zip_extract(): {}", e))?;
152
153            let cursor = Cursor::new(bytes);
154            let mut archive = zip::ZipArchive::new(cursor)
155                .map_err(|e| format!("archive.zip_extract() invalid zip: {}", e))?;
156
157            let mut entries = Vec::new();
158            for i in 0..archive.len() {
159                let mut file = archive.by_index(i).map_err(|e| {
160                    format!("archive.zip_extract() failed to read entry {}: {}", i, e)
161                })?;
162
163                if file.is_dir() {
164                    continue;
165                }
166
167                let name = file.name().to_string();
168                let mut contents = String::new();
169                file.read_to_string(&mut contents).map_err(|e| {
170                    format!("archive.zip_extract() failed to read '{}': {}", name, e)
171                })?;
172
173                entries.push(make_entry(&name, &contents));
174            }
175
176            Ok(ValueWord::from_array(Arc::new(entries)))
177        },
178        ModuleFunction {
179            description: "Extract a zip archive from a byte array into an array of entries"
180                .to_string(),
181            params: vec![ModuleParam {
182                name: "data".to_string(),
183                type_name: "Array<int>".to_string(),
184                required: true,
185                description: "Zip archive as byte array".to_string(),
186                ..Default::default()
187            }],
188            return_type: Some("Array<{name: string, data: string}>".to_string()),
189        },
190    );
191
192    // archive.tar_create(entries: Array<{name: string, data: string}>) -> Array<int>
193    module.add_function_with_schema(
194        "tar_create",
195        |args: &[ValueWord], _ctx: &ModuleContext| {
196            let entries_val = args
197                .first()
198                .ok_or_else(|| "archive.tar_create() requires an entries array".to_string())?;
199            let entries =
200                extract_entries(entries_val).map_err(|e| format!("archive.tar_create(): {}", e))?;
201
202            let mut builder = tar::Builder::new(Vec::new());
203
204            for (name, data) in &entries {
205                let data_bytes = data.as_bytes();
206                let mut header = tar::Header::new_gnu();
207                header.set_size(data_bytes.len() as u64);
208                header.set_mode(0o644);
209                header.set_cksum();
210
211                builder
212                    .append_data(&mut header, name.as_str(), data_bytes)
213                    .map_err(|e| format!("archive.tar_create() failed for '{}': {}", name, e))?;
214            }
215
216            let tar_bytes = builder
217                .into_inner()
218                .map_err(|e| format!("archive.tar_create() failed to finish: {}", e))?;
219
220            Ok(bytes_to_array(&tar_bytes))
221        },
222        ModuleFunction {
223            description: "Create a tar archive in memory from an array of entries".to_string(),
224            params: vec![ModuleParam {
225                name: "entries".to_string(),
226                type_name: "Array<{name: string, data: string}>".to_string(),
227                required: true,
228                description: "Array of objects with 'name' and 'data' fields".to_string(),
229                ..Default::default()
230            }],
231            return_type: Some("Array<int>".to_string()),
232        },
233    );
234
235    // archive.tar_extract(data: Array<int>) -> Array<{name: string, data: string}>
236    module.add_function_with_schema(
237        "tar_extract",
238        |args: &[ValueWord], _ctx: &ModuleContext| {
239            use std::io::{Cursor, Read};
240
241            let input = args.first().ok_or_else(|| {
242                "archive.tar_extract() requires an Array<int> argument".to_string()
243            })?;
244            let bytes =
245                bytes_from_array(input).map_err(|e| format!("archive.tar_extract(): {}", e))?;
246
247            let cursor = Cursor::new(bytes);
248            let mut archive = tar::Archive::new(cursor);
249
250            let mut entries = Vec::new();
251            for entry_result in archive
252                .entries()
253                .map_err(|e| format!("archive.tar_extract() invalid tar: {}", e))?
254            {
255                let mut entry = entry_result
256                    .map_err(|e| format!("archive.tar_extract() failed to read entry: {}", e))?;
257
258                // Skip directories
259                if entry.header().entry_type().is_dir() {
260                    continue;
261                }
262
263                let name = entry
264                    .path()
265                    .map_err(|e| format!("archive.tar_extract() invalid path: {}", e))?
266                    .to_string_lossy()
267                    .to_string();
268
269                let mut contents = String::new();
270                entry.read_to_string(&mut contents).map_err(|e| {
271                    format!("archive.tar_extract() failed to read '{}': {}", name, e)
272                })?;
273
274                entries.push(make_entry(&name, &contents));
275            }
276
277            Ok(ValueWord::from_array(Arc::new(entries)))
278        },
279        ModuleFunction {
280            description: "Extract a tar archive from a byte array into an array of entries"
281                .to_string(),
282            params: vec![ModuleParam {
283                name: "data".to_string(),
284                type_name: "Array<int>".to_string(),
285                required: true,
286                description: "Tar archive as byte array".to_string(),
287                ..Default::default()
288            }],
289            return_type: Some("Array<{name: string, data: string}>".to_string()),
290        },
291    );
292
293    module
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
301        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
302        crate::module_exports::ModuleContext {
303            schemas: registry,
304            invoke_callable: None,
305            raw_invoker: None,
306            function_hashes: None,
307            vm_state: None,
308            granted_permissions: None,
309            scope_constraints: None,
310            set_pending_resume: None,
311            set_pending_frame_resume: None,
312        }
313    }
314
315    fn make_test_entries() -> ValueWord {
316        let entries = vec![
317            make_entry("hello.txt", "Hello, World!"),
318            make_entry("data/numbers.txt", "1 2 3 4 5"),
319        ];
320        ValueWord::from_array(Arc::new(entries))
321    }
322
323    #[test]
324    fn test_archive_module_creation() {
325        let module = create_archive_module();
326        assert_eq!(module.name, "std::core::archive");
327        assert!(module.has_export("zip_create"));
328        assert!(module.has_export("zip_extract"));
329        assert!(module.has_export("tar_create"));
330        assert!(module.has_export("tar_extract"));
331    }
332
333    #[test]
334    fn test_zip_roundtrip() {
335        let module = create_archive_module();
336        let ctx = test_ctx();
337        let zip_create_fn = module.get_export("zip_create").unwrap();
338        let zip_extract_fn = module.get_export("zip_extract").unwrap();
339
340        let entries = make_test_entries();
341        let zip_bytes = zip_create_fn(&[entries], &ctx).unwrap();
342
343        // Should be a byte array
344        assert!(zip_bytes.as_any_array().is_some());
345
346        let extracted = zip_extract_fn(&[zip_bytes], &ctx).unwrap();
347        let arr = extracted.as_any_array().unwrap().to_generic();
348        assert_eq!(arr.len(), 2);
349
350        // Check first entry
351        let (name0, data0) = extract_entry_fields(&arr[0]).unwrap();
352        assert_eq!(name0, "hello.txt");
353        assert_eq!(data0, "Hello, World!");
354
355        // Check second entry
356        let (name1, data1) = extract_entry_fields(&arr[1]).unwrap();
357        assert_eq!(name1, "data/numbers.txt");
358        assert_eq!(data1, "1 2 3 4 5");
359    }
360
361    #[test]
362    fn test_tar_roundtrip() {
363        let module = create_archive_module();
364        let ctx = test_ctx();
365        let tar_create_fn = module.get_export("tar_create").unwrap();
366        let tar_extract_fn = module.get_export("tar_extract").unwrap();
367
368        let entries = make_test_entries();
369        let tar_bytes = tar_create_fn(&[entries], &ctx).unwrap();
370
371        assert!(tar_bytes.as_any_array().is_some());
372
373        let extracted = tar_extract_fn(&[tar_bytes], &ctx).unwrap();
374        let arr = extracted.as_any_array().unwrap().to_generic();
375        assert_eq!(arr.len(), 2);
376
377        let (name0, data0) = extract_entry_fields(&arr[0]).unwrap();
378        assert_eq!(name0, "hello.txt");
379        assert_eq!(data0, "Hello, World!");
380
381        let (name1, data1) = extract_entry_fields(&arr[1]).unwrap();
382        assert_eq!(name1, "data/numbers.txt");
383        assert_eq!(data1, "1 2 3 4 5");
384    }
385
386    #[test]
387    fn test_zip_create_empty() {
388        let module = create_archive_module();
389        let ctx = test_ctx();
390        let zip_create_fn = module.get_export("zip_create").unwrap();
391        let zip_extract_fn = module.get_export("zip_extract").unwrap();
392
393        let empty = ValueWord::from_array(Arc::new(Vec::new()));
394        let zip_bytes = zip_create_fn(&[empty], &ctx).unwrap();
395
396        let extracted = zip_extract_fn(&[zip_bytes], &ctx).unwrap();
397        let arr = extracted.as_any_array().unwrap().to_generic();
398        assert_eq!(arr.len(), 0);
399    }
400
401    #[test]
402    fn test_tar_create_empty() {
403        let module = create_archive_module();
404        let ctx = test_ctx();
405        let tar_create_fn = module.get_export("tar_create").unwrap();
406        let tar_extract_fn = module.get_export("tar_extract").unwrap();
407
408        let empty = ValueWord::from_array(Arc::new(Vec::new()));
409        let tar_bytes = tar_create_fn(&[empty], &ctx).unwrap();
410
411        let extracted = tar_extract_fn(&[tar_bytes], &ctx).unwrap();
412        let arr = extracted.as_any_array().unwrap().to_generic();
413        assert_eq!(arr.len(), 0);
414    }
415
416    #[test]
417    fn test_zip_extract_invalid_data() {
418        let module = create_archive_module();
419        let ctx = test_ctx();
420        let zip_extract_fn = module.get_export("zip_extract").unwrap();
421
422        let bad_data = ValueWord::from_array(Arc::new(vec![
423            ValueWord::from_i64(1),
424            ValueWord::from_i64(2),
425        ]));
426        let result = zip_extract_fn(&[bad_data], &ctx);
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn test_tar_extract_invalid_data() {
432        let module = create_archive_module();
433        let ctx = test_ctx();
434        let tar_extract_fn = module.get_export("tar_extract").unwrap();
435
436        let bad_data = ValueWord::from_array(Arc::new(vec![
437            ValueWord::from_i64(1),
438            ValueWord::from_i64(2),
439        ]));
440        let result = tar_extract_fn(&[bad_data], &ctx);
441        // tar with just 2 bytes will likely result in empty entries (not enough for header)
442        // or an error — either is acceptable
443        if let Ok(val) = result {
444            let arr = val.as_any_array().unwrap().to_generic();
445            assert_eq!(arr.len(), 0);
446        }
447    }
448
449    #[test]
450    fn test_zip_create_requires_array() {
451        let module = create_archive_module();
452        let ctx = test_ctx();
453        let zip_create_fn = module.get_export("zip_create").unwrap();
454
455        let result = zip_create_fn(&[ValueWord::from_i64(42)], &ctx);
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_schemas() {
461        let module = create_archive_module();
462
463        let zip_create_schema = module.get_schema("zip_create").unwrap();
464        assert_eq!(zip_create_schema.params.len(), 1);
465        assert!(zip_create_schema.params[0].required);
466        assert_eq!(zip_create_schema.return_type.as_deref(), Some("Array<int>"));
467
468        let zip_extract_schema = module.get_schema("zip_extract").unwrap();
469        assert_eq!(
470            zip_extract_schema.return_type.as_deref(),
471            Some("Array<{name: string, data: string}>")
472        );
473
474        let tar_create_schema = module.get_schema("tar_create").unwrap();
475        assert_eq!(tar_create_schema.params.len(), 1);
476
477        let tar_extract_schema = module.get_schema("tar_extract").unwrap();
478        assert_eq!(
479            tar_extract_schema.return_type.as_deref(),
480            Some("Array<{name: string, data: string}>")
481        );
482    }
483
484    #[test]
485    fn test_zip_unicode_content() {
486        let module = create_archive_module();
487        let ctx = test_ctx();
488        let zip_create_fn = module.get_export("zip_create").unwrap();
489        let zip_extract_fn = module.get_export("zip_extract").unwrap();
490
491        let entries = vec![make_entry("unicode.txt", "Hello \u{1F600} World \u{00E9}")];
492        let input = ValueWord::from_array(Arc::new(entries));
493        let zip_bytes = zip_create_fn(&[input], &ctx).unwrap();
494
495        let extracted = zip_extract_fn(&[zip_bytes], &ctx).unwrap();
496        let arr = extracted.as_any_array().unwrap().to_generic();
497        let (_, data) = extract_entry_fields(&arr[0]).unwrap();
498        assert_eq!(data, "Hello \u{1F600} World \u{00E9}");
499    }
500}