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