Skip to main content

shape_runtime/stdlib/
virtual_fs.rs

1//! In-memory virtual filesystem for sandbox mode.
2//!
3//! All reads and writes operate against an in-memory store. The host can
4//! pre-seed read-only files before execution and extract written files after.
5//! No real disk I/O is performed.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::RwLock;
10
11use super::runtime_policy::{FileMetadata, FileSystemProvider, PathEntry};
12
13// ============================================================================
14// VFS Types
15// ============================================================================
16
17/// A single entry in the virtual filesystem.
18#[derive(Debug, Clone)]
19struct VfsEntry {
20    content: Vec<u8>,
21    is_dir: bool,
22}
23
24/// In-memory virtual filesystem implementing [`FileSystemProvider`].
25///
26/// Thread-safe via internal `RwLock`. Designed for sandbox mode where no real
27/// disk access is permitted.
28pub struct VirtualFilesystem {
29    files: RwLock<HashMap<PathBuf, VfsEntry>>,
30    read_only: RwLock<HashSet<PathBuf>>,
31    total_written: RwLock<usize>,
32    max_size: usize,
33}
34
35impl VirtualFilesystem {
36    /// Create a new empty VFS with the given maximum total size in bytes.
37    ///
38    /// `max_size` of `0` means unlimited.
39    pub fn new(max_size: usize) -> Self {
40        Self {
41            files: RwLock::new(HashMap::new()),
42            read_only: RwLock::new(HashSet::new()),
43            total_written: RwLock::new(0),
44            max_size,
45        }
46    }
47
48    /// Pre-seed a read-only file. Typically called by the host before
49    /// executing a sandboxed program (e.g., from `[sandbox.seed_files]`).
50    pub fn seed_file(&self, path: impl Into<PathBuf>, content: Vec<u8>) {
51        let path = path.into();
52        // Ensure parent directories exist.
53        self.ensure_parents(&path);
54        let mut files = self.files.write().unwrap();
55        files.insert(
56            path.clone(),
57            VfsEntry {
58                content,
59                is_dir: false,
60            },
61        );
62        self.read_only.write().unwrap().insert(path);
63    }
64
65    /// Seed a directory (empty). Automatically seeds all parent directories.
66    pub fn seed_dir(&self, path: impl Into<PathBuf>) {
67        let path = path.into();
68        self.ensure_parents(&path);
69        let mut files = self.files.write().unwrap();
70        files.insert(
71            path.clone(),
72            VfsEntry {
73                content: Vec::new(),
74                is_dir: true,
75            },
76        );
77        self.read_only.write().unwrap().insert(path);
78    }
79
80    /// Extract all files written by the sandboxed program (excludes seed files).
81    ///
82    /// Returns a map from path to file content.
83    pub fn extract_written_files(&self) -> HashMap<PathBuf, Vec<u8>> {
84        let files = self.files.read().unwrap();
85        let ro = self.read_only.read().unwrap();
86        files
87            .iter()
88            .filter(|(p, e)| !e.is_dir && !ro.contains(*p))
89            .map(|(p, e)| (p.clone(), e.content.clone()))
90            .collect()
91    }
92
93    /// Total bytes written by the sandboxed program (excludes seed files).
94    pub fn total_bytes_written(&self) -> usize {
95        *self.total_written.read().unwrap()
96    }
97
98    /// Ensure all ancestor directories of `path` exist as directory entries.
99    fn ensure_parents(&self, path: &Path) {
100        let mut files = self.files.write().unwrap();
101        for ancestor in path.ancestors().skip(1) {
102            if ancestor == Path::new("") || ancestor == Path::new("/") {
103                // Always implicitly exists.
104                continue;
105            }
106            files
107                .entry(ancestor.to_path_buf())
108                .or_insert_with(|| VfsEntry {
109                    content: Vec::new(),
110                    is_dir: true,
111                });
112        }
113    }
114
115    /// Check size limit, returning PermissionDenied if exceeded.
116    fn check_size_limit(&self, additional: usize) -> std::io::Result<()> {
117        if self.max_size == 0 {
118            return Ok(());
119        }
120        let current = *self.total_written.read().unwrap();
121        if current + additional > self.max_size {
122            return Err(std::io::Error::new(
123                std::io::ErrorKind::Other,
124                format!(
125                    "VFS size limit exceeded: {} + {} > {}",
126                    current, additional, self.max_size
127                ),
128            ));
129        }
130        Ok(())
131    }
132}
133
134impl FileSystemProvider for VirtualFilesystem {
135    fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
136        let files = self.files.read().unwrap();
137        match files.get(path) {
138            Some(entry) if !entry.is_dir => Ok(entry.content.clone()),
139            Some(_) => Err(std::io::Error::new(
140                std::io::ErrorKind::Other,
141                format!("{} is a directory", path.display()),
142            )),
143            None => Err(std::io::Error::new(
144                std::io::ErrorKind::NotFound,
145                format!("{} not found in VFS", path.display()),
146            )),
147        }
148    }
149
150    fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
151        if self.read_only.read().unwrap().contains(path) {
152            return Err(std::io::Error::new(
153                std::io::ErrorKind::PermissionDenied,
154                format!("{} is read-only (seed file)", path.display()),
155            ));
156        }
157        self.check_size_limit(data.len())?;
158        self.ensure_parents(path);
159        let mut files = self.files.write().unwrap();
160
161        // Subtract old size if overwriting a user-written file.
162        let old_size = files
163            .get(path)
164            .filter(|e| !e.is_dir)
165            .map(|e| e.content.len())
166            .unwrap_or(0);
167
168        files.insert(
169            path.to_path_buf(),
170            VfsEntry {
171                content: data.to_vec(),
172                is_dir: false,
173            },
174        );
175
176        let mut total = self.total_written.write().unwrap();
177        *total = total.saturating_sub(old_size) + data.len();
178        Ok(())
179    }
180
181    fn append(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
182        if self.read_only.read().unwrap().contains(path) {
183            return Err(std::io::Error::new(
184                std::io::ErrorKind::PermissionDenied,
185                format!("{} is read-only (seed file)", path.display()),
186            ));
187        }
188        self.check_size_limit(data.len())?;
189        self.ensure_parents(path);
190        let mut files = self.files.write().unwrap();
191        let entry = files.entry(path.to_path_buf()).or_insert_with(|| VfsEntry {
192            content: Vec::new(),
193            is_dir: false,
194        });
195        if entry.is_dir {
196            return Err(std::io::Error::new(
197                std::io::ErrorKind::Other,
198                format!("{} is a directory", path.display()),
199            ));
200        }
201        entry.content.extend_from_slice(data);
202        *self.total_written.write().unwrap() += data.len();
203        Ok(())
204    }
205
206    fn exists(&self, path: &Path) -> bool {
207        // Root always exists.
208        if path == Path::new("/") || path == Path::new("") {
209            return true;
210        }
211        self.files.read().unwrap().contains_key(path)
212    }
213
214    fn remove(&self, path: &Path) -> std::io::Result<()> {
215        if self.read_only.read().unwrap().contains(path) {
216            return Err(std::io::Error::new(
217                std::io::ErrorKind::PermissionDenied,
218                format!("{} is read-only (seed file)", path.display()),
219            ));
220        }
221        let mut files = self.files.write().unwrap();
222        match files.remove(path) {
223            Some(entry) if !entry.is_dir => {
224                // Reclaim written bytes.
225                let mut total = self.total_written.write().unwrap();
226                *total = total.saturating_sub(entry.content.len());
227                Ok(())
228            }
229            Some(entry) => {
230                // Put it back — can't remove non-empty dir through this API.
231                files.insert(path.to_path_buf(), entry);
232                Err(std::io::Error::new(
233                    std::io::ErrorKind::Other,
234                    format!("{} is a directory", path.display()),
235                ))
236            }
237            None => Err(std::io::Error::new(
238                std::io::ErrorKind::NotFound,
239                format!("{} not found in VFS", path.display()),
240            )),
241        }
242    }
243
244    fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathEntry>> {
245        let files = self.files.read().unwrap();
246        // Check the directory exists (root is implicit).
247        let is_root = path == Path::new("/") || path == Path::new("");
248        if !is_root {
249            match files.get(path) {
250                Some(e) if e.is_dir => {}
251                Some(_) => {
252                    return Err(std::io::Error::new(
253                        std::io::ErrorKind::Other,
254                        format!("{} is not a directory", path.display()),
255                    ));
256                }
257                None => {
258                    return Err(std::io::Error::new(
259                        std::io::ErrorKind::NotFound,
260                        format!("{} not found in VFS", path.display()),
261                    ));
262                }
263            }
264        }
265
266        let mut entries = Vec::new();
267        for (p, e) in files.iter() {
268            if let Some(parent) = p.parent() {
269                if parent == path && p != path {
270                    entries.push(PathEntry {
271                        path: p.clone(),
272                        is_dir: e.is_dir,
273                    });
274                }
275            }
276        }
277        entries.sort_by(|a, b| a.path.cmp(&b.path));
278        Ok(entries)
279    }
280
281    fn metadata(&self, path: &Path) -> std::io::Result<FileMetadata> {
282        let files = self.files.read().unwrap();
283        match files.get(path) {
284            Some(entry) => Ok(FileMetadata {
285                size: entry.content.len() as u64,
286                is_dir: entry.is_dir,
287                is_file: !entry.is_dir,
288                readonly: self.read_only.read().unwrap().contains(path),
289            }),
290            None => Err(std::io::Error::new(
291                std::io::ErrorKind::NotFound,
292                format!("{} not found in VFS", path.display()),
293            )),
294        }
295    }
296
297    fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
298        let mut files = self.files.write().unwrap();
299        for ancestor in path.ancestors() {
300            if ancestor == Path::new("") || ancestor == Path::new("/") {
301                continue;
302            }
303            files
304                .entry(ancestor.to_path_buf())
305                .or_insert_with(|| VfsEntry {
306                    content: Vec::new(),
307                    is_dir: true,
308                });
309        }
310        // Ensure the target itself is also created.
311        files.entry(path.to_path_buf()).or_insert_with(|| VfsEntry {
312            content: Vec::new(),
313            is_dir: true,
314        });
315        Ok(())
316    }
317}
318
319// ============================================================================
320// Tests
321// ============================================================================
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn vfs() -> VirtualFilesystem {
328        VirtualFilesystem::new(0) // unlimited
329    }
330
331    // -- Basic read/write --
332
333    #[test]
334    fn write_then_read() {
335        let fs = vfs();
336        fs.write(Path::new("/hello.txt"), b"world").unwrap();
337        let data = fs.read(Path::new("/hello.txt")).unwrap();
338        assert_eq!(data, b"world");
339    }
340
341    #[test]
342    fn read_nonexistent() {
343        let fs = vfs();
344        let err = fs.read(Path::new("/nope")).unwrap_err();
345        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
346    }
347
348    #[test]
349    fn overwrite_file() {
350        let fs = vfs();
351        fs.write(Path::new("/f"), b"old").unwrap();
352        fs.write(Path::new("/f"), b"new").unwrap();
353        assert_eq!(fs.read(Path::new("/f")).unwrap(), b"new");
354    }
355
356    // -- Append --
357
358    #[test]
359    fn append_creates_and_extends() {
360        let fs = vfs();
361        fs.append(Path::new("/log.txt"), b"line1\n").unwrap();
362        fs.append(Path::new("/log.txt"), b"line2\n").unwrap();
363        assert_eq!(fs.read(Path::new("/log.txt")).unwrap(), b"line1\nline2\n");
364    }
365
366    // -- Seed files (read-only) --
367
368    #[test]
369    fn seed_file_is_readable() {
370        let fs = vfs();
371        fs.seed_file(PathBuf::from("/config.toml"), b"key = true".to_vec());
372        assert_eq!(fs.read(Path::new("/config.toml")).unwrap(), b"key = true");
373    }
374
375    #[test]
376    fn seed_file_is_not_writable() {
377        let fs = vfs();
378        fs.seed_file(PathBuf::from("/config.toml"), b"data".to_vec());
379        let err = fs.write(Path::new("/config.toml"), b"hacked").unwrap_err();
380        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
381    }
382
383    #[test]
384    fn seed_file_is_not_appendable() {
385        let fs = vfs();
386        fs.seed_file(PathBuf::from("/config.toml"), b"data".to_vec());
387        let err = fs.append(Path::new("/config.toml"), b"extra").unwrap_err();
388        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
389    }
390
391    #[test]
392    fn seed_file_is_not_removable() {
393        let fs = vfs();
394        fs.seed_file(PathBuf::from("/config.toml"), b"data".to_vec());
395        let err = fs.remove(Path::new("/config.toml")).unwrap_err();
396        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
397    }
398
399    #[test]
400    fn seed_file_not_in_extract() {
401        let fs = vfs();
402        fs.seed_file(PathBuf::from("/seed.txt"), b"seed".to_vec());
403        fs.write(Path::new("/output.txt"), b"output").unwrap();
404        let written = fs.extract_written_files();
405        assert!(written.contains_key(Path::new("/output.txt")));
406        assert!(!written.contains_key(Path::new("/seed.txt")));
407    }
408
409    // -- Directories --
410
411    #[test]
412    fn create_dir_and_list() {
413        let fs = vfs();
414        fs.create_dir_all(Path::new("/data/subdir")).unwrap();
415        fs.write(Path::new("/data/subdir/file.txt"), b"hi").unwrap();
416        let entries = fs.list_dir(Path::new("/data/subdir")).unwrap();
417        assert_eq!(entries.len(), 1);
418        assert_eq!(entries[0].path, PathBuf::from("/data/subdir/file.txt"));
419        assert!(!entries[0].is_dir);
420    }
421
422    #[test]
423    fn list_root() {
424        let fs = vfs();
425        fs.write(Path::new("/a.txt"), b"a").unwrap();
426        fs.create_dir_all(Path::new("/dir")).unwrap();
427        let entries = fs.list_dir(Path::new("/")).unwrap();
428        assert!(entries.len() >= 2);
429    }
430
431    #[test]
432    fn read_directory_fails() {
433        let fs = vfs();
434        fs.create_dir_all(Path::new("/mydir")).unwrap();
435        let err = fs.read(Path::new("/mydir")).unwrap_err();
436        assert_eq!(err.kind(), std::io::ErrorKind::Other);
437    }
438
439    // -- Size limits --
440
441    #[test]
442    fn size_limit_enforced() {
443        let fs = VirtualFilesystem::new(10);
444        fs.write(Path::new("/a"), b"12345").unwrap();
445        assert_eq!(fs.total_bytes_written(), 5);
446        // This should fail — would exceed limit
447        let err = fs.write(Path::new("/b"), b"123456").unwrap_err();
448        assert_eq!(err.kind(), std::io::ErrorKind::Other);
449    }
450
451    #[test]
452    fn overwrite_reclaims_space() {
453        let fs = VirtualFilesystem::new(20);
454        fs.write(Path::new("/f"), b"1234567890").unwrap(); // 10 bytes
455        assert_eq!(fs.total_bytes_written(), 10);
456        fs.write(Path::new("/f"), b"ab").unwrap(); // replace with 2 bytes
457        assert_eq!(fs.total_bytes_written(), 2);
458    }
459
460    // -- Exists --
461
462    #[test]
463    fn exists_for_files_and_dirs() {
464        let fs = vfs();
465        assert!(!fs.exists(Path::new("/x")));
466        fs.write(Path::new("/x"), b"data").unwrap();
467        assert!(fs.exists(Path::new("/x")));
468        fs.create_dir_all(Path::new("/d")).unwrap();
469        assert!(fs.exists(Path::new("/d")));
470    }
471
472    #[test]
473    fn root_always_exists() {
474        let fs = vfs();
475        assert!(fs.exists(Path::new("/")));
476    }
477
478    // -- Remove --
479
480    #[test]
481    fn remove_file() {
482        let fs = vfs();
483        fs.write(Path::new("/f"), b"data").unwrap();
484        fs.remove(Path::new("/f")).unwrap();
485        assert!(!fs.exists(Path::new("/f")));
486    }
487
488    #[test]
489    fn remove_nonexistent_errors() {
490        let fs = vfs();
491        let err = fs.remove(Path::new("/nope")).unwrap_err();
492        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
493    }
494
495    #[test]
496    fn remove_reclaims_bytes() {
497        let fs = VirtualFilesystem::new(100);
498        fs.write(Path::new("/f"), b"12345").unwrap();
499        assert_eq!(fs.total_bytes_written(), 5);
500        fs.remove(Path::new("/f")).unwrap();
501        assert_eq!(fs.total_bytes_written(), 0);
502    }
503
504    // -- Metadata --
505
506    #[test]
507    fn metadata_file() {
508        let fs = vfs();
509        fs.write(Path::new("/f"), b"hello").unwrap();
510        let m = fs.metadata(Path::new("/f")).unwrap();
511        assert!(m.is_file);
512        assert!(!m.is_dir);
513        assert_eq!(m.size, 5);
514        assert!(!m.readonly);
515    }
516
517    #[test]
518    fn metadata_seed_file_is_readonly() {
519        let fs = vfs();
520        fs.seed_file(PathBuf::from("/s"), b"data".to_vec());
521        let m = fs.metadata(Path::new("/s")).unwrap();
522        assert!(m.readonly);
523    }
524
525    #[test]
526    fn metadata_dir() {
527        let fs = vfs();
528        fs.create_dir_all(Path::new("/d")).unwrap();
529        let m = fs.metadata(Path::new("/d")).unwrap();
530        assert!(m.is_dir);
531        assert!(!m.is_file);
532    }
533
534    // -- Parent directory auto-creation --
535
536    #[test]
537    fn writing_deep_path_creates_parents() {
538        let fs = vfs();
539        fs.write(Path::new("/a/b/c/file.txt"), b"deep").unwrap();
540        assert!(fs.exists(Path::new("/a")));
541        assert!(fs.exists(Path::new("/a/b")));
542        assert!(fs.exists(Path::new("/a/b/c")));
543        let m = fs.metadata(Path::new("/a/b")).unwrap();
544        assert!(m.is_dir);
545    }
546}