use std::path::{Component, Path, PathBuf};
use object_store::ObjectStoreScheme;
use super::location::{StorageLocationScheme, StorageLocationUrl};
use crate::{Error, Result};
#[derive(Debug, Clone, Default)]
pub struct LocalStoragePolicy {
allowed_roots: Vec<PathBuf>,
}
impl LocalStoragePolicy {
pub fn new<I, P>(roots: I) -> Result<Self>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let allowed_roots = roots
.into_iter()
.map(|root| {
let root = root.as_ref();
root.canonicalize().map_err(|e| {
Error::invalid_argument(format!(
"allowed local storage root '{}' is not accessible: {e}",
root.display()
))
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self { allowed_roots })
}
pub fn deny_all() -> Self {
Self::default()
}
pub(crate) fn allowed_roots(&self) -> &[PathBuf] {
&self.allowed_roots
}
pub fn check(&self, location: &StorageLocationUrl) -> Result<()> {
if !matches!(
location.scheme(),
StorageLocationScheme::ObjectStore(ObjectStoreScheme::Local)
) {
return Ok(());
}
let path = location.raw().to_file_path().map_err(|_| {
Error::invalid_argument(format!("not a valid local file path: {}", location.raw()))
})?;
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(Error::invalid_argument(format!(
"local storage path '{}' must not contain '..'",
path.display()
)));
}
let normalized = lexically_normalize(&path);
if self.allowed_roots.is_empty() {
return Err(Error::invalid_argument(format!(
"local (file://) storage is not enabled on this server; \
path '{}' is not within any allowed root",
normalized.display()
)));
}
if self
.allowed_roots
.iter()
.any(|root| is_within(&normalized, root))
{
Ok(())
} else {
Err(Error::invalid_argument(format!(
"local storage path '{}' is not within any allowed root",
normalized.display()
)))
}
}
}
fn lexically_normalize(path: &Path) -> PathBuf {
path.components()
.filter(|c| !matches!(c, Component::CurDir))
.collect()
}
fn is_within(path: &Path, root: &Path) -> bool {
let mut root_parts = root.components();
let mut path_parts = path.components();
loop {
match (root_parts.next(), path_parts.next()) {
(None, _) => return true,
(Some(_), None) => return false,
(Some(a), Some(b)) if a != b => return false,
(Some(_), Some(_)) => continue,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loc(url: &str) -> StorageLocationUrl {
StorageLocationUrl::parse(url).unwrap()
}
#[test]
fn deny_all_rejects_every_file_url() {
let policy = LocalStoragePolicy::deny_all();
assert!(policy.check(&loc("file:///tmp/anything")).is_err());
assert!(policy.check(&loc("file:///")).is_err());
}
#[test]
fn cloud_schemes_pass_through_even_with_empty_policy() {
let policy = LocalStoragePolicy::deny_all();
assert!(policy.check(&loc("s3://bucket/data")).is_ok());
assert!(
policy
.check(&loc("abfss://c@acct.dfs.core.windows.net/x"))
.is_ok()
);
}
#[cfg(not(windows))]
#[test]
fn allows_paths_within_a_configured_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let policy = LocalStoragePolicy::new([&root]).unwrap();
let inside = url::Url::from_directory_path(root.join("catalog/table"))
.unwrap()
.to_string();
assert!(policy.check(&loc(&inside)).is_ok());
let at_root = url::Url::from_directory_path(&root).unwrap().to_string();
assert!(policy.check(&loc(&at_root)).is_ok());
}
#[cfg(not(windows))]
#[test]
fn rejects_paths_outside_every_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let policy = LocalStoragePolicy::new([&root]).unwrap();
assert!(policy.check(&loc("file:///etc/passwd")).is_err());
}
#[cfg(not(windows))]
#[test]
fn rejects_sibling_prefix() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let policy = LocalStoragePolicy::new([&root]).unwrap();
let mut sibling = root.clone();
let name = format!("{}-secret", root.file_name().unwrap().to_string_lossy());
sibling.set_file_name(name);
sibling.push("data");
let url = url::Url::from_directory_path(&sibling).unwrap().to_string();
assert!(policy.check(&loc(&url)).is_err());
}
#[cfg(not(windows))]
#[test]
fn rejects_parent_dir_traversal() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let policy = LocalStoragePolicy::new([&root]).unwrap();
let escape = url::Url::from_directory_path(root.join("..").join("escape"))
.unwrap()
.to_string();
assert!(policy.check(&loc(&escape)).is_err());
}
#[test]
fn new_errors_on_missing_root() {
let result = LocalStoragePolicy::new(["/this/path/does/not/exist/uc-xyz"]);
assert!(result.is_err());
}
#[test]
fn is_within_matches_on_component_boundaries() {
assert!(is_within(Path::new("/data/t1"), Path::new("/data")));
assert!(is_within(Path::new("/data"), Path::new("/data")));
assert!(!is_within(Path::new("/data-secret/t1"), Path::new("/data")));
assert!(!is_within(Path::new("/other/t1"), Path::new("/data")));
}
}