use crate::utils::error::{Error, Result};
use std::convert::TryFrom;
use std::fmt;
use std::path::{Path, PathBuf};
const MAX_PATH_DEPTH: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SafePath {
inner: PathBuf,
}
impl SafePath {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
Self::validate_path(path)?;
Ok(Self {
inner: Self::normalize_path(path),
})
}
pub fn join<P: AsRef<Path>>(&self, path: P) -> Result<Self> {
let joined = self.inner.join(path.as_ref());
Self::validate_path(&joined)?;
Ok(Self {
inner: Self::normalize_path(&joined),
})
}
#[must_use]
pub fn as_path(&self) -> &Path {
&self.inner
}
#[must_use]
pub fn into_path_buf(self) -> PathBuf {
self.inner
}
#[must_use]
pub fn as_path_buf(&self) -> PathBuf {
self.inner.clone()
}
pub fn new_absolute<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::invalid_input("Path cannot be empty"));
}
for component in path.components() {
if let std::path::Component::ParentDir = component {
return Err(Error::invalid_input(format!(
"Path contains parent directory reference (..): {}",
path.display()
)));
}
}
Ok(Self {
inner: path.to_path_buf(),
})
}
pub fn current_dir() -> Result<Self> {
let cwd = std::env::current_dir()
.map_err(|e| Error::new(&format!("Failed to get current directory: {}", e)))?;
Self::new_absolute(cwd)
}
pub fn parent(&self) -> Result<Self> {
let parent_path = self
.inner
.parent()
.ok_or_else(|| Error::invalid_input("Path has no parent"))?;
if parent_path.is_absolute() {
Self::new_absolute(parent_path)
} else {
Self::new(parent_path)
}
}
#[must_use]
pub fn exists(&self) -> bool {
self.inner.exists()
}
fn validate_path(path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(Error::invalid_input("Path cannot be empty"));
}
let normalized = Self::normalize_path(path);
let components: Vec<_> = normalized.components().collect();
if components.len() > MAX_PATH_DEPTH {
return Err(Error::invalid_input(format!(
"Path depth {} exceeds maximum allowed depth of {}",
components.len(),
MAX_PATH_DEPTH
)));
}
for component in path.components() {
if let std::path::Component::ParentDir = component {
return Err(Error::invalid_input(format!(
"Path contains parent directory reference (..): {}",
path.display()
)));
}
}
for component in &components {
if let std::path::Component::Normal(os_str) = component {
if let Some(s) = os_str.to_str() {
if s.trim().is_empty() {
return Err(Error::invalid_input(
"Path components cannot be empty or whitespace-only",
));
}
}
}
}
Ok(())
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {
}
std::path::Component::Normal(_) => {
normalized.push(component);
}
_ => {
normalized.push(component);
}
}
}
normalized
}
}
impl TryFrom<&str> for SafePath {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<String> for SafePath {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<&String> for SafePath {
type Error = Error;
fn try_from(value: &String) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<PathBuf> for SafePath {
type Error = Error;
fn try_from(value: PathBuf) -> Result<Self> {
Self::new(value)
}
}
impl TryFrom<&Path> for SafePath {
type Error = Error;
fn try_from(value: &Path) -> Result<Self> {
Self::new(value)
}
}
impl fmt::Display for SafePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.inner.display())
}
}
impl AsRef<Path> for SafePath {
fn as_ref(&self) -> &Path {
&self.inner
}
}
impl AsRef<PathBuf> for SafePath {
fn as_ref(&self) -> &PathBuf {
&self.inner
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_simple_path() {
let path = "src/generated";
let result = SafePath::new(path);
assert!(result.is_ok());
let safe_path = result.unwrap();
assert_eq!(safe_path.as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_new_single_component() {
let path = "test";
let result = SafePath::new(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "test");
}
#[test]
fn test_new_with_current_dir_prefix() {
let path = "./output";
let result = SafePath::new(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "output");
}
#[test]
fn test_new_with_multiple_current_dirs() {
let path = "./src/./generated/./output";
let result = SafePath::new(path);
assert!(result.is_ok());
assert_eq!(
result.unwrap().as_path().to_str().unwrap(),
"src/generated/output"
);
}
#[test]
fn test_new_parent_dir_fails() {
let path = "../etc/passwd";
let result = SafePath::new(path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("parent directory reference"));
}
#[test]
fn test_new_parent_dir_in_middle_fails() {
let path = "src/../../../etc/passwd";
let result = SafePath::new(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parent directory"));
}
#[test]
fn test_new_parent_dir_at_end_fails() {
let path = "src/generated/..";
let result = SafePath::new(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parent directory"));
}
#[test]
fn test_new_multiple_parent_dirs_fails() {
let path = "../../../../../../etc/passwd";
let result = SafePath::new(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parent directory"));
}
#[test]
fn test_new_empty_path_fails() {
let path = "";
let result = SafePath::new(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_new_whitespace_component_fails() {
let path = "src/ /generated";
let result = SafePath::new(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("whitespace-only"));
}
#[test]
fn test_new_max_depth_allowed() {
let components: Vec<String> = (0..MAX_PATH_DEPTH).map(|i| format!("level{}", i)).collect();
let path = components.join("/");
let result = SafePath::new(&path);
assert!(result.is_ok());
}
#[test]
fn test_new_exceeds_max_depth_fails() {
let components: Vec<String> = (0..=MAX_PATH_DEPTH)
.map(|i| format!("level{}", i))
.collect();
let path = components.join("/");
let result = SafePath::new(&path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("exceeds maximum allowed depth"));
assert!(err.to_string().contains("20"));
}
#[test]
fn test_join_simple() {
let base = SafePath::new("src").unwrap();
let result = base.join("generated");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_join_multiple_times() {
let base = SafePath::new("src").unwrap();
let step1 = base.join("generated").unwrap();
let step2 = step1.join("output").unwrap();
let result = step2.join("file.rs");
assert!(result.is_ok());
assert_eq!(
result.unwrap().as_path().to_str().unwrap(),
"src/generated/output/file.rs"
);
}
#[test]
fn test_join_with_parent_dir_fails() {
let base = SafePath::new("src").unwrap();
let result = base.join("../etc");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parent directory"));
}
#[test]
fn test_join_exceeding_depth_fails() {
let components: Vec<String> = (0..18).map(|i| format!("level{}", i)).collect();
let base = SafePath::new(components.join("/")).unwrap();
let result = base.join("level18/level19/level20");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("exceeds maximum allowed depth"));
}
#[test]
fn test_try_from_str() {
let path = "src/generated";
let result = SafePath::try_from(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_try_from_string() {
let path = String::from("src/generated");
let result = SafePath::try_from(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_try_from_string_ref() {
let path = String::from("src/generated");
let result = SafePath::try_from(&path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_try_from_path_buf() {
let path = PathBuf::from("src/generated");
let result = SafePath::try_from(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_try_from_path() {
let path = Path::new("src/generated");
let result = SafePath::try_from(path);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
}
#[test]
fn test_try_from_invalid_str_fails() {
let path = "../etc/passwd";
let result = SafePath::try_from(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parent directory"));
}
#[test]
fn test_display_trait() {
let safe = SafePath::new("src/generated").unwrap();
let display_string = format!("{}", safe);
assert_eq!(display_string, "src/generated");
}
#[test]
fn test_debug_trait() {
let safe = SafePath::new("src/generated").unwrap();
let debug_string = format!("{:?}", safe);
assert!(debug_string.contains("SafePath"));
assert!(debug_string.contains("src/generated"));
}
#[test]
fn test_as_ref_path() {
let safe = SafePath::new("src/generated").unwrap();
let path_ref: &Path = safe.as_ref();
assert_eq!(path_ref.to_str().unwrap(), "src/generated");
}
#[test]
fn test_as_ref_path_buf() {
let safe = SafePath::new("src/generated").unwrap();
let path_buf_ref: &PathBuf = safe.as_ref();
assert_eq!(path_buf_ref.to_str().unwrap(), "src/generated");
}
#[test]
fn test_equality() {
let safe1 = SafePath::new("src/generated").unwrap();
let safe2 = SafePath::new("src/generated").unwrap();
let safe3 = SafePath::new("src/output").unwrap();
assert_eq!(safe1, safe2);
assert_ne!(safe1, safe3);
}
#[test]
fn test_clone() {
let safe = SafePath::new("src/generated").unwrap();
let cloned = safe.clone();
assert_eq!(safe, cloned);
assert_eq!(safe.as_path(), cloned.as_path());
}
#[test]
fn test_normalization_removes_current_dir() {
let path = "./src/./generated/./output";
let safe = SafePath::new(path).unwrap();
assert_eq!(safe.as_path().to_str().unwrap(), "src/generated/output");
}
#[test]
fn test_into_path_buf() {
let safe = SafePath::new("src/generated").unwrap();
let path_buf = safe.into_path_buf();
assert_eq!(path_buf.to_str().unwrap(), "src/generated");
}
#[test]
fn test_path_with_file_extension() {
let path = "src/generated/output.rs";
let result = SafePath::new(path);
assert!(result.is_ok());
assert_eq!(
result.unwrap().as_path().to_str().unwrap(),
"src/generated/output.rs"
);
}
#[test]
fn test_path_with_special_chars_in_name() {
let path = "src/my-project_v2.0/output";
let result = SafePath::new(path);
assert!(result.is_ok());
assert_eq!(
result.unwrap().as_path().to_str().unwrap(),
"src/my-project_v2.0/output"
);
}
}