#![allow(non_snake_case)]
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::fs::OpenOptions;
use std::time::SystemTime;
use crate::core::RiResult;
use serde::de::DeserializeOwned;
use serde::Serialize;
#[derive(Clone)]
struct FileSystemImpl {
project_root: PathBuf,
app_data_root: PathBuf,
}
impl FileSystemImpl {
fn new_with_roots(project_root: PathBuf, app_data_root: PathBuf) -> Self {
FileSystemImpl { project_root, app_data_root }
}
fn new_with_root(project_root: PathBuf) -> Self {
let app_data_root = project_root.join(".dms");
FileSystemImpl::new_with_roots(project_root, app_data_root)
}
fn validate_path(&self, path: &Path) -> RiResult<PathBuf> {
let canonical_project = self.project_root.canonicalize()
.map_err(|e| crate::core::RiError::Other(format!("Project root canonicalization failed: {e}")))?;
let canonical_app_data = self.app_data_root.canonicalize()
.map_err(|e| crate::core::RiError::Other(format!("App data root canonicalization failed: {e}")))?;
let canonical_path = if path.exists() {
path.canonicalize()
.map_err(|e| crate::core::RiError::Other(format!("Path canonicalization failed: {e}")))?
} else {
let parent = path.parent().ok_or_else(|| {
crate::core::RiError::Other("Invalid path: no parent directory".to_string())
})?;
if parent.exists() {
let canonical_parent = parent.canonicalize()
.map_err(|e| crate::core::RiError::Other(format!("Parent canonicalization failed: {e}")))?;
let file_name = path.file_name().ok_or_else(|| {
crate::core::RiError::Other("Invalid path: no file name".to_string())
})?;
canonical_parent.join(file_name)
} else {
return Err(crate::core::RiError::Other(
"Path validation failed: parent directory does not exist".to_string()
));
}
};
if !canonical_path.starts_with(&canonical_project) &&
!canonical_path.starts_with(&canonical_app_data) {
return Err(crate::core::RiError::Other(
"Path traversal detected: path is outside allowed directories".to_string()
));
}
let mut current = canonical_path.clone();
while let Some(parent) = current.parent() {
if parent.join(current.file_name().unwrap_or_default()).exists() {
let metadata = std::fs::symlink_metadata(¤t)
.map_err(|e| crate::core::RiError::Other(format!("Symlink metadata check failed: {e}")))?;
if metadata.file_type().is_symlink() {
let symlink_target = std::fs::read_link(¤t)
.map_err(|e| crate::core::RiError::Other(format!("Failed to read symlink: {e}")))?;
let canonical_target = symlink_target.canonicalize()
.map_err(|e| crate::core::RiError::Other(format!("Symlink target canonicalization failed: {e}")))?;
if !canonical_target.starts_with(&canonical_project) &&
!canonical_target.starts_with(&canonical_app_data) {
return Err(crate::core::RiError::Other(
"Symlink points outside allowed directories".to_string()
));
}
}
}
current = parent.to_path_buf();
}
Ok(canonical_path)
}
fn resolve_and_validate_path(&self, path: &Path) -> RiResult<PathBuf> {
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
self.project_root.join(path)
};
self.validate_path(&resolved)
}
fn project_root(&self) -> &Path {
&self.project_root
}
fn safe_mkdir(&self, path: &Path) -> RiResult<PathBuf> {
fs::create_dir_all(path).map_err(|e| crate::core::RiError::Other(format!("safe_mkdir failed: {e}")))?;
Ok(path.to_path_buf())
}
fn ensure_parent_dir(&self, path: &Path) -> RiResult<PathBuf> {
if let Some(parent) = path.parent() {
self.safe_mkdir(parent)
} else {
Ok(self.project_root.clone())
}
}
fn atomic_write_text(&self, path: &Path, text: &str) -> RiResult<()> {
let validated_path = self.resolve_and_validate_path(path)?;
self.ensure_parent_dir(&validated_path)?;
let dir = validated_path.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| crate::core::RiError::Other(format!("timestamp error: {e}")))?;
let tmp_name = format!(".tmp_{}_{}", ts.as_millis(), validated_path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"));
let tmp_path = dir.join(tmp_name);
{
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_text open tmp failed: {e}")))?;
file.write_all(text.as_bytes())
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_text write failed: {e}")))?;
file.sync_all()
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_text sync failed: {e}")))?;
}
fs::rename(&tmp_path, &validated_path)
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_text rename failed: {e}")))?;
Ok(())
}
fn atomic_write_bytes(&self, path: &Path, data: &[u8]) -> RiResult<()> {
let validated_path = self.resolve_and_validate_path(path)?;
self.ensure_parent_dir(&validated_path)?;
let dir = validated_path.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| crate::core::RiError::Other(format!("timestamp error: {e}")))?;
let tmp_name = format!(".tmp_{}_{}", ts.as_millis(), validated_path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"));
let tmp_path = dir.join(tmp_name);
{
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes open tmp failed: {e}")))?;
file.write_all(data)
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes write failed: {e}")))?;
file.sync_all()
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes sync failed: {e}")))?;
}
fs::rename(&tmp_path, &validated_path)
.map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes rename failed: {e}")))?;
Ok(())
}
fn read_text(&self, path: &Path) -> RiResult<String> {
let validated_path = self.resolve_and_validate_path(path)?;
let mut file = OpenOptions::new()
.read(true)
.open(&validated_path)
.map_err(|e| crate::core::RiError::Other(format!("read_text open failed: {e}")))?;
let mut buf = String::new();
file.read_to_string(&mut buf)
.map_err(|e| crate::core::RiError::Other(format!("read_text read failed: {e}")))?;
Ok(buf)
}
fn app_dir(&self) -> PathBuf {
let _ = fs::create_dir_all(&self.app_data_root);
self.app_data_root.clone()
}
fn category_dir(&self, name: &str) -> PathBuf {
let dir = self.app_dir().join(name);
let _ = fs::create_dir_all(&dir);
dir
}
}
#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
#[derive(Clone)]
pub struct RiFileSystem {
inner: FileSystemImpl,
}
impl RiFileSystem {
pub fn new_with_root(project_root: PathBuf) -> Self {
let inner = FileSystemImpl::new_with_root(project_root);
RiFileSystem { inner }
}
pub fn new_with_roots(project_root: PathBuf, app_data_root: PathBuf) -> Self {
let inner = FileSystemImpl::new_with_roots(project_root, app_data_root);
RiFileSystem { inner }
}
pub fn new_auto_root() -> RiResult<Self> {
let cwd = std::env::current_dir()
.map_err(|e| crate::core::RiError::Other(format!("detect project root failed: {e}")))?;
Ok(Self::new_with_root(cwd))
}
pub fn project_root(&self) -> &Path {
self.inner.project_root()
}
pub fn safe_mkdir<P: AsRef<Path>>(&self, path: P) -> RiResult<PathBuf> {
self.inner.safe_mkdir(path.as_ref())
}
pub fn ensure_parent_dir<P: AsRef<Path>>(&self, path: P) -> RiResult<PathBuf> {
self.inner.ensure_parent_dir(path.as_ref())
}
pub fn atomic_write_text<P: AsRef<Path>>(&self, path: P, text: &str) -> RiResult<()> {
self.inner.atomic_write_text(path.as_ref(), text)
}
pub fn atomic_write_bytes<P: AsRef<Path>>(&self, path: P, data: &[u8]) -> RiResult<()> {
self.inner.atomic_write_bytes(path.as_ref(), data)
}
pub fn read_text<P: AsRef<Path>>(&self, path: P) -> RiResult<String> {
self.inner.read_text(path.as_ref())
}
pub fn read_json<P: AsRef<Path>, T: DeserializeOwned>(&self, path: P) -> RiResult<T> {
let text = self.read_text(path)?;
serde_json::from_str(&text)
.map_err(|e| crate::core::RiError::Other(format!("json read failed: {e}")))
}
pub fn exists<P: AsRef<Path>>(&self, path: P) -> bool {
path.as_ref().exists()
}
pub fn remove_file<P: AsRef<Path>>(&self, path: P) -> RiResult<()> {
let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
match fs::remove_file(&validated_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(crate::core::RiError::Other(format!("remove_file failed: {e}"))),
}
}
pub fn remove_dir_all<P: AsRef<Path>>(&self, path: P) -> RiResult<()> {
let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
match fs::remove_dir_all(&validated_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(crate::core::RiError::Other(format!("remove_dir_all failed: {e}"))),
}
}
pub fn copy_file<P: AsRef<Path>, Q: AsRef<Path>>(&self, from: P, to: Q) -> RiResult<()> {
let src = self.inner.resolve_and_validate_path(from.as_ref())?;
let dst = self.inner.resolve_and_validate_path(to.as_ref())?;
if let Some(parent) = dst.parent() {
self.safe_mkdir(parent)?;
}
fs::copy(&src, &dst)
.map_err(|e| crate::core::RiError::Other(format!("copy_file failed: {e}")))?;
Ok(())
}
pub fn append_text<P: AsRef<Path>>(&self, path: P, text: &str) -> RiResult<()> {
use std::io::Write as _;
let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
self.ensure_parent_dir(&validated_path)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&validated_path)
.map_err(|e| crate::core::RiError::Other(format!("append_text open failed: {e}")))?;
file.write_all(text.as_bytes())
.map_err(|e| crate::core::RiError::Other(format!("append_text write failed: {e}")))?;
file.flush()
.map_err(|e| crate::core::RiError::Other(format!("append_text flush failed: {e}")))?;
Ok(())
}
pub fn write_json<P: AsRef<Path>, T: Serialize>(&self, path: P, value: &T) -> RiResult<()> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| crate::core::RiError::Other(format!("json serialize failed: {e}")))?;
self.atomic_write_text(path, &text)
}
pub fn app_dir(&self) -> PathBuf {
self.inner.app_dir()
}
pub fn logs_dir(&self) -> PathBuf {
self.inner.category_dir("logs")
}
pub fn cache_dir(&self) -> PathBuf {
self.inner.category_dir("cache")
}
pub fn reports_dir(&self) -> PathBuf {
self.inner.category_dir("reports")
}
pub fn observability_dir(&self) -> PathBuf {
self.inner.category_dir("observability")
}
pub fn temp_dir(&self) -> PathBuf {
self.inner.category_dir("tmp")
}
pub fn ensure_category_path<S: AsRef<str>, P: AsRef<Path>>(&self, category: S, path_or_name: P) -> PathBuf {
let base = match category.as_ref() {
"logs" => self.logs_dir(),
"cache" => self.cache_dir(),
"reports" => self.reports_dir(),
"observability" => self.observability_dir(),
"tmp" => self.temp_dir(),
_ => self.app_dir(),
};
let target = base.join(path_or_name.as_ref());
let _ = fs::create_dir_all(target.parent().unwrap_or(&base));
target
}
pub fn normalize_under_category<S: AsRef<str>, P: AsRef<Path>>(&self, category: S, path_or_name: P) -> PathBuf {
let name = path_or_name.as_ref().file_name().unwrap_or_else(|| std::ffi::OsStr::new(""));
self.ensure_category_path(category, PathBuf::from(name))
}
}
#[cfg(feature = "pyo3")]
#[pyo3::prelude::pymethods]
impl RiFileSystem {
#[new]
fn py_new(project_root: String) -> Result<Self, pyo3::prelude::PyErr> {
let path = PathBuf::from(project_root);
Ok(Self::new_with_root(path))
}
#[staticmethod]
fn new_auto_root_py() -> Result<Self, pyo3::PyErr> {
Self::new_auto_root()
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to create filesystem: {e}")))
}
#[pyo3(name = "atomic_write_text")]
fn atomic_write_text_impl(&self, path: String, text: String) -> Result<(), pyo3::PyErr> {
self.atomic_write_text(&path, &text)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write text: {e}")))
}
#[pyo3(name = "atomic_write_bytes")]
fn atomic_write_bytes_impl(&self, path: String, data: Vec<u8>) -> Result<(), pyo3::PyErr> {
self.atomic_write_bytes(&path, &data)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write bytes: {e}")))
}
#[pyo3(name = "read_text")]
fn read_text_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
self.read_text(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to read text: {e}")))
}
#[pyo3(name = "write_json")]
fn write_json_impl(&self, path: String, value: String) -> Result<(), pyo3::PyErr> {
let json_value: serde_json::Value = serde_json::from_str(&value)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Invalid JSON: {e}")))?;
self.write_json(&path, &json_value)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write JSON: {e}")))
}
#[pyo3(name = "read_json")]
fn read_json_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
let json_value: serde_json::Value = self.read_json(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to read JSON: {e}")))?;
serde_json::to_string_pretty(&json_value)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to serialize JSON: {e}")))
}
#[pyo3(name = "exists")]
fn exists_impl(&self, path: String) -> bool {
self.exists(&path)
}
#[pyo3(name = "remove_file")]
fn remove_file_impl(&self, path: String) -> Result<(), pyo3::PyErr> {
self.remove_file(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to remove file: {e}")))
}
#[pyo3(name = "remove_dir_all")]
fn remove_dir_all_impl(&self, path: String) -> Result<(), pyo3::PyErr> {
self.remove_dir_all(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to remove directory: {e}")))
}
#[pyo3(name = "copy_file")]
fn copy_file_impl(&self, from: String, to: String) -> Result<(), pyo3::PyErr> {
self.copy_file(&from, &to)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to copy file: {e}")))
}
#[pyo3(name = "append_text")]
fn append_text_impl(&self, path: String, text: String) -> Result<(), pyo3::PyErr> {
self.append_text(&path, &text)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to append text: {e}")))
}
#[pyo3(name = "safe_mkdir")]
fn safe_mkdir_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
let result = self.safe_mkdir(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to create directory: {e}")))?;
Ok(result.to_string_lossy().to_string())
}
#[pyo3(name = "ensure_parent_dir")]
fn ensure_parent_dir_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
let result = self.ensure_parent_dir(&path)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to ensure parent directory: {e}")))?;
Ok(result.to_string_lossy().to_string())
}
#[pyo3(name = "get_project_root")]
fn get_project_root_impl(&self) -> String {
self.project_root().to_string_lossy().to_string()
}
#[pyo3(name = "get_app_dir")]
fn get_app_dir_impl(&self) -> String {
self.app_dir().to_string_lossy().to_string()
}
#[pyo3(name = "get_logs_dir")]
fn get_logs_dir_impl(&self) -> String {
self.logs_dir().to_string_lossy().to_string()
}
#[pyo3(name = "get_cache_dir")]
fn get_cache_dir_impl(&self) -> String {
self.cache_dir().to_string_lossy().to_string()
}
#[pyo3(name = "get_reports_dir")]
fn get_reports_dir_impl(&self) -> String {
self.reports_dir().to_string_lossy().to_string()
}
#[pyo3(name = "get_observability_dir")]
fn get_observability_dir_impl(&self) -> String {
self.observability_dir().to_string_lossy().to_string()
}
#[pyo3(name = "get_temp_dir")]
fn get_temp_dir_impl(&self) -> String {
self.temp_dir().to_string_lossy().to_string()
}
#[pyo3(name = "ensure_category_path")]
fn ensure_category_path_impl(&self, category: String, path_or_name: String) -> String {
self.ensure_category_path(&category, &path_or_name)
.to_string_lossy()
.to_string()
}
#[pyo3(name = "normalize_under_category")]
fn normalize_under_category_impl(&self, category: String, path_or_name: String) -> String {
self.normalize_under_category(&category, &path_or_name)
.to_string_lossy()
.to_string()
}
}