use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PathSecurityError {
#[error("Path traversal attempt detected: {path}")]
PathTraversalAttempt { path: String },
#[error("Failed to canonicalize path {path}: {source}")]
CanonicalizationFailed {
path: String,
#[source]
source: std::io::Error,
},
#[error("Path {path} is outside allowed bounds {allowed_base}")]
OutsideAllowedBounds { path: String, allowed_base: String },
#[error("Base directory {base} is not accessible: {source}")]
BaseDirectoryInaccessible {
base: String,
#[source]
source: std::io::Error,
},
#[error("Invalid path: {reason}")]
InvalidPath { reason: String },
}
#[derive(Debug, Clone)]
pub struct ValidatedPath {
canonical_path: PathBuf,
original_path: String,
}
impl ValidatedPath {
pub fn as_path(&self) -> &Path {
&self.canonical_path
}
pub fn to_path_buf(&self) -> PathBuf {
self.canonical_path.clone()
}
pub fn original_path(&self) -> &str {
&self.original_path
}
}
#[derive(Debug, Clone)]
pub struct SecurePath {
allowed_base: PathBuf,
}
impl SecurePath {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Result<Self, PathSecurityError> {
let base_path = base_dir.as_ref();
let allowed_base =
base_path
.canonicalize()
.map_err(|e| PathSecurityError::BaseDirectoryInaccessible {
base: base_path.display().to_string(),
source: e,
})?;
Ok(Self { allowed_base })
}
pub fn validate_path<P: AsRef<Path>>(
&self,
user_path: P,
) -> Result<ValidatedPath, PathSecurityError> {
let user_path_ref = user_path.as_ref();
let user_path_str = user_path_ref.to_string_lossy().to_string();
if user_path_str.is_empty() {
return Err(PathSecurityError::InvalidPath {
reason: "Path is empty".to_string(),
});
}
if user_path_str.contains('\0') {
return Err(PathSecurityError::InvalidPath {
reason: "Path contains null bytes".to_string(),
});
}
if user_path_str.contains("..") {
#[cfg(feature = "tracing-integration")]
tracing::warn!(
"Potential path traversal attempt detected: {}",
user_path_str
);
}
let joined_path = self.allowed_base.join(user_path_ref);
let canonical_path = match joined_path.canonicalize() {
Ok(path) => path,
Err(e) => {
if let Some(parent) = joined_path.parent() {
if let Some(filename) = joined_path.file_name() {
match parent.canonicalize() {
Ok(canonical_parent) => canonical_parent.join(filename),
Err(_) => {
return Err(PathSecurityError::CanonicalizationFailed {
path: user_path_str,
source: e,
});
}
}
} else {
return Err(PathSecurityError::CanonicalizationFailed {
path: user_path_str,
source: e,
});
}
} else {
return Err(PathSecurityError::CanonicalizationFailed {
path: user_path_str,
source: e,
});
}
}
};
if !canonical_path.starts_with(&self.allowed_base) {
return Err(PathSecurityError::OutsideAllowedBounds {
path: canonical_path.display().to_string(),
allowed_base: self.allowed_base.display().to_string(),
});
}
#[cfg(feature = "tracing-integration")]
tracing::debug!(
"Path validation successful: {} -> {}",
user_path_str,
canonical_path.display()
);
Ok(ValidatedPath {
canonical_path,
original_path: user_path_str,
})
}
pub fn base_directory(&self) -> &Path {
&self.allowed_base
}
}
impl SecurePath {
pub fn read_to_string<P: AsRef<Path>>(
&self,
user_path: P,
) -> Result<String, Box<dyn std::error::Error>> {
let validated = self.validate_path(user_path)?;
let content = fs::read_to_string(validated.as_path())?;
Ok(content)
}
pub fn write_string<P: AsRef<Path>, C: AsRef<str>>(
&self,
user_path: P,
content: C,
) -> Result<(), Box<dyn std::error::Error>> {
let validated = self.validate_path(user_path)?;
if let Some(parent) = validated.as_path().parent() {
fs::create_dir_all(parent)?;
}
fs::write(validated.as_path(), content.as_ref())?;
Ok(())
}
pub fn read_bytes<P: AsRef<Path>>(
&self,
user_path: P,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let validated = self.validate_path(user_path)?;
let bytes = fs::read(validated.as_path())?;
Ok(bytes)
}
pub fn write_bytes<P: AsRef<Path>, B: AsRef<[u8]>>(
&self,
user_path: P,
bytes: B,
) -> Result<(), Box<dyn std::error::Error>> {
let validated = self.validate_path(user_path)?;
if let Some(parent) = validated.as_path().parent() {
fs::create_dir_all(parent)?;
}
fs::write(validated.as_path(), bytes.as_ref())?;
Ok(())
}
pub fn copy_file<P1: AsRef<Path>, P2: AsRef<Path>>(
&self,
src: P1,
dst: P2,
) -> Result<u64, Box<dyn std::error::Error>> {
let validated_src = self.validate_path(src)?;
let validated_dst = self.validate_path(dst)?;
if let Some(parent) = validated_dst.as_path().parent() {
fs::create_dir_all(parent)?;
}
let bytes_copied = fs::copy(validated_src.as_path(), validated_dst.as_path())?;
Ok(bytes_copied)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_secure_path_creation() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
assert_eq!(
secure_path.base_directory(),
temp_dir.path().canonicalize().unwrap()
);
}
#[test]
fn test_invalid_base_directory() {
let result = SecurePath::new("/nonexistent/directory");
assert!(result.is_err());
match result.unwrap_err() {
PathSecurityError::BaseDirectoryInaccessible { .. } => (),
other => panic!("Expected BaseDirectoryInaccessible, got {:?}", other),
}
}
#[test]
fn test_valid_path_validation() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let validated = secure_path.validate_path("configs/app.toml").unwrap();
let expected = temp_dir
.path()
.canonicalize()
.unwrap()
.join("configs/app.toml");
assert_eq!(validated.as_path(), expected);
assert_eq!(validated.original_path(), "configs/app.toml");
}
#[test]
fn test_path_traversal_attack() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let result = secure_path.validate_path("../../etc/passwd");
assert!(result.is_err());
match result.unwrap_err() {
PathSecurityError::OutsideAllowedBounds { .. } => (),
other => panic!("Expected OutsideAllowedBounds, got {:?}", other),
}
}
#[test]
fn test_empty_path() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let result = secure_path.validate_path("");
assert!(result.is_err());
match result.unwrap_err() {
PathSecurityError::InvalidPath { .. } => (),
other => panic!("Expected InvalidPath, got {:?}", other),
}
}
#[test]
fn test_null_byte_path() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let result = secure_path.validate_path("file\0.txt");
assert!(result.is_err());
match result.unwrap_err() {
PathSecurityError::InvalidPath { .. } => (),
other => panic!("Expected InvalidPath, got {:?}", other),
}
}
#[test]
fn test_secure_file_operations() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let content = "Hello, secure world!";
secure_path.write_string("test.txt", content).unwrap();
let read_content = secure_path.read_to_string("test.txt").unwrap();
assert_eq!(read_content, content);
let bytes = b"Binary data";
secure_path.write_bytes("binary.dat", bytes).unwrap();
let read_bytes = secure_path.read_bytes("binary.dat").unwrap();
assert_eq!(read_bytes, bytes);
}
#[test]
fn test_secure_copy_operation() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let content = "File to copy";
secure_path.write_string("source.txt", content).unwrap();
let bytes_copied = secure_path
.copy_file("source.txt", "destination.txt")
.unwrap();
assert!(bytes_copied > 0);
let copied_content = secure_path.read_to_string("destination.txt").unwrap();
assert_eq!(copied_content, content);
}
#[test]
fn test_directory_creation() {
let temp_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
secure_path
.write_string("nested/dirs/file.txt", "content")
.unwrap();
let content = secure_path.read_to_string("nested/dirs/file.txt").unwrap();
assert_eq!(content, "content");
}
}