use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use oxideav_core::{BytesSource, Error, Result, SourceRegistry};
use crate::uri;
#[derive(Clone, Debug, Default)]
pub struct FileScope {
roots: Vec<PathBuf>,
permissive: bool,
}
impl FileScope {
pub fn new() -> Self {
Self::default()
}
pub fn permissive() -> Self {
Self {
roots: Vec::new(),
permissive: true,
}
}
pub fn allow_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
let canon = std::fs::canonicalize(dir.as_ref()).unwrap_or_else(|_| dir.as_ref().into());
if !self.roots.iter().any(|r| r == &canon) {
self.roots.push(canon);
}
self
}
pub fn resolve(&self, uri_str: &str) -> Result<PathBuf> {
let (scheme, rest) = uri::split(uri_str);
if scheme != "file" {
return Err(Error::invalid(format!(
"FileScope cannot resolve non-file URI: {uri_str}"
)));
}
if rest.as_bytes().contains(&0u8) {
return Err(Error::invalid("file path contains NUL byte"));
}
let canon = std::fs::canonicalize(rest)
.map_err(|e| Error::invalid(format!("file '{rest}' did not canonicalise: {e}")))?;
if !self.is_allowed(&canon) {
return Err(Error::invalid(format!(
"file '{rest}' (canonical '{}') is outside the FileScope allow-list",
canon.display()
)));
}
Ok(canon)
}
fn is_allowed(&self, canon: &Path) -> bool {
if self.permissive {
return true;
}
self.roots
.iter()
.any(|root| under_root(root.as_path(), canon))
}
pub fn open(&self, uri_str: &str) -> Result<Box<dyn BytesSource>> {
let canon = self.resolve(uri_str)?;
let f = File::open(canon)?;
Ok(Box::new(f))
}
pub fn register_into(self, registry: &mut SourceRegistry) {
*active().write().expect("FileScope slot poisoned") = Some(Arc::new(self));
registry.register_bytes("file", open_file_scoped);
}
}
fn active() -> &'static RwLock<Option<Arc<FileScope>>> {
static SLOT: OnceLock<RwLock<Option<Arc<FileScope>>>> = OnceLock::new();
SLOT.get_or_init(|| RwLock::new(None))
}
pub fn open_file_scoped(uri_str: &str) -> Result<Box<dyn BytesSource>> {
let slot = active().read().expect("FileScope slot poisoned");
let scope = slot
.as_ref()
.ok_or_else(|| Error::invalid("file driver: no FileScope installed"))?
.clone();
drop(slot);
scope.open(uri_str)
}
fn under_root(root: &Path, child: &Path) -> bool {
let mut r = root.components();
let mut c = child.components();
loop {
match (r.next(), c.next()) {
(Some(a), Some(b)) if a == b => continue,
(Some(_), _) => return false,
(None, _) => return true,
}
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn tmp_file(name: &str, body: &[u8]) -> PathBuf {
let p = std::env::temp_dir().join(format!("oxideav-source-scope-{name}"));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body).unwrap();
p
}
fn tmp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("oxideav-source-scope-d-{name}"));
let _ = std::fs::create_dir_all(&p);
p
}
#[test]
fn empty_scope_rejects_everything() {
let path = tmp_file("empty-rejects", b"x");
let scope = FileScope::new();
let r = scope.resolve(&format!("file://{}", path.display()));
assert!(r.is_err());
}
#[test]
fn allow_dir_admits_files_inside() {
let dir = tmp_dir("allow-admits");
let file = dir.join("a.bin");
std::fs::write(&file, b"hello").unwrap();
let scope = FileScope::new().allow_dir(&dir);
let canon = scope
.resolve(&format!("file://{}", file.display()))
.unwrap();
assert_eq!(canon, std::fs::canonicalize(&file).unwrap());
}
#[test]
fn traversal_blocked_after_canonicalisation() {
let dir = tmp_dir("traversal-blocked");
let outside = tmp_file("traversal-outside", b"secret");
let traversal = dir.join("..").join(outside.file_name().unwrap());
let scope = FileScope::new().allow_dir(&dir);
let r = scope.resolve(&format!("file://{}", traversal.display()));
assert!(r.is_err(), "traversal must be rejected");
}
#[test]
fn prefix_match_is_component_aware() {
let parent = tmp_dir("prefix-component-aware-parent");
let mut extra: PathBuf = parent.clone();
extra.set_file_name(format!(
"{}_extra",
parent.file_name().unwrap().to_string_lossy()
));
std::fs::create_dir_all(&extra).unwrap();
let outside = extra.join("file.bin");
std::fs::write(&outside, b"x").unwrap();
let scope = FileScope::new().allow_dir(&parent);
let r = scope.resolve(&format!("file://{}", outside.display()));
assert!(
r.is_err(),
"component-aware match must reject sibling dir whose name shares a prefix"
);
}
#[test]
fn permissive_admits_anything_readable() {
let p = tmp_file("permissive", b"abc");
let scope = FileScope::permissive();
let r = scope.resolve(&format!("file://{}", p.display()));
assert!(r.is_ok());
}
#[test]
fn nul_byte_rejected() {
let scope = FileScope::permissive();
let r = scope.resolve("file:///tmp/a\0b");
assert!(r.is_err());
}
#[test]
fn non_file_scheme_rejected() {
let scope = FileScope::permissive();
let r = scope.resolve("http://example.com/x");
assert!(r.is_err());
}
#[test]
fn open_reads_file_under_allowed_dir() {
let dir = tmp_dir("open-reads");
let file = dir.join("payload.bin");
std::fs::write(&file, b"payload!").unwrap();
let scope = FileScope::new().allow_dir(&dir);
let mut r = scope.open(&format!("file://{}", file.display())).unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut r, &mut buf).unwrap();
assert_eq!(buf, b"payload!");
}
}