Skip to main content

shape_runtime/stdlib/
file.rs

1//! Native `file` module for high-level filesystem operations.
2//!
3//! Exports: file.read_text, file.write_text, file.read_lines, file.append,
4//!          file.read_bytes, file.write_bytes
5//!
6//! All operations go through [`FileSystemProvider`] so that sandbox/VFS modes
7//! work transparently. The default provider is [`RealFileSystem`].
8//!
9//! Policy gated: read ops require FsRead, write ops require FsWrite.
10
11use crate::module_exports::{ModuleContext, ModuleExports, ModuleFunction, ModuleParam};
12use crate::stdlib::runtime_policy::{FileSystemProvider, RealFileSystem};
13use shape_value::ValueWord;
14use std::path::Path;
15use std::sync::Arc;
16
17/// Create a file module that uses the given filesystem provider.
18/// The default `create_file_module()` uses [`RealFileSystem`]; callers can
19/// substitute a `PolicyEnforcedFs` or `VirtualFileSystem` for sandboxing.
20pub fn create_file_module_with_provider(fs: Arc<dyn FileSystemProvider>) -> ModuleExports {
21    let mut module = ModuleExports::new("file");
22    module.description = "High-level filesystem operations".to_string();
23
24    // file.read_text(path: string) -> Result<string>
25    {
26        let fs = Arc::clone(&fs);
27        module.add_function_with_schema(
28            "read_text",
29            move |args: &[ValueWord], _ctx: &ModuleContext| {
30                let path_str = args
31                    .first()
32                    .and_then(|a| a.as_str())
33                    .ok_or_else(|| "file.read_text() requires a path string".to_string())?;
34
35                let bytes = fs
36                    .read(Path::new(path_str))
37                    .map_err(|e| format!("file.read_text() failed: {}", e))?;
38
39                let text = String::from_utf8(bytes)
40                    .map_err(|e| format!("file.read_text() invalid UTF-8: {}", e))?;
41
42                Ok(ValueWord::from_ok(ValueWord::from_string(Arc::new(text))))
43            },
44            ModuleFunction {
45                description: "Read the entire contents of a file as a UTF-8 string".to_string(),
46                params: vec![ModuleParam {
47                    name: "path".to_string(),
48                    type_name: "string".to_string(),
49                    required: true,
50                    description: "Path to the file".to_string(),
51                    ..Default::default()
52                }],
53                return_type: Some("Result<string>".to_string()),
54            },
55        );
56    }
57
58    // file.write_text(path: string, content: string) -> Result<unit>
59    {
60        let fs = Arc::clone(&fs);
61        module.add_function_with_schema(
62            "write_text",
63            move |args: &[ValueWord], _ctx: &ModuleContext| {
64                let path_str = args
65                    .first()
66                    .and_then(|a| a.as_str())
67                    .ok_or_else(|| "file.write_text() requires a path string".to_string())?;
68
69                let content = args
70                    .get(1)
71                    .and_then(|a| a.as_str())
72                    .ok_or_else(|| "file.write_text() requires a content string".to_string())?;
73
74                fs.write(Path::new(path_str), content.as_bytes())
75                    .map_err(|e| format!("file.write_text() failed: {}", e))?;
76
77                Ok(ValueWord::from_ok(ValueWord::unit()))
78            },
79            ModuleFunction {
80                description: "Write a string to a file, creating or truncating it".to_string(),
81                params: vec![
82                    ModuleParam {
83                        name: "path".to_string(),
84                        type_name: "string".to_string(),
85                        required: true,
86                        description: "Path to the file".to_string(),
87                        ..Default::default()
88                    },
89                    ModuleParam {
90                        name: "content".to_string(),
91                        type_name: "string".to_string(),
92                        required: true,
93                        description: "Text content to write".to_string(),
94                        ..Default::default()
95                    },
96                ],
97                return_type: Some("Result<unit>".to_string()),
98            },
99        );
100    }
101
102    // file.read_lines(path: string) -> Result<Array<string>>
103    {
104        let fs = Arc::clone(&fs);
105        module.add_function_with_schema(
106            "read_lines",
107            move |args: &[ValueWord], _ctx: &ModuleContext| {
108                let path_str = args
109                    .first()
110                    .and_then(|a| a.as_str())
111                    .ok_or_else(|| "file.read_lines() requires a path string".to_string())?;
112
113                let bytes = fs
114                    .read(Path::new(path_str))
115                    .map_err(|e| format!("file.read_lines() failed: {}", e))?;
116
117                let text = String::from_utf8(bytes)
118                    .map_err(|e| format!("file.read_lines() invalid UTF-8: {}", e))?;
119
120                let lines: Vec<ValueWord> = text
121                    .lines()
122                    .map(|l| ValueWord::from_string(Arc::new(l.to_string())))
123                    .collect();
124
125                Ok(ValueWord::from_ok(ValueWord::from_array(Arc::new(lines))))
126            },
127            ModuleFunction {
128                description: "Read a file and return its lines as an array of strings".to_string(),
129                params: vec![ModuleParam {
130                    name: "path".to_string(),
131                    type_name: "string".to_string(),
132                    required: true,
133                    description: "Path to the file".to_string(),
134                    ..Default::default()
135                }],
136                return_type: Some("Result<Array<string>>".to_string()),
137            },
138        );
139    }
140
141    // file.append(path: string, content: string) -> Result<unit>
142    {
143        let fs = Arc::clone(&fs);
144        module.add_function_with_schema(
145            "append",
146            move |args: &[ValueWord], _ctx: &ModuleContext| {
147                let path_str = args
148                    .first()
149                    .and_then(|a| a.as_str())
150                    .ok_or_else(|| "file.append() requires a path string".to_string())?;
151
152                let content = args
153                    .get(1)
154                    .and_then(|a| a.as_str())
155                    .ok_or_else(|| "file.append() requires a content string".to_string())?;
156
157                fs.append(Path::new(path_str), content.as_bytes())
158                    .map_err(|e| format!("file.append() failed: {}", e))?;
159
160                Ok(ValueWord::from_ok(ValueWord::unit()))
161            },
162            ModuleFunction {
163                description: "Append a string to a file, creating it if it does not exist"
164                    .to_string(),
165                params: vec![
166                    ModuleParam {
167                        name: "path".to_string(),
168                        type_name: "string".to_string(),
169                        required: true,
170                        description: "Path to the file".to_string(),
171                        ..Default::default()
172                    },
173                    ModuleParam {
174                        name: "content".to_string(),
175                        type_name: "string".to_string(),
176                        required: true,
177                        description: "Text content to append".to_string(),
178                        ..Default::default()
179                    },
180                ],
181                return_type: Some("Result<unit>".to_string()),
182            },
183        );
184    }
185
186    // file.read_bytes(path: string) -> Result<Array<number>>
187    {
188        let fs = Arc::clone(&fs);
189        module.add_function_with_schema(
190            "read_bytes",
191            move |args: &[ValueWord], _ctx: &ModuleContext| {
192                let path_str = args
193                    .first()
194                    .and_then(|a| a.as_str())
195                    .ok_or_else(|| "file.read_bytes() requires a path string".to_string())?;
196
197                let bytes = fs
198                    .read(Path::new(path_str))
199                    .map_err(|e| format!("file.read_bytes() failed: {}", e))?;
200
201                let arr: Vec<ValueWord> = bytes
202                    .iter()
203                    .map(|&b| ValueWord::from_f64(b as f64))
204                    .collect();
205
206                Ok(ValueWord::from_ok(ValueWord::from_array(Arc::new(arr))))
207            },
208            ModuleFunction {
209                description: "Read the entire contents of a file as an array of byte values"
210                    .to_string(),
211                params: vec![ModuleParam {
212                    name: "path".to_string(),
213                    type_name: "string".to_string(),
214                    required: true,
215                    description: "Path to the file".to_string(),
216                    ..Default::default()
217                }],
218                return_type: Some("Result<Array<number>>".to_string()),
219            },
220        );
221    }
222
223    // file.write_bytes(path: string, data: Array<number>) -> Result<unit>
224    {
225        let fs = Arc::clone(&fs);
226        module.add_function_with_schema(
227            "write_bytes",
228            move |args: &[ValueWord], _ctx: &ModuleContext| {
229                let path_str = args
230                    .first()
231                    .and_then(|a| a.as_str())
232                    .ok_or_else(|| "file.write_bytes() requires a path string".to_string())?;
233
234                let arr = args
235                    .get(1)
236                    .and_then(|a| a.as_any_array())
237                    .ok_or_else(|| "file.write_bytes() requires a data array".to_string())?
238                    .to_generic();
239
240                let bytes: Vec<u8> = arr
241                    .iter()
242                    .enumerate()
243                    .map(|(i, nb)| {
244                        let n = nb.as_number_coerce().ok_or_else(|| {
245                            format!("file.write_bytes() element {} is not a number", i)
246                        })?;
247                        if n < 0.0 || n > 255.0 || n.fract() != 0.0 {
248                            return Err(format!(
249                                "file.write_bytes() element {} is not a valid byte (0-255): {}",
250                                i, n
251                            ));
252                        }
253                        Ok(n as u8)
254                    })
255                    .collect::<Result<Vec<u8>, String>>()?;
256
257                fs.write(Path::new(path_str), &bytes)
258                    .map_err(|e| format!("file.write_bytes() failed: {}", e))?;
259
260                Ok(ValueWord::from_ok(ValueWord::unit()))
261            },
262            ModuleFunction {
263                description: "Write an array of byte values to a file".to_string(),
264                params: vec![
265                    ModuleParam {
266                        name: "path".to_string(),
267                        type_name: "string".to_string(),
268                        required: true,
269                        description: "Path to the file".to_string(),
270                        ..Default::default()
271                    },
272                    ModuleParam {
273                        name: "data".to_string(),
274                        type_name: "Array<number>".to_string(),
275                        required: true,
276                        description: "Array of byte values (0-255)".to_string(),
277                        ..Default::default()
278                    },
279                ],
280                return_type: Some("Result<unit>".to_string()),
281            },
282        );
283    }
284
285    module
286}
287
288/// Create the `file` module using the default real filesystem.
289pub fn create_file_module() -> ModuleExports {
290    create_file_module_with_provider(Arc::new(RealFileSystem))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
298        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
299        crate::module_exports::ModuleContext {
300            schemas: registry,
301            invoke_callable: None,
302            raw_invoker: None,
303            function_hashes: None,
304            vm_state: None,
305            granted_permissions: None,
306            scope_constraints: None,
307            set_pending_resume: None,
308            set_pending_frame_resume: None,
309        }
310    }
311
312    #[test]
313    fn test_file_module_creation() {
314        let module = create_file_module();
315        assert_eq!(module.name, "file");
316        assert!(module.has_export("read_text"));
317        assert!(module.has_export("write_text"));
318        assert!(module.has_export("read_lines"));
319        assert!(module.has_export("append"));
320        assert!(module.has_export("read_bytes"));
321        assert!(module.has_export("write_bytes"));
322    }
323
324    #[test]
325    fn test_file_read_write_roundtrip() {
326        let module = create_file_module();
327        let ctx = test_ctx();
328        let write_fn = module.get_export("write_text").unwrap();
329        let read_fn = module.get_export("read_text").unwrap();
330
331        let dir = tempfile::tempdir().unwrap();
332        let path = dir.path().join("test.txt");
333        let path_str = path.to_str().unwrap();
334
335        // Write
336        let result = write_fn(
337            &[
338                ValueWord::from_string(Arc::new(path_str.to_string())),
339                ValueWord::from_string(Arc::new("hello world".to_string())),
340            ],
341            &ctx,
342        )
343        .unwrap();
344        assert!(result.as_ok_inner().is_some());
345
346        // Read back
347        let result = read_fn(
348            &[ValueWord::from_string(Arc::new(path_str.to_string()))],
349            &ctx,
350        )
351        .unwrap();
352        let inner = result.as_ok_inner().expect("should be Ok");
353        assert_eq!(inner.as_str(), Some("hello world"));
354    }
355
356    #[test]
357    fn test_file_read_lines() {
358        let module = create_file_module();
359        let ctx = test_ctx();
360        let write_fn = module.get_export("write_text").unwrap();
361        let read_lines_fn = module.get_export("read_lines").unwrap();
362
363        let dir = tempfile::tempdir().unwrap();
364        let path = dir.path().join("lines.txt");
365        let path_str = path.to_str().unwrap();
366
367        write_fn(
368            &[
369                ValueWord::from_string(Arc::new(path_str.to_string())),
370                ValueWord::from_string(Arc::new("line1\nline2\nline3".to_string())),
371            ],
372            &ctx,
373        )
374        .unwrap();
375
376        let result = read_lines_fn(
377            &[ValueWord::from_string(Arc::new(path_str.to_string()))],
378            &ctx,
379        )
380        .unwrap();
381        let inner = result.as_ok_inner().expect("should be Ok");
382        let arr = inner.as_any_array().expect("should be array").to_generic();
383        assert_eq!(arr.len(), 3);
384        assert_eq!(arr[0].as_str(), Some("line1"));
385        assert_eq!(arr[1].as_str(), Some("line2"));
386        assert_eq!(arr[2].as_str(), Some("line3"));
387    }
388
389    #[test]
390    fn test_file_append() {
391        let module = create_file_module();
392        let ctx = test_ctx();
393        let write_fn = module.get_export("write_text").unwrap();
394        let append_fn = module.get_export("append").unwrap();
395        let read_fn = module.get_export("read_text").unwrap();
396
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("append.txt");
399        let path_str = path.to_str().unwrap();
400
401        write_fn(
402            &[
403                ValueWord::from_string(Arc::new(path_str.to_string())),
404                ValueWord::from_string(Arc::new("hello".to_string())),
405            ],
406            &ctx,
407        )
408        .unwrap();
409
410        append_fn(
411            &[
412                ValueWord::from_string(Arc::new(path_str.to_string())),
413                ValueWord::from_string(Arc::new(" world".to_string())),
414            ],
415            &ctx,
416        )
417        .unwrap();
418
419        let result = read_fn(
420            &[ValueWord::from_string(Arc::new(path_str.to_string()))],
421            &ctx,
422        )
423        .unwrap();
424        let inner = result.as_ok_inner().expect("should be Ok");
425        assert_eq!(inner.as_str(), Some("hello world"));
426    }
427
428    #[test]
429    fn test_file_read_bytes_write_bytes_roundtrip() {
430        let module = create_file_module();
431        let ctx = test_ctx();
432        let write_fn = module.get_export("write_bytes").unwrap();
433        let read_fn = module.get_export("read_bytes").unwrap();
434
435        let dir = tempfile::tempdir().unwrap();
436        let path = dir.path().join("bytes.bin");
437        let path_str = path.to_str().unwrap();
438
439        let data = ValueWord::from_array(Arc::new(vec![
440            ValueWord::from_f64(0.0),
441            ValueWord::from_f64(127.0),
442            ValueWord::from_f64(255.0),
443        ]));
444
445        write_fn(
446            &[ValueWord::from_string(Arc::new(path_str.to_string())), data],
447            &ctx,
448        )
449        .unwrap();
450
451        let result = read_fn(
452            &[ValueWord::from_string(Arc::new(path_str.to_string()))],
453            &ctx,
454        )
455        .unwrap();
456        let inner = result.as_ok_inner().expect("should be Ok");
457        let arr = inner.as_any_array().expect("should be array").to_generic();
458        assert_eq!(arr.len(), 3);
459        assert_eq!(arr[0].as_f64(), Some(0.0));
460        assert_eq!(arr[1].as_f64(), Some(127.0));
461        assert_eq!(arr[2].as_f64(), Some(255.0));
462    }
463
464    #[test]
465    fn test_file_write_bytes_validates_range() {
466        let module = create_file_module();
467        let ctx = test_ctx();
468        let write_fn = module.get_export("write_bytes").unwrap();
469
470        let dir = tempfile::tempdir().unwrap();
471        let path = dir.path().join("bad.bin");
472        let path_str = path.to_str().unwrap();
473
474        // 256 is out of range
475        let data = ValueWord::from_array(Arc::new(vec![ValueWord::from_f64(256.0)]));
476        let result = write_fn(
477            &[ValueWord::from_string(Arc::new(path_str.to_string())), data],
478            &ctx,
479        );
480        assert!(result.is_err());
481
482        // Negative is out of range
483        let data = ValueWord::from_array(Arc::new(vec![ValueWord::from_f64(-1.0)]));
484        let result = write_fn(
485            &[ValueWord::from_string(Arc::new(path_str.to_string())), data],
486            &ctx,
487        );
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn test_file_read_nonexistent() {
493        let module = create_file_module();
494        let ctx = test_ctx();
495        let read_fn = module.get_export("read_text").unwrap();
496        let result = read_fn(
497            &[ValueWord::from_string(Arc::new(
498                "/nonexistent/path/file.txt".to_string(),
499            ))],
500            &ctx,
501        );
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_file_requires_string_args() {
507        let module = create_file_module();
508        let ctx = test_ctx();
509        let read_fn = module.get_export("read_text").unwrap();
510        assert!(read_fn(&[ValueWord::from_f64(42.0)], &ctx).is_err());
511        assert!(read_fn(&[], &ctx).is_err());
512    }
513
514    #[test]
515    fn test_file_schemas() {
516        let module = create_file_module();
517
518        let read_schema = module.get_schema("read_text").unwrap();
519        assert_eq!(read_schema.params.len(), 1);
520        assert_eq!(read_schema.return_type.as_deref(), Some("Result<string>"));
521
522        let write_schema = module.get_schema("write_text").unwrap();
523        assert_eq!(write_schema.params.len(), 2);
524
525        let read_bytes_schema = module.get_schema("read_bytes").unwrap();
526        assert_eq!(
527            read_bytes_schema.return_type.as_deref(),
528            Some("Result<Array<number>>")
529        );
530
531        let write_bytes_schema = module.get_schema("write_bytes").unwrap();
532        assert_eq!(write_bytes_schema.params.len(), 2);
533        assert_eq!(write_bytes_schema.params[1].type_name, "Array<number>");
534    }
535}