1use 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#[derive(Clone, Debug, Default)]
35pub struct FileScope {
36 roots: Vec<PathBuf>,
40 permissive: bool,
46}
47
48impl FileScope {
49 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn permissive() -> Self {
60 Self {
61 roots: Vec::new(),
62 permissive: true,
63 }
64 }
65
66 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 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 if rest.as_bytes().contains(&0u8) {
88 return Err(Error::invalid("file path contains NUL byte"));
89 }
90 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 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 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 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
132fn 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
140pub 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
153fn 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 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 let outside = tmp_file("traversal-outside", b"secret");
212 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 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}