#![allow(clippy::new_ret_no_self, clippy::unused_unit)]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use id_effect::kernel::Effect;
use id_effect::{Env, Needs, ProviderError, ProviderSpec};
use crate::error::FsError;
pub trait FileSystem: Send + Sync + 'static {
fn read(&self, path: &Path) -> Effect<Vec<u8>, FsError, ()>;
fn write(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()>;
fn append(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()>;
fn create_dir_all(&self, path: &Path) -> Effect<(), FsError, ()>;
fn remove_file(&self, path: &Path) -> Effect<(), FsError, ()>;
fn metadata_len(&self, path: &Path) -> Effect<u64, FsError, ()>;
fn exists(&self, path: &Path) -> Effect<bool, FsError, ()>;
}
#[derive(Clone, Default)]
pub struct LiveFileSystem;
impl LiveFileSystem {
#[inline]
pub fn new() -> Self {
Self
}
}
impl FileSystem for LiveFileSystem {
fn read(&self, path: &Path) -> Effect<Vec<u8>, FsError, ()> {
let path = path.to_path_buf();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move { tokio::fs::read(&path).await.map_err(FsError::from) })
})
}
fn write(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()> {
let path = path.to_path_buf();
let data = data.to_vec();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move { tokio::fs::write(&path, &data).await.map_err(FsError::from) })
})
}
fn append(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()> {
let path = path.to_path_buf();
let data = data.to_vec();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move {
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
.map_err(FsError::from)?;
f.write_all(&data).await.map_err(FsError::from)?;
f.flush().await.map_err(FsError::from)?;
Ok(())
})
})
}
fn create_dir_all(&self, path: &Path) -> Effect<(), FsError, ()> {
let path = path.to_path_buf();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move {
tokio::fs::create_dir_all(&path)
.await
.map_err(FsError::from)
})
})
}
fn remove_file(&self, path: &Path) -> Effect<(), FsError, ()> {
let path = path.to_path_buf();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move { tokio::fs::remove_file(&path).await.map_err(FsError::from) })
})
}
fn metadata_len(&self, path: &Path) -> Effect<u64, FsError, ()> {
let path = path.to_path_buf();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move {
let m = tokio::fs::metadata(&path).await.map_err(FsError::from)?;
Ok(m.len())
})
})
}
fn exists(&self, path: &Path) -> Effect<bool, FsError, ()> {
let path = path.to_path_buf();
Effect::new_async(move |_r: &mut ()| {
Box::pin(async move { tokio::fs::try_exists(&path).await.map_err(FsError::from) })
})
}
}
#[derive(Clone, Default)]
pub struct TestFileSystem {
inner: Arc<Mutex<std::collections::BTreeMap<String, Vec<u8>>>>,
}
impl TestFileSystem {
#[inline]
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(std::collections::BTreeMap::new())),
}
}
#[cfg(test)]
fn poison_inner_mutex(&self) {
let inner = Arc::clone(&self.inner);
let handle = std::thread::spawn(move || {
let _guard = inner.lock().expect("lock");
panic!("test mutex poison");
});
assert!(handle.join().is_err());
}
fn key(path: &Path) -> Result<String, FsError> {
let s = path
.to_str()
.ok_or_else(|| FsError::PathNotAllowed("non-utf8 path".into()))?;
if s.contains("..") {
return Err(FsError::PathNotAllowed(
"`..` not allowed in test paths".into(),
));
}
Ok(s.to_string())
}
}
impl FileSystem for TestFileSystem {
fn read(&self, path: &Path) -> Effect<Vec<u8>, FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
Effect::new(move |_r: &mut ()| {
let map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
map.get(&key).cloned().ok_or_else(|| {
FsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"test file not found",
))
})
})
}
fn write(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
let data = data.to_vec();
Effect::new(move |_r: &mut ()| {
let mut map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
map.insert(key, data);
Ok(())
})
}
fn append(&self, path: &Path, data: &[u8]) -> Effect<(), FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
let data = data.to_vec();
Effect::new(move |_r: &mut ()| {
let mut map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
map.entry(key).or_default().extend_from_slice(&data);
Ok(())
})
}
fn create_dir_all(&self, _path: &Path) -> Effect<(), FsError, ()> {
id_effect::succeed(())
}
fn remove_file(&self, path: &Path) -> Effect<(), FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
Effect::new(move |_r: &mut ()| {
let mut map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
map.remove(&key).ok_or_else(|| {
FsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"test file not found",
))
})?;
Ok(())
})
}
fn metadata_len(&self, path: &Path) -> Effect<u64, FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
Effect::new(move |_r: &mut ()| {
let map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
let v = map.get(&key).ok_or_else(|| {
FsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"test file not found",
))
})?;
Ok(v.len() as u64)
})
}
fn exists(&self, path: &Path) -> Effect<bool, FsError, ()> {
let key = match Self::key(path) {
Ok(k) => k,
Err(e) => return id_effect::fail(e),
};
let inner = Arc::clone(&self.inner);
Effect::new(move |_r: &mut ()| {
let map = inner
.lock()
.map_err(|e| FsError::PathNotAllowed(e.to_string()))?;
Ok(map.contains_key(&key))
})
}
}
pub type FileSystemService = Arc<dyn FileSystem>;
#[derive(::id_effect::ProviderSpecDerive)]
#[provides(FileSystemService)]
pub struct LiveFileSystemProvider;
impl LiveFileSystemProvider {
fn new() -> FileSystemService {
Arc::new(LiveFileSystem::new())
}
}
#[inline]
pub fn read<R>(path: PathBuf) -> Effect<Vec<u8>, FsError, R>
where
R: Needs<FileSystemService> + 'static,
{
Effect::new_async(move |r: &mut R| {
let fs = r.need().clone();
let inner = fs.read(&path);
Box::pin(async move { inner.run(&mut ()).await })
})
}
#[inline]
pub fn exists<R>(path: PathBuf) -> Effect<bool, FsError, R>
where
R: Needs<FileSystemService> + 'static,
{
Effect::new_async(move |r: &mut R| {
let fs = r.need().clone();
let inner = fs.exists(&path);
Box::pin(async move { inner.run(&mut ()).await })
})
}
#[cfg(test)]
mod tests {
use super::*;
use id_effect::run_blocking;
use std::path::Path;
#[test]
fn filesystem_service_type_alias_is_object_safe() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<FileSystemService>();
}
mod test_file_system {
use super::*;
mod path_policy {
use super::*;
#[test]
fn write_rejects_when_path_contains_dotdot() {
let fs = TestFileSystem::new();
let err = run_blocking(fs.write(Path::new("a/../b.txt"), b"x"), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[cfg(unix)]
#[test]
fn write_rejects_when_path_not_utf8() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let fs = TestFileSystem::new();
let p = PathBuf::from(OsString::from_vec(vec![0xFF, 0xFE]));
let err = run_blocking(fs.write(&p, b"x"), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
}
mod exists {
use super::*;
#[test]
fn false_before_write_true_after() {
let fs = TestFileSystem::new();
let p = Path::new("probe.txt");
assert!(!run_blocking(fs.exists(p), ()).unwrap());
run_blocking(fs.write(p, b"x"), ()).unwrap();
assert!(run_blocking(fs.exists(p), ()).unwrap());
}
#[test]
fn false_after_remove() {
let fs = TestFileSystem::new();
let p = Path::new("gone.txt");
run_blocking(fs.write(p, b"x"), ()).unwrap();
run_blocking(fs.remove_file(p), ()).unwrap();
assert!(!run_blocking(fs.exists(p), ()).unwrap());
}
}
mod read_write_round_trip {
use super::*;
#[test]
fn read_returns_bytes_after_write() {
let fs = TestFileSystem::new();
let p = Path::new("dir/file.bin");
run_blocking(fs.write(p, b"payload"), ()).unwrap();
let got = run_blocking(fs.read(p), ()).unwrap();
assert_eq!(got, b"payload");
}
#[test]
fn append_extends_existing_file() {
let fs = TestFileSystem::new();
let p = Path::new("log.txt");
run_blocking(fs.write(p, b"a"), ()).unwrap();
run_blocking(fs.append(p, b"b"), ()).unwrap();
let got = run_blocking(fs.read(p), ()).unwrap();
assert_eq!(got, b"ab");
}
#[test]
fn metadata_len_matches_written_length() {
let fs = TestFileSystem::new();
let p = Path::new("sized.dat");
let data = vec![0u8; 42];
run_blocking(fs.write(p, &data), ()).unwrap();
let n = run_blocking(fs.metadata_len(p), ()).unwrap();
assert_eq!(n, 42);
}
#[test]
fn remove_file_deletes_then_read_fails() {
let fs = TestFileSystem::new();
let p = Path::new("gone.txt");
run_blocking(fs.write(p, b"x"), ()).unwrap();
run_blocking(fs.remove_file(p), ()).unwrap();
let err = run_blocking(fs.read(p), ()).unwrap_err();
assert!(matches!(err, FsError::Io(_)));
}
}
mod create_dir_all {
use super::*;
#[test]
fn succeeds_without_mutating_store() {
let fs = TestFileSystem::new();
run_blocking(fs.create_dir_all(Path::new("any/nested")), ()).unwrap();
}
}
mod poisoned_mutex {
use super::*;
#[test]
fn read_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.read(Path::new("ok.txt")), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[test]
fn write_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.write(Path::new("ok.txt"), b"x"), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[test]
fn append_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.append(Path::new("ok.txt"), b"x"), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[test]
fn remove_file_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.remove_file(Path::new("ok.txt")), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[test]
fn metadata_len_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.metadata_len(Path::new("ok.txt")), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
#[test]
fn exists_maps_lock_poison_to_path_not_allowed() {
let fs = TestFileSystem::new();
fs.poison_inner_mutex();
let err = run_blocking(fs.exists(Path::new("ok.txt")), ()).unwrap_err();
assert!(matches!(err, FsError::PathNotAllowed(_)));
}
}
}
}