use std::future::Future;
use std::path::{Path as OsPath, PathBuf};
use std::pin::Pin;
use bytes::Bytes;
use path_clean::PathClean;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use url::Url;
use web_time::SystemTime;
use super::{ListOptions, ObjectKey, ObjectMeta, ObjectStore};
use crate::buc::Config;
use crate::err::Error;
#[derive(Clone, Debug)]
pub struct FileStoreOptions {
root: ObjectKey,
lowercase_paths: bool,
}
#[derive(Clone, Debug)]
pub struct FileStore {
options: FileStoreOptions,
config: Config,
}
impl FileStore {
pub fn new(options: FileStoreOptions, config: Config) -> Self {
FileStore {
options,
config,
}
}
pub async fn parse_url(
url_str: &str,
config: &Config,
) -> Result<Option<FileStoreOptions>, Error> {
let Ok(url) = Url::parse(url_str) else {
return Ok(None);
};
if url.scheme() != "file" {
return Ok(None);
}
let lowercase_paths: bool = url
.query_pairs()
.find(|(key, _)| key == "lowercase_paths")
.map(|(_, value)| {
if value.is_empty() {
Ok(true)
} else {
value.parse()
}
})
.transpose()
.map_err(|_| {
Error::InvalidBucketUrl(
"Expected to find a bool for query option `lowercase_paths`".to_string(),
)
})?
.unwrap_or(false);
#[allow(unused_mut)]
let mut path_from_url = url.path().to_string();
#[cfg(windows)]
{
if path_from_url.starts_with('/')
&& path_from_url.len() > 2
&& path_from_url.as_bytes()[1].is_ascii_alphabetic()
&& path_from_url.as_bytes()[2] == b':'
{
path_from_url.remove(0); }
}
let path_buf = PathBuf::from(&path_from_url).clean();
if !path_buf.is_absolute() {
return Err(Error::InvalidBucketUrl(format!(
"File path '{}' (derived from URL path '{}') is not absolute.",
path_buf.display(),
path_from_url
)));
}
if !is_path_allowed(&path_buf, lowercase_paths, &config.bucket_list) {
return Err(Error::FileAccessDenied(path_from_url.clone()));
}
let metadata = tokio::fs::metadata(&path_buf).await;
if let Ok(metadata) = metadata {
if !metadata.is_dir() {
return Err(Error::InvalidBucketUrl(format!(
"Path '{}' is not a directory.",
path_buf.display()
)));
}
} else {
tokio::fs::create_dir_all(&path_buf).await.map_err(|e| {
Error::InvalidBucketUrl(format!(
"Failed to create directory '{}': {}",
path_buf.display(),
e
))
})?;
};
Ok(Some(FileStoreOptions {
root: ObjectKey::new(path_from_url),
lowercase_paths,
}))
}
async fn path_exists(path: &OsPath) -> Result<bool, String> {
tokio::fs::try_exists(path)
.await
.map_err(|e| format!("Failed to check if path exists: {}", e))
}
async fn to_os_path(&self, path: &ObjectKey) -> Result<PathBuf, String> {
#[allow(unused_mut)]
let mut root_str = self.options.root.as_str();
#[cfg(windows)]
{
if root_str.starts_with('/')
&& root_str.len() > 2
&& root_str.as_bytes()[1] != b'/' && root_str.as_bytes()[2] == b':'
{
root_str = &root_str[1..];
}
}
let root_path = PathBuf::from(root_str).clean();
let canonical_root = tokio::fs::canonicalize(&root_path).await.map_err(|e| {
format!("Failed to canonicalize root path '{}': {}", root_path.display(), e)
})?;
let relative_path_str = path.as_str().trim_start_matches('/');
let relative_path = if self.options.lowercase_paths {
relative_path_str.to_lowercase()
} else {
relative_path_str.to_string()
};
let full_path = canonical_root.join(&relative_path).clean();
if !full_path.starts_with(&canonical_root) {
return Err(format!("Path escapes the bucket root: {}", full_path.display()));
}
if !is_path_allowed(&full_path, self.options.lowercase_paths, &self.config.bucket_list) {
return Err(format!(
"Path is not inside the allowed bucket directories: {}",
full_path.display()
));
}
Ok(full_path)
}
async fn ensure_parent_dirs(path: &OsPath) -> Result<(), String> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create directories: {}", e))?;
}
Ok(())
}
}
fn is_path_allowed(
path_to_check: &std::path::Path,
lowercase_paths: bool,
allowed: &[PathBuf],
) -> bool {
if !lowercase_paths {
return allowed.iter().any(|allowed_path| path_to_check.starts_with(allowed_path));
}
let Some(raw_path) = path_to_check.to_str() else {
return false;
};
const WINDOWS_CANONICAL_PATH_PREFIX: &str = "//?/";
let normalized = raw_path.to_lowercase().replace('\\', "/");
let path_str = normalized.strip_prefix(WINDOWS_CANONICAL_PATH_PREFIX).unwrap_or(&normalized);
allowed.iter().any(|allowed_path| {
let Some(raw_allowed) = allowed_path.to_str() else {
return false;
};
let allowed_str = raw_allowed.to_lowercase().replace('\\', "/");
let allowed_str = allowed_str.trim_end_matches('/');
let Some(rest) = path_str.strip_prefix(allowed_str) else {
return false;
};
rest.is_empty() || rest.starts_with('/')
})
}
impl ObjectStore for FileStore {
fn put<'a>(
&'a self,
key: &'a ObjectKey,
data: Bytes,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
Self::ensure_parent_dirs(&os_path).await?;
let mut file = File::create(&os_path)
.await
.map_err(|e| format!("Failed to create file: {}", e))?;
file.write_all(&data).await.map_err(|e| format!("Failed to write to file: {}", e))?;
file.flush().await.map_err(|e| format!("Failed to flush file: {}", e))?;
Ok(())
})
}
fn put_if_not_exists<'a>(
&'a self,
key: &'a ObjectKey,
data: Bytes,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
if Self::path_exists(&os_path).await? {
return Ok(());
}
Self::ensure_parent_dirs(&os_path).await?;
let mut file = File::create(&os_path)
.await
.map_err(|e| format!("Failed to create file: {}", e))?;
file.write_all(&data).await.map_err(|e| format!("Failed to write to file: {}", e))?;
file.flush().await.map_err(|e| format!("Failed to flush file: {}", e))?;
Ok(())
})
}
fn get<'a>(
&'a self,
key: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<Option<Bytes>, String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
if !Self::path_exists(&os_path).await? {
return Ok(None);
}
let data = tokio::fs::read(&os_path)
.await
.map_err(|e| format!("Failed to read file: {}", e))?;
Ok(Some(Bytes::from(data)))
})
}
fn head<'a>(
&'a self,
key: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<Option<ObjectMeta>, String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
if !Self::path_exists(&os_path).await? {
return Ok(None);
}
let metadata = tokio::fs::metadata(&os_path)
.await
.map_err(|e| format!("Failed to get metadata: {}", e))?;
let size = metadata.len();
let updated = metadata.modified().unwrap_or_else(|_| SystemTime::now()).into();
Ok(Some(ObjectMeta {
size,
updated,
key: key.to_owned(),
}))
})
}
fn delete<'a>(
&'a self,
key: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
if !Self::path_exists(&os_path).await? {
return Ok(());
}
tokio::fs::remove_file(&os_path)
.await
.map_err(|e| format!("Failed to delete file: {}", e))?;
Ok(())
})
}
fn exists<'a>(
&'a self,
key: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<bool, String>> + Send + 'a>> {
Box::pin(async move {
let os_path = self.to_os_path(key).await?;
Self::path_exists(&os_path).await
})
}
fn copy<'a>(
&'a self,
key: &'a ObjectKey,
target: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let source_key = self.to_os_path(key).await?;
let target_key = self.to_os_path(target).await?;
if !Self::path_exists(&source_key).await? {
return Err(format!("Source key does not exist: {}", source_key.display()));
}
Self::ensure_parent_dirs(&target_key).await?;
tokio::fs::copy(&source_key, &target_key)
.await
.map_err(|e| format!("Failed to copy file: {}", e))?;
Ok(())
})
}
fn copy_if_not_exists<'a>(
&'a self,
key: &'a ObjectKey,
target: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let source_key = self.to_os_path(key).await?;
let target_key = self.to_os_path(target).await?;
if Self::path_exists(&target_key).await? {
return Ok(());
}
if !Self::path_exists(&source_key).await? {
return Ok(());
}
Self::ensure_parent_dirs(&target_key).await?;
tokio::fs::copy(&source_key, &target_key)
.await
.map_err(|e| format!("Failed to copy file: {}", e))?;
Ok(())
})
}
fn rename<'a>(
&'a self,
key: &'a ObjectKey,
target: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let source_key = self.to_os_path(key).await?;
let target_key = self.to_os_path(target).await?;
if !Self::path_exists(&source_key).await? {
return Err(format!("Source file does not exist: {}", source_key.display()));
}
Self::ensure_parent_dirs(&target_key).await?;
tokio::fs::rename(&source_key, &target_key)
.await
.map_err(|e| format!("Failed to rename file: {}", e))?;
Ok(())
})
}
fn rename_if_not_exists<'a>(
&'a self,
key: &'a ObjectKey,
target: &'a ObjectKey,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>> {
Box::pin(async move {
let source_key = self.to_os_path(key).await?;
let target_key = self.to_os_path(target).await?;
if Self::path_exists(&target_key).await? {
return Ok(());
}
if !Self::path_exists(&source_key).await? {
return Err(format!("Source file does not exist: {}", source_key.display()));
}
Self::ensure_parent_dirs(&target_key).await?;
tokio::fs::rename(&source_key, &target_key)
.await
.map_err(|e| format!("Failed to rename file: {}", e))?;
Ok(())
})
}
fn list<'a>(
&'a self,
opts: &'a ListOptions,
) -> Pin<Box<dyn Future<Output = Result<Vec<ObjectMeta>, String>> + Send + 'a>> {
Box::pin(async move {
let base_key = opts.prefix.clone().unwrap_or_default();
let os_path = self.to_os_path(&base_key).await?;
if !Self::path_exists(&os_path).await? {
return Ok(Vec::new());
}
let metadata = tokio::fs::metadata(&os_path)
.await
.map_err(|e| format!("Failed to get metadata: {}", e))?;
if metadata.is_file() {
if let Some(ref start_key) = opts.start
&& base_key.to_string() < start_key.to_string()
{
return Ok(Vec::new());
}
let size = metadata.len();
let updated = metadata.modified().unwrap_or_else(|_| SystemTime::now()).into();
return Ok(vec![ObjectMeta {
key: base_key,
size,
updated,
}]);
}
let mut read_dir = tokio::fs::read_dir(&os_path)
.await
.map_err(|e| format!("Failed to read directory: {}", e))?;
let mut all_entries = Vec::new();
while let Ok(Some(entry)) = read_dir.next_entry().await {
let path = entry.path();
let metadata = match tokio::fs::metadata(&path).await {
Ok(md) => md,
Err(e) => {
error!("Failed to get metadata for {}: {}", path.display(), e);
continue;
}
};
if metadata.is_dir() {
continue;
}
let rel_path = path
.strip_prefix(&os_path)
.map_err(|e| format!("Failed to get relative path: {}", e))?;
let rel_str = rel_path.to_string_lossy();
let entry_key = base_key.join(&ObjectKey::new(rel_str.into_owned()));
all_entries.push((entry_key, metadata));
}
all_entries.sort_by_key(|(key, _)| key.to_string());
let filtered_entries = if let Some(ref start_key) = opts.start {
all_entries
.into_iter()
.filter(|(key, _)| key.to_string() > start_key.to_string())
.collect()
} else {
all_entries
};
let limited_entries = if let Some(limit_val) = opts.limit {
filtered_entries.into_iter().take(limit_val).collect::<Vec<_>>()
} else {
filtered_entries
};
let objects = limited_entries
.into_iter()
.map(|(entry_key, metadata)| {
let size = metadata.len();
let updated = metadata.modified().unwrap_or_else(|_| SystemTime::now()).into();
ObjectMeta {
key: entry_key,
size,
updated,
}
})
.collect();
Ok(objects)
})
}
}
#[cfg(test)]
mod tests {
use temp_dir::TempDir;
use super::*;
use crate::buc::Config;
async fn canonical(dir: &OsPath) -> PathBuf {
tokio::fs::canonicalize(dir).await.unwrap()
}
fn build_url(dir: &OsPath, query: &str) -> String {
let path = dir.to_string_lossy();
if query.is_empty() {
format!("file://{path}")
} else {
format!("file://{path}?{query}")
}
}
async fn open_store(dir: &OsPath, query: &str) -> (FileStore, PathBuf) {
let root = canonical(dir).await;
let cfg = Config::for_test(vec![root.clone()]);
let opts = FileStore::parse_url(&build_url(&root, query), &cfg)
.await
.expect("parse_url should succeed for an allowlisted path")
.expect("file:// URL should resolve to a FileStore");
(FileStore::new(opts, cfg), root)
}
fn on_disk_names(dir: &OsPath) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
names.sort();
names
}
#[tokio::test]
async fn default_preserves_case_end_to_end() {
let dir = TempDir::new().unwrap();
let (store, root) = open_store(dir.path(), "").await;
let key = ObjectKey::new("/CaseProbe_XYZ.tmp".to_string());
store.put(&key, Bytes::from_static(b"hello")).await.unwrap();
assert_eq!(on_disk_names(&root), vec!["CaseProbe_XYZ.tmp"]);
assert!(store.exists(&key).await.unwrap());
assert_eq!(store.get(&key).await.unwrap().as_deref(), Some(&b"hello"[..]));
let listed = store.list(&ListOptions::default()).await.unwrap();
let keys: Vec<_> = listed.into_iter().map(|m| m.key.to_string()).collect();
assert_eq!(keys, vec!["/CaseProbe_XYZ.tmp"]);
store.delete(&key).await.unwrap();
assert!(!store.exists(&key).await.unwrap());
assert!(on_disk_names(&root).is_empty());
}
#[tokio::test]
async fn opt_in_lowercase_paths_still_folds() {
let dir = TempDir::new().unwrap();
let (store, root) = open_store(dir.path(), "lowercase_paths=true").await;
let key = ObjectKey::new("/MixedCase.txt".to_string());
store.put(&key, Bytes::from_static(b"hello")).await.unwrap();
assert_eq!(on_disk_names(&root), vec!["mixedcase.txt"]);
let lower = ObjectKey::new("/mixedcase.txt".to_string());
assert!(store.exists(&key).await.unwrap());
assert!(store.exists(&lower).await.unwrap());
}
#[tokio::test]
async fn bare_lowercase_paths_query_enables_folding() {
let dir = TempDir::new().unwrap();
let (store, root) = open_store(dir.path(), "lowercase_paths").await;
let key = ObjectKey::new("/MixedCase.txt".to_string());
store.put(&key, Bytes::from_static(b"hello")).await.unwrap();
assert_eq!(on_disk_names(&root), vec!["mixedcase.txt"]);
}
#[test]
fn case_sensitive_allowlist_matches_prefix_components() {
let allowed = vec![PathBuf::from("/srv/data")];
assert!(is_path_allowed(OsPath::new("/srv/data/file.txt"), false, &allowed));
assert!(!is_path_allowed(OsPath::new("/srv/other/file.txt"), false, &allowed));
}
#[test]
fn lowercase_allowlist_matches_case_insensitively() {
let allowed = vec![PathBuf::from("/srv/Data")];
assert!(is_path_allowed(OsPath::new("/SRV/data/file.txt"), true, &allowed));
}
#[test]
fn lowercase_allowlist_requires_component_boundary() {
let allowed = vec![PathBuf::from("/srv/data")];
assert!(!is_path_allowed(OsPath::new("/srv/data-evil/secret"), true, &allowed));
assert!(is_path_allowed(OsPath::new("/srv/data"), true, &allowed));
assert!(is_path_allowed(OsPath::new("/srv/data/file.txt"), true, &allowed));
}
#[test]
fn lowercase_allowlist_accepts_trailing_separator() {
let allowed = vec![PathBuf::from("/srv/data/")];
assert!(is_path_allowed(OsPath::new("/srv/data"), true, &allowed));
assert!(is_path_allowed(OsPath::new("/srv/data/file.txt"), true, &allowed));
assert!(!is_path_allowed(OsPath::new("/srv/data-evil"), true, &allowed));
}
#[cfg(unix)]
#[test]
fn lowercase_mode_rejects_non_utf8_paths() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let allowed = vec![PathBuf::from("/srv/data")];
let bad_path = OsPath::new(OsStr::from_bytes(b"/srv/data/\xFFsecret"));
assert!(!is_path_allowed(bad_path, true, &allowed));
}
#[cfg(unix)]
#[test]
fn lowercase_mode_rejects_non_utf8_allowed_entry() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let allowed = vec![PathBuf::from(OsStr::from_bytes(b"/srv/data/\xFF"))];
assert!(!is_path_allowed(OsPath::new("/srv/data/file.txt"), true, &allowed));
}
}