use crate::security::{AccessError, Policy, display_path};
use serde::Serialize;
use std::{
fs,
io::{Read, Write},
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FsError {
#[error(transparent)]
Access(#[from] AccessError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("operation exceeds configured limit of {limit} bytes")]
Limit {
limit: usize,
},
#[error("content is not valid UTF-8")]
Utf8,
#[error("search text must not be empty")]
EmptyPattern,
#[error("expected {expected} replacements, found {actual}")]
Conflict {
expected: usize,
actual: usize,
},
}
#[derive(Debug, Serialize)]
pub struct Entry {
pub name: String,
pub path: String,
pub kind: &'static str,
pub size: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
pub name: String,
pub display_path: String,
pub kind: &'static str,
pub size: Option<u64>,
pub read_only: bool,
pub created_ms: Option<i64>,
pub modified_ms: Option<i64>,
pub accessed_ms: Option<i64>,
pub symlink_target: Option<String>,
pub blake3: Option<String>,
}
fn system_time_ms(value: Option<SystemTime>) -> Option<i64> {
value
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.and_then(|d| i64::try_from(d.as_millis()).ok())
}
#[derive(Clone)]
pub struct Filesystem {
policy: Policy,
max_read: usize,
max_write: usize,
}
impl Filesystem {
pub fn new(policy: Policy, max_read: usize, max_write: usize) -> Self {
Self {
policy,
max_read,
max_write,
}
}
pub fn list(&self, path: &Path) -> Result<Vec<Entry>, FsError> {
let path = self.policy.read_path(path)?;
let mut result = Vec::new();
for item in fs::read_dir(path)? {
let item = item?;
let meta = item.metadata()?;
let file_type = item.file_type()?;
result.push(Entry {
name: item.file_name().to_string_lossy().into_owned(),
path: display_path(&item.path()),
kind: if file_type.is_symlink() {
"symlink"
} else if meta.is_dir() {
"directory"
} else {
"file"
},
size: meta.len(),
});
}
result.sort_unstable_by(|a, b| a.name.cmp(&b.name));
Ok(result)
}
pub fn read(&self, path: &Path, offset: u64, length: usize) -> Result<Vec<u8>, FsError> {
if length > self.max_read {
return Err(FsError::Limit {
limit: self.max_read,
});
}
let path = self.policy.read_path(path)?;
let mut file = fs::File::open(path)?;
use std::io::Seek;
file.seek(std::io::SeekFrom::Start(offset))?;
let mut data = Vec::with_capacity(length);
file.take(length as u64).read_to_end(&mut data)?;
Ok(data)
}
pub fn write(&self, path: &Path, data: &[u8]) -> Result<String, FsError> {
let (hash, _) = self.write_with_parents(path, data, false)?;
Ok(hash)
}
pub fn write_with_parents(
&self,
path: &Path,
data: &[u8],
create_parents: bool,
) -> Result<(String, Vec<String>), FsError> {
if data.len() > self.max_write {
return Err(FsError::Limit {
limit: self.max_write,
});
}
let (path, created) = self.policy.write_path_with_parents(path, create_parents)?;
let parent = path
.parent()
.ok_or_else(|| AccessError::Invalid(path.display().to_string()))?;
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(data)?;
file.as_file().sync_all()?;
file.persist(&path).map_err(|error| error.error)?;
Ok((
blake3::hash(data).to_hex().to_string(),
created.iter().map(|path| display_path(path)).collect(),
))
}
pub fn read_all(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path = self.policy.read_path(path)?;
let meta = fs::metadata(&path)?;
if meta.len() > self.max_read as u64 {
return Err(FsError::Limit {
limit: self.max_read,
});
}
let file = fs::File::open(path)?;
let mut data = Vec::with_capacity(meta.len() as usize);
file.take(self.max_read as u64 + 1).read_to_end(&mut data)?;
if data.len() > self.max_read {
return Err(FsError::Limit {
limit: self.max_read,
});
}
Ok(data)
}
pub fn max_write_bytes(&self) -> usize {
self.max_write
}
pub fn check_write(&self, path: &Path) -> Result<(), FsError> {
self.policy.write_path(path)?;
Ok(())
}
pub fn write_existing(&self, path: &Path, data: &[u8]) -> Result<String, FsError> {
self.policy.read_path(path)?;
self.write(path, data)
}
pub fn file_info(&self, path: &Path, include_hash: bool) -> Result<FileInfo, FsError> {
let path = self.policy.read_path(path)?;
let metadata = fs::metadata(&path)?;
let kind = if metadata.is_file() {
"file"
} else if metadata.is_dir() {
"directory"
} else {
"other"
};
let blake3 = if include_hash && metadata.is_file() {
Some(self.hash(&path)?)
} else {
None
};
Ok(FileInfo {
name: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
display_path: display_path(&path),
kind,
size: metadata.is_file().then_some(metadata.len()),
read_only: metadata.permissions().readonly(),
created_ms: system_time_ms(metadata.created().ok()),
modified_ms: system_time_ms(metadata.modified().ok()),
accessed_ms: system_time_ms(metadata.accessed().ok()),
symlink_target: None,
blake3,
})
}
pub fn create_directory(&self, path: &Path) -> Result<(), FsError> {
fs::create_dir(self.policy.write_path(path)?)?;
Ok(())
}
pub fn move_path(&self, source: &Path, destination: &Path) -> Result<(), FsError> {
let source = self.policy.read_path(source)?;
let destination = self.policy.write_path(destination)?;
if destination.exists() {
return Err(FsError::Io(std::io::ErrorKind::AlreadyExists.into()));
}
fs::rename(source, destination)?;
Ok(())
}
pub fn remove(&self, path: &Path) -> Result<(), FsError> {
let path = self.policy.read_path(path)?;
let checked = self.policy.write_path(&path)?;
if checked.is_dir() {
fs::remove_dir(checked)?;
} else {
fs::remove_file(checked)?;
}
Ok(())
}
pub fn hash(&self, path: &Path) -> Result<String, FsError> {
let path = self.policy.read_path(path)?;
let mut file = fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finalize().to_hex().to_string())
}
pub fn edit(
&self,
path: &Path,
old: &str,
new: &str,
expected: usize,
) -> Result<String, FsError> {
if old.is_empty() {
return Err(FsError::EmptyPattern);
}
let bytes = self.read(path, 0, self.max_read)?;
let text = String::from_utf8(bytes).map_err(|_| FsError::Utf8)?;
let actual = text.matches(old).count();
if actual != expected {
return Err(FsError::Conflict { expected, actual });
}
self.write(path, text.replacen(old, new, expected).as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_reads_and_hashes() {
let root = tempfile::tempdir().unwrap();
let policy = Policy::new(vec![root.path().to_owned()], false, false).unwrap();
let filesystem = Filesystem::new(policy, 1024, 1024);
let path = root.path().join("sample.txt");
let hash = filesystem.write(&path, b"hello").unwrap();
assert_eq!(filesystem.read(&path, 0, 5).unwrap(), b"hello");
assert_eq!(hash, blake3::hash(b"hello").to_hex().to_string());
}
#[test]
fn rejects_oversized_reads_and_writes() {
let root = tempfile::tempdir().unwrap();
let policy = Policy::new(vec![root.path().to_owned()], false, false).unwrap();
let filesystem = Filesystem::new(policy, 4, 4);
let path = root.path().join("sample");
assert!(matches!(
filesystem.write(&path, b"12345"),
Err(FsError::Limit { .. })
));
filesystem.write(&path, b"1234").unwrap();
assert!(matches!(
filesystem.read(&path, 0, 5),
Err(FsError::Limit { .. })
));
}
#[test]
fn creates_missing_parent_directories_when_requested() {
let root = tempfile::tempdir().unwrap();
let policy = Policy::new(vec![root.path().to_owned()], false, false).unwrap();
let filesystem = Filesystem::new(policy, 1024, 1024);
let path = root.path().join("reports").join("2026").join("result.txt");
let (_, created) = filesystem
.write_with_parents(&path, b"ready", true)
.unwrap();
assert_eq!(created.len(), 2);
assert_eq!(filesystem.read(&path, 0, 5).unwrap(), b"ready");
}
#[test]
fn requires_existing_parent_by_default() {
let root = tempfile::tempdir().unwrap();
let policy = Policy::new(vec![root.path().to_owned()], false, false).unwrap();
let filesystem = Filesystem::new(policy, 1024, 1024);
let path = root.path().join("missing").join("result.txt");
assert!(filesystem.write(&path, b"ready").is_err());
assert!(!root.path().join("missing").exists());
}
#[test]
fn read_only_mode_never_creates_parent_directories() {
let root = tempfile::tempdir().unwrap();
let policy = Policy::new(vec![root.path().to_owned()], true, false).unwrap();
let filesystem = Filesystem::new(policy, 1024, 1024);
let path = root.path().join("missing").join("result.txt");
assert!(matches!(
filesystem.write_with_parents(&path, b"ready", true),
Err(FsError::Access(AccessError::ReadOnly))
));
assert!(!root.path().join("missing").exists());
}
}