Skip to main content

oxideav_source/
scope.rs

1//! Security policy for the `file://` driver — directory allow-listing.
2//!
3//! The default [`open_file`](crate::open_file) opener accepts any path
4//! the process can read. That is appropriate for CLI tools where the
5//! user picks the file, but unsafe for server processes that take a URI
6//! from an external request: a `file:///etc/passwd` URI would otherwise
7//! happily resolve.
8//!
9//! [`FileScope`] holds an allow-list of canonicalised directory roots.
10//! A scope-bound opener resolves the requested path through
11//! `std::fs::canonicalize` (which follows symlinks and resolves `..`)
12//! and rejects anything whose canonical form is not inside one of the
13//! allow-listed roots. Install a scope with
14//! [`FileScope::register_into`]; from then on, `reg.open("file://…")`
15//! is filtered through the scope.
16//!
17//! `register_into` plumbs the active scope through a process-global
18//! slot because the registry's opener API takes a plain `fn` pointer.
19//! A single `FileScope` is therefore active per process at a time;
20//! later `register_into` calls overwrite earlier ones.
21//!
22//! The scope mirrors how container runtimes restrict file:// — there
23//! is no external spec; this is operational policy.
24
25use std::fs::File;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, OnceLock, RwLock};
28
29use oxideav_core::{BytesSource, Error, Result, SourceRegistry};
30
31use crate::uri;
32
33/// Configurable allow-list for `file://` opens.
34#[derive(Clone, Debug, Default)]
35pub struct FileScope {
36    /// Canonicalised directory roots. A request resolves to allowed iff
37    /// its canonical form starts with one of these roots (path-component
38    /// prefix match, not byte-prefix).
39    roots: Vec<PathBuf>,
40    /// If true, every canonicalisable path is admitted (the `roots`
41    /// list is consulted only on `is_allowed`, which always returns
42    /// true while this flag is set). Used by [`permissive`](Self::permissive)
43    /// to cross-platform represent "no restriction" without baking in a
44    /// Unix-style `/` root that does not match Windows canonical paths.
45    permissive: bool,
46}
47
48impl FileScope {
49    /// Empty scope — every open is rejected. Use [`allow_dir`](Self::allow_dir)
50    /// to widen.
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// A scope that permits everything the process can read. Equivalent
56    /// to the default [`open_file`](crate::open_file) behaviour. Useful
57    /// where the registry plumbing expects a `FileScope` but the caller
58    /// has no security policy to enforce.
59    pub fn permissive() -> Self {
60        Self {
61            roots: Vec::new(),
62            permissive: true,
63        }
64    }
65
66    /// Permit any path whose canonical form lives under `dir`.
67    /// The directory itself is canonicalised at insertion time, so
68    /// downstream resolution does not chase symlinks repeatedly.
69    pub fn allow_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
70        let canon = std::fs::canonicalize(dir.as_ref()).unwrap_or_else(|_| dir.as_ref().into());
71        if !self.roots.iter().any(|r| r == &canon) {
72            self.roots.push(canon);
73        }
74        self
75    }
76
77    /// Resolve a URI against the scope, returning the canonical absolute
78    /// path on success.
79    pub fn resolve(&self, uri_str: &str) -> Result<PathBuf> {
80        let (scheme, rest) = uri::split(uri_str);
81        if scheme != "file" {
82            return Err(Error::invalid(format!(
83                "FileScope cannot resolve non-file URI: {uri_str}"
84            )));
85        }
86        // Reject paths containing a NUL byte before we even touch the FS.
87        if rest.as_bytes().contains(&0u8) {
88            return Err(Error::invalid("file path contains NUL byte"));
89        }
90        // Canonicalise — this follows symlinks and resolves `..`, which
91        // is exactly what defeats a `/safe/../etc/passwd` traversal.
92        let canon = std::fs::canonicalize(rest)
93            .map_err(|e| Error::invalid(format!("file '{rest}' did not canonicalise: {e}")))?;
94        if !self.is_allowed(&canon) {
95            return Err(Error::invalid(format!(
96                "file '{rest}' (canonical '{}') is outside the FileScope allow-list",
97                canon.display()
98            )));
99        }
100        Ok(canon)
101    }
102
103    /// True iff `canon` lies under at least one allow-listed root,
104    /// matched on path components (not bytewise — `/foo` does not
105    /// permit `/foobar`). A `permissive` scope always returns true.
106    fn is_allowed(&self, canon: &Path) -> bool {
107        if self.permissive {
108            return true;
109        }
110        self.roots
111            .iter()
112            .any(|root| under_root(root.as_path(), canon))
113    }
114
115    /// Open `uri_str` under this scope.
116    pub fn open(&self, uri_str: &str) -> Result<Box<dyn BytesSource>> {
117        let canon = self.resolve(uri_str)?;
118        let f = File::open(canon)?;
119        Ok(Box::new(f))
120    }
121
122    /// Install this scope as the `file://` driver of `registry`,
123    /// **replacing** any prior `file` registration. The scope is stored
124    /// in a process-global slot keyed by `registry` registration order;
125    /// subsequent `register_into` calls overwrite that slot.
126    pub fn register_into(self, registry: &mut SourceRegistry) {
127        *active().write().expect("FileScope slot poisoned") = Some(Arc::new(self));
128        registry.register_bytes("file", open_file_scoped);
129    }
130}
131
132/// Process-global "current scope" used by [`open_file_scoped`]. Source
133/// registry opener functions must be plain `fn` pointers (not closures),
134/// so we keep the scope in a slot the opener can look up.
135fn active() -> &'static RwLock<Option<Arc<FileScope>>> {
136    static SLOT: OnceLock<RwLock<Option<Arc<FileScope>>>> = OnceLock::new();
137    SLOT.get_or_init(|| RwLock::new(None))
138}
139
140/// Free-function opener compatible with `SourceRegistry::register_bytes`.
141/// Looks up the active scope and delegates. If no scope is installed,
142/// every call errors — install via [`FileScope::register_into`] first.
143pub fn open_file_scoped(uri_str: &str) -> Result<Box<dyn BytesSource>> {
144    let slot = active().read().expect("FileScope slot poisoned");
145    let scope = slot
146        .as_ref()
147        .ok_or_else(|| Error::invalid("file driver: no FileScope installed"))?
148        .clone();
149    drop(slot);
150    scope.open(uri_str)
151}
152
153/// True iff `child` lies at or under `root`, compared component-wise.
154fn under_root(root: &Path, child: &Path) -> bool {
155    let mut r = root.components();
156    let mut c = child.components();
157    loop {
158        match (r.next(), c.next()) {
159            (Some(a), Some(b)) if a == b => continue,
160            (Some(_), _) => return false,
161            (None, _) => return true,
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use std::io::Write;
169
170    use super::*;
171
172    fn tmp_file(name: &str, body: &[u8]) -> PathBuf {
173        let p = std::env::temp_dir().join(format!("oxideav-source-scope-{name}"));
174        let mut f = std::fs::File::create(&p).unwrap();
175        f.write_all(body).unwrap();
176        p
177    }
178
179    fn tmp_dir(name: &str) -> PathBuf {
180        let p = std::env::temp_dir().join(format!("oxideav-source-scope-d-{name}"));
181        let _ = std::fs::create_dir_all(&p);
182        p
183    }
184
185    #[test]
186    fn empty_scope_rejects_everything() {
187        let path = tmp_file("empty-rejects", b"x");
188        let scope = FileScope::new();
189        let r = scope.resolve(&format!("file://{}", path.display()));
190        assert!(r.is_err());
191    }
192
193    #[test]
194    fn allow_dir_admits_files_inside() {
195        let dir = tmp_dir("allow-admits");
196        let file = dir.join("a.bin");
197        std::fs::write(&file, b"hello").unwrap();
198        let scope = FileScope::new().allow_dir(&dir);
199        let canon = scope
200            .resolve(&format!("file://{}", file.display()))
201            .unwrap();
202        // canonicalise differs from raw path on macOS (/private/var/...);
203        // require the *file* path to canonicalise equally.
204        assert_eq!(canon, std::fs::canonicalize(&file).unwrap());
205    }
206
207    #[test]
208    fn traversal_blocked_after_canonicalisation() {
209        let dir = tmp_dir("traversal-blocked");
210        // Outside file:
211        let outside = tmp_file("traversal-outside", b"secret");
212        // The traversal path: <dir>/../<file>
213        let traversal = dir.join("..").join(outside.file_name().unwrap());
214        let scope = FileScope::new().allow_dir(&dir);
215        let r = scope.resolve(&format!("file://{}", traversal.display()));
216        assert!(r.is_err(), "traversal must be rejected");
217    }
218
219    #[test]
220    fn prefix_match_is_component_aware() {
221        let parent = tmp_dir("prefix-component-aware-parent");
222        // /tmp/.../parent_extra — bytewise prefix-matches parent but is a
223        // different directory.
224        let mut extra: PathBuf = parent.clone();
225        extra.set_file_name(format!(
226            "{}_extra",
227            parent.file_name().unwrap().to_string_lossy()
228        ));
229        std::fs::create_dir_all(&extra).unwrap();
230        let outside = extra.join("file.bin");
231        std::fs::write(&outside, b"x").unwrap();
232        let scope = FileScope::new().allow_dir(&parent);
233        let r = scope.resolve(&format!("file://{}", outside.display()));
234        assert!(
235            r.is_err(),
236            "component-aware match must reject sibling dir whose name shares a prefix"
237        );
238    }
239
240    #[test]
241    fn permissive_admits_anything_readable() {
242        let p = tmp_file("permissive", b"abc");
243        let scope = FileScope::permissive();
244        let r = scope.resolve(&format!("file://{}", p.display()));
245        assert!(r.is_ok());
246    }
247
248    #[test]
249    fn nul_byte_rejected() {
250        let scope = FileScope::permissive();
251        let r = scope.resolve("file:///tmp/a\0b");
252        assert!(r.is_err());
253    }
254
255    #[test]
256    fn non_file_scheme_rejected() {
257        let scope = FileScope::permissive();
258        let r = scope.resolve("http://example.com/x");
259        assert!(r.is_err());
260    }
261
262    #[test]
263    fn open_reads_file_under_allowed_dir() {
264        let dir = tmp_dir("open-reads");
265        let file = dir.join("payload.bin");
266        std::fs::write(&file, b"payload!").unwrap();
267        let scope = FileScope::new().allow_dir(&dir);
268        let mut r = scope.open(&format!("file://{}", file.display())).unwrap();
269        let mut buf = Vec::new();
270        std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
271        assert_eq!(buf, b"payload!");
272    }
273}