Skip to main content

virtual_filesystem/
scoped_fs.rs

1use crate::file::{DirEntry, File, Metadata, OpenOptions};
2use crate::util::{make_relative, normalize_path};
3use crate::FileSystem;
4use std::io::{self, ErrorKind};
5use std::path::PathBuf;
6
7/// A filesystem that restricts access to a subdirectory of an inner filesystem.
8/// Paths that would escape the scope via `../` traversal are rejected.
9pub struct ScopedFS<FS: FileSystem> {
10    inner: FS,
11    root: PathBuf, // normalized, relative (e.g. "src")
12}
13
14impl<FS: FileSystem> ScopedFS<FS> {
15    pub fn new(root: &str, inner: FS) -> Self {
16        Self {
17            inner,
18            root: normalize_path(make_relative(root)),
19        }
20    }
21
22    fn resolve(&self, path: &str) -> crate::Result<String> {
23        let joined = normalize_path(self.root.join(make_relative(path)));
24        if !joined.starts_with(&self.root) {
25            return Err(io::Error::new(ErrorKind::PermissionDenied, "Traversal prevented"));
26        }
27        Ok(joined.to_str().unwrap().to_owned())
28    }
29}
30
31impl<FS: FileSystem> FileSystem for ScopedFS<FS> {
32    fn create_dir(&self, path: &str) -> crate::Result<()> {
33        self.inner.create_dir(&self.resolve(path)?)
34    }
35
36    fn metadata(&self, path: &str) -> crate::Result<Metadata> {
37        self.inner.metadata(&self.resolve(path)?)
38    }
39
40    fn open_file_options(&self, path: &str, options: &OpenOptions) -> crate::Result<Box<dyn File>> {
41        self.inner.open_file_options(&self.resolve(path)?, options)
42    }
43
44    fn read_dir(&self, path: &str) -> crate::Result<Box<dyn Iterator<Item = crate::Result<DirEntry>>>> {
45        self.inner.read_dir(&self.resolve(path)?)
46    }
47
48    fn remove_dir(&self, path: &str) -> crate::Result<()> {
49        self.inner.remove_dir(&self.resolve(path)?)
50    }
51
52    fn remove_file(&self, path: &str) -> crate::Result<()> {
53        self.inner.remove_file(&self.resolve(path)?)
54    }
55}
56
57#[cfg(test)]
58mod test {
59    use super::ScopedFS;
60    use crate::memory_fs::MemoryFS;
61    use crate::FileSystem;
62    use std::io::{ErrorKind, Write};
63
64    /// Builds a MemoryFS with:
65    ///   src/init.luau   — "entry"
66    ///   secret.txt      — "secret"  (outside scope)
67    fn make_fs() -> ScopedFS<MemoryFS> {
68        let mem = MemoryFS::default();
69        mem.create_dir("src").unwrap();
70        write!(mem.create_file("src/init.luau").unwrap(), "entry").unwrap();
71        write!(mem.create_file("secret.txt").unwrap(), "secret").unwrap();
72        ScopedFS::new("src", mem)
73    }
74
75    #[test]
76    fn reads_file_inside_scope() {
77        let fs = make_fs();
78        let content = fs.open_file("init.luau").unwrap().read_into_string().unwrap();
79        assert_eq!(content, "entry");
80    }
81
82    #[test]
83    fn traversal_single_dotdot() {
84        let fs = make_fs();
85        let err = fs.open_file("../secret.txt").err().unwrap();
86        assert_eq!(err.kind(), ErrorKind::PermissionDenied);
87    }
88
89    #[test]
90    fn traversal_dotdot_chain() {
91        let fs = make_fs();
92        let err = fs.open_file("../../secret.txt").err().unwrap();
93        assert_eq!(err.kind(), ErrorKind::PermissionDenied);
94    }
95
96    #[test]
97    fn traversal_descend_then_escape() {
98        // goes into a subdir first, then tries to climb out past root
99        let fs = make_fs();
100        let err = fs.open_file("subdir/../../secret.txt").err().unwrap();
101        assert_eq!(err.kind(), ErrorKind::PermissionDenied);
102    }
103
104    #[test]
105    fn root_resolves_to_scope_root() {
106        let fs = make_fs();
107        // "/" or "" should resolve to "src" — a directory, not an error
108        assert!(fs.metadata("/").unwrap().is_directory());
109        assert!(fs.metadata("").unwrap().is_directory());
110    }
111}