use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Component, 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 = if user_path_ref.is_absolute() {
user_path_ref.to_path_buf()
} else {
self.allowed_base.join(user_path_ref)
};
let normalized_path = normalize_lexically(&joined_path);
if !normalized_path.starts_with(&self.allowed_base) {
return Err(PathSecurityError::OutsideAllowedBounds {
path: normalized_path.display().to_string(),
allowed_base: self.allowed_base.display().to_string(),
});
}
let canonical_path = self.resolve_existing_ancestor(&normalized_path, &user_path_str)?;
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
}
fn resolve_existing_ancestor(
&self,
normalized_path: &Path,
original_path: &str,
) -> Result<PathBuf, PathSecurityError> {
let mut probe = normalized_path.to_path_buf();
let mut missing_suffix = PathBuf::new();
loop {
if probe.exists() {
let canonical_probe = probe.canonicalize().map_err(|e| {
PathSecurityError::CanonicalizationFailed {
path: original_path.to_string(),
source: e,
}
})?;
if !canonical_probe.starts_with(&self.allowed_base) {
return Err(PathSecurityError::OutsideAllowedBounds {
path: canonical_probe.display().to_string(),
allowed_base: self.allowed_base.display().to_string(),
});
}
if missing_suffix.as_os_str().is_empty() {
return Ok(canonical_probe);
}
return Ok(canonical_probe.join(missing_suffix));
}
let Some(name) = probe.file_name() else {
return Err(PathSecurityError::CanonicalizationFailed {
path: original_path.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"no existing ancestor found",
),
});
};
let mut next_suffix = PathBuf::from(name);
if !missing_suffix.as_os_str().is_empty() {
next_suffix.push(missing_suffix);
}
missing_suffix = next_suffix;
if !probe.pop() {
return Err(PathSecurityError::CanonicalizationFailed {
path: original_path.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"no existing ancestor found",
),
});
}
}
}
fn prepare_write_path<P: AsRef<Path>>(
&self,
user_path: P,
) -> Result<ValidatedPath, PathSecurityError> {
let validated = self.validate_path(user_path)?;
self.prepare_validated_write_path(&validated)
}
fn prepare_validated_write_path(
&self,
validated: &ValidatedPath,
) -> Result<ValidatedPath, PathSecurityError> {
if let Some(parent) = validated.as_path().parent() {
fs::create_dir_all(parent).map_err(|e| PathSecurityError::CanonicalizationFailed {
path: validated.original_path().to_string(),
source: e,
})?;
}
let revalidated = self.validate_path(validated.original_path())?;
if let Some(parent) = revalidated.as_path().parent() {
let canonical_parent =
parent
.canonicalize()
.map_err(|e| PathSecurityError::CanonicalizationFailed {
path: validated.original_path().to_string(),
source: e,
})?;
if !canonical_parent.starts_with(&self.allowed_base) {
return Err(PathSecurityError::OutsideAllowedBounds {
path: canonical_parent.display().to_string(),
allowed_base: self.allowed_base.display().to_string(),
});
}
}
Ok(revalidated)
}
}
fn create_no_follow_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW);
options.open(path)
}
fn write_all_no_follow(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut file = create_no_follow_file(path)?;
file.write_all(bytes)
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
let _ = normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
normalized
}
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.prepare_write_path(user_path)?;
write_all_no_follow(validated.as_path(), content.as_ref().as_bytes())?;
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.prepare_write_path(user_path)?;
write_all_no_follow(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.prepare_write_path(dst)?;
let bytes = fs::read(validated_src.as_path())?;
write_all_no_follow(validated_dst.as_path(), &bytes)?;
Ok(bytes.len() as u64)
}
}
#[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");
}
#[cfg(unix)]
#[test]
fn write_preparation_revalidates_parent_after_creation() {
let temp_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let secure_path = SecurePath::new(temp_dir.path()).unwrap();
let validated = secure_path.validate_path("staged/file.txt").unwrap();
std::os::unix::fs::symlink(outside_dir.path(), temp_dir.path().join("staged")).unwrap();
let error = secure_path
.prepare_validated_write_path(&validated)
.expect_err("parent symlink swap must be rejected");
match error {
PathSecurityError::OutsideAllowedBounds { path, allowed_base } => {
assert!(path.starts_with(&outside_dir.path().display().to_string()));
assert_eq!(
allowed_base,
secure_path.base_directory().display().to_string()
);
}
other => panic!("Expected OutsideAllowedBounds, got {:?}", other),
}
}
#[cfg(unix)]
#[test]
fn no_follow_write_rejects_final_symlink_swap() {
let temp_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let link_path = temp_dir.path().join("target.txt");
let escaped_path = outside_dir.path().join("escaped.txt");
std::os::unix::fs::symlink(&escaped_path, &link_path).unwrap();
let error =
write_all_no_follow(&link_path, b"blocked").expect_err("symlink write must fail");
assert_eq!(error.raw_os_error(), Some(libc::ELOOP));
assert!(!escaped_path.exists());
}
}