use std::{
future::Future,
path::{Path, PathBuf, StripPrefixError},
};
use bytes::Bytes;
use path_clean::PathClean;
use crate::{
model::FileInfo,
proto::{FileSystem, calculate_local_unix_path},
};
#[derive(Debug, Clone)]
pub struct TokioLocalStorage {
base: PathBuf,
ignore_parts: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum TokioLocalStorageError {
#[error("An operation outside of the base directory is requested, is this some kind of escape attack? Dir: {0}")]
AccessOutOfBaseDirectory(PathBuf),
#[error("Filename not within root directory, is this some kind of escape attack?")]
StripPrefixError(#[from] StripPrefixError),
#[error("Io Error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid UTF-8 filename. This code requires filenames to match UTF-8")]
InvalidUtf8Filename,
}
impl TokioLocalStorage {
pub fn new(base: PathBuf, ignore_parts: Vec<String>) -> Self { Self { base, ignore_parts } }
fn sub_path(&self, path: &str) -> Result<PathBuf, TokioLocalStorageError> {
let path = self.base.join(path).clean();
if !path.starts_with(&self.base) {
return Err(TokioLocalStorageError::AccessOutOfBaseDirectory(path));
}
Ok(path)
}
}
impl FileSystem for TokioLocalStorage {
type Error = TokioLocalStorageError;
type StorePrepare = tokio::fs::File;
fn all_files(&mut self) -> impl Future<Output = Result<Vec<FileInfo>, Self::Error>> + Send {
let mut nextdirs = vec![self.base.clone()];
let mut file_infos = Vec::new();
async move {
while let Some(next) = nextdirs.pop() {
let mut dir = tokio::fs::read_dir(&next).await?;
while let Some(entry) = dir.next_entry().await? {
let path = entry.path();
let relative_path = path.strip_prefix(&self.base)?;
if self.ignore_parts.iter().any(|ignore| relative_path.starts_with(ignore)) {
continue;
}
if path.is_dir() {
nextdirs.push(path);
} else {
parse_file_info(&self.base, path, &mut file_infos).await?;
}
}
}
Ok(file_infos)
}
}
fn prepare_store_file(
&self,
info: FileInfo,
) -> impl Future<Output = Result<Self::StorePrepare, Self::Error>> + Send {
let path = self.sub_path(&info.local_unix_path);
async move {
let path = path?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
};
let file = tokio::fs::File::create(path).await?;
Ok(file)
}
}
#[expect(clippy::manual_async_fn)]
fn store_file(
&self,
mut prepared: Self::StorePrepare,
mut data: Bytes,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
async move {
use tokio::io::AsyncWriteExt;
prepared.write_all_buf(&mut data).await?;
Ok(())
}
}
fn delete_file(&self, info: FileInfo) -> impl Future<Output = Result<(), Self::Error>> + Send {
let path = self.sub_path(&info.local_unix_path);
async move {
let path = path?;
tokio::fs::remove_file(path).await?;
Ok(())
}
}
}
async fn parse_file_info(
root: &Path,
path: PathBuf,
file_infos: &mut Vec<FileInfo>,
) -> Result<(), TokioLocalStorageError> {
let file_bytes = tokio::fs::read(&path).await?;
let crc32 = crc32fast::hash(&file_bytes);
let local_unix_path = calculate_local_unix_path(root, &path).ok_or(TokioLocalStorageError::InvalidUtf8Filename)?;
file_infos.push(FileInfo { crc32, local_unix_path });
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokio_local_storage_all_files() -> Result<(), Box<dyn std::error::Error>> {
let base_dir = std::env::current_dir()?.join("tests/testfiles");
assert!(base_dir.is_dir());
let mut storage = TokioLocalStorage::new(base_dir, vec![".gitignore".to_string()]);
let mut expected_files = vec![FileInfo {
local_unix_path: "example1.zip".to_string(),
crc32: 858847700,
}];
expected_files.sort_by_key(|e| e.local_unix_path.clone());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let file_infos = rt.block_on(storage.all_files())?;
assert_eq!(file_infos, expected_files);
Ok(())
}
#[test]
fn test_tokio_local_storage_all_files_recursive() -> Result<(), Box<dyn std::error::Error>> {
let base_dir = std::env::current_dir()?.join("tests");
assert!(base_dir.is_dir());
let mut storage = TokioLocalStorage::new(base_dir, vec!["testfiles/.gitignore".to_string()]);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let file_infos = rt.block_on(storage.all_files())?;
println!("{:?}", file_infos);
assert!(file_infos.len() >= 2);
assert!(!file_infos.iter().any(|e| e.local_unix_path == "testfiles/.gitignore"));
assert!(file_infos.iter().any(|e| e.local_unix_path == "testfiles/example1.zip"));
Ok(())
}
#[test]
fn test_sub_path() {
let base_dir = TokioLocalStorage::new(PathBuf::from("/tmp"), Vec::new());
assert_eq!("/tmp/foo", base_dir.sub_path("foo").unwrap().as_os_str());
assert_eq!("/tmp/foo/bar", base_dir.sub_path("foo/bar").unwrap().as_os_str());
assert!(base_dir.sub_path("../illegal").is_err());
assert!(base_dir.sub_path("../root").is_err());
}
}