use crate::error::{PraxisError, PraxisResult};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct FsPolicy {
workspace_root: PathBuf,
}
impl FsPolicy {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
Self {
workspace_root: workspace_root.into(),
}
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn resolve_existing(&self, candidate: &Path) -> PraxisResult<PathBuf> {
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
self.workspace_root.join(candidate)
};
let canonical = joined.canonicalize()?;
self.ensure_within_root(&canonical)?;
Ok(canonical)
}
pub fn resolve_for_write(&self, candidate: &Path) -> PraxisResult<PathBuf> {
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
self.workspace_root.join(candidate)
};
let parent = joined
.parent()
.ok_or_else(|| PraxisError::PathOutsideWorkspace {
path: joined.display().to_string(),
})?;
let canonical_parent = parent.canonicalize()?;
self.ensure_within_root(&canonical_parent)?;
Ok(canonical_parent.join(joined.file_name().unwrap()))
}
fn ensure_within_root(&self, candidate: &Path) -> PraxisResult<()> {
let canonical_root = self.workspace_root.canonicalize().map_err(|e| {
PraxisError::WorkspaceViolation(format!("cannot resolve workspace root: {e}"))
})?;
if candidate.starts_with(&canonical_root) {
Ok(())
} else {
Err(PraxisError::PathOutsideWorkspace {
path: candidate.display().to_string(),
})
}
}
pub fn resolve(&self, path: &str) -> PraxisResult<PathBuf> {
let target = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
self.workspace_root.join(path)
};
if target.exists() {
let canonical = target.canonicalize()?;
let canonical_root = self.workspace_root.canonicalize().map_err(|e| {
PraxisError::WorkspaceViolation(format!("cannot resolve workspace root: {e}"))
})?;
if !canonical.starts_with(&canonical_root) {
return Err(PraxisError::PathOutsideWorkspace {
path: path.to_string(),
});
}
Ok(canonical)
} else {
let parent = target
.parent()
.ok_or_else(|| PraxisError::PathOutsideWorkspace {
path: path.to_string(),
})?;
if parent.exists() {
let canonical_parent = parent.canonicalize()?;
let canonical_root = self.workspace_root.canonicalize().map_err(|e| {
PraxisError::WorkspaceViolation(format!("cannot resolve workspace root: {e}"))
})?;
if !canonical_parent.starts_with(&canonical_root) {
return Err(PraxisError::PathOutsideWorkspace {
path: path.to_string(),
});
}
Ok(target)
} else {
Err(PraxisError::PathOutsideWorkspace {
path: path.to_string(),
})
}
}
}
pub fn relative(&self, absolute_path: &Path) -> Option<PathBuf> {
let canonical_root = self.workspace_root.canonicalize().ok()?;
let canonical_path = absolute_path.canonicalize().ok()?;
canonical_path
.strip_prefix(&canonical_root)
.ok()
.map(|p| p.to_path_buf())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_existing_file_within_workspace() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello").unwrap();
let policy = FsPolicy::new(dir.path());
let resolved = policy.resolve("test.txt").unwrap();
assert!(resolved.exists());
assert!(resolved.starts_with(dir.path().canonicalize().unwrap()));
}
#[test]
fn resolve_absolute_path_within_workspace() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello").unwrap();
let policy = FsPolicy::new(dir.path());
let resolved = policy.resolve(file_path.to_str().unwrap()).unwrap();
assert!(resolved.exists());
}
#[test]
fn resolve_path_outside_workspace_rejected() {
let dir = tempfile::tempdir().unwrap();
let policy = FsPolicy::new(dir.path());
let err = policy.resolve("/etc/passwd").unwrap_err();
assert!(matches!(err, PraxisError::PathOutsideWorkspace { .. }));
}
#[test]
fn resolve_traversal_attack_rejected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let policy = FsPolicy::new(dir.path());
let err = policy.resolve("../../../etc/passwd").unwrap_err();
assert!(matches!(err, PraxisError::PathOutsideWorkspace { .. }));
}
#[test]
fn resolve_new_file_within_workspace() {
let dir = tempfile::tempdir().unwrap();
let policy = FsPolicy::new(dir.path());
let resolved = policy.resolve("new_file.txt").unwrap();
assert!(!resolved.exists());
assert!(resolved.ends_with("new_file.txt"));
}
#[test]
fn relative_path() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("sub/test.txt");
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
std::fs::write(&file_path, "hello").unwrap();
let policy = FsPolicy::new(dir.path());
let rel = policy.relative(&file_path).unwrap();
assert_eq!(rel, PathBuf::from("sub/test.txt"));
}
}