use crate::constants::DEFAULT_MAX_PATH_LEN;
use crate::error::{PathError, PathResult, SafeNameError};
use crate::name::{FileValidationOptions, validate_file_with_options};
#[derive(Debug, Clone)]
pub struct PathValidationOptions {
pub file_options: FileValidationOptions,
pub max_path_len: usize,
pub allow_trailing_nul: bool,
}
impl Default for PathValidationOptions {
fn default() -> Self {
Self {
file_options: FileValidationOptions::default(),
max_path_len: DEFAULT_MAX_PATH_LEN,
allow_trailing_nul: false,
}
}
}
pub fn validate_path(path: impl AsRef<[u8]>) -> PathResult<()> {
validate_path_with_options(path, &PathValidationOptions::default())
}
pub fn validate_path_with_options(
path: impl AsRef<[u8]>,
options: &PathValidationOptions,
) -> PathResult<()> {
let original = path.as_ref();
let path = if options.allow_trailing_nul {
let end = original.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
&original[..end]
} else {
original
};
if path.is_empty() {
return Err(PathError::new(
0,
SafeNameError::InvalidLength {
len: 0,
max: options.max_path_len,
},
));
}
if path.len() > options.max_path_len {
return Err(PathError::new(
0,
SafeNameError::InvalidLength {
len: path.len(),
max: options.max_path_len,
},
));
}
let mut component_idx = 0;
let mut start = 0;
for (idx, &byte) in path.iter().enumerate() {
if byte == b'/' {
if idx > start {
let component = &path[start..idx];
validate_file_with_options(component, &options.file_options)
.map_err(|err| PathError::new(component_idx, err))?;
component_idx += 1;
}
start = idx + 1;
}
}
if start < path.len() {
let component = &path[start..];
validate_file_with_options(component, &options.file_options)
.map_err(|err| PathError::new(component_idx, err))?;
}
Ok(())
}
pub fn is_path_safe(path: impl AsRef<[u8]>) -> bool {
validate_path(path).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_path_validation() {
assert!(validate_path(b"/home/user/file.txt").is_ok());
assert!(validate_path("/home/user/file.txt").is_ok());
assert!(validate_path(b"relative/path/file.txt").is_ok());
assert!(validate_path(b"/").is_ok());
assert!(validate_path(b"single").is_ok());
let result = validate_path(b"/home/-rf");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.index, 1);
assert_eq!(
err.error,
SafeNameError::InvalidByte {
index: 0,
byte: b'-'
}
);
assert!(validate_path(b"/home//user").is_ok());
}
#[test]
fn test_is_path_safe_various_types() {
assert!(is_path_safe("/home/user"));
assert!(is_path_safe(b"/home/user"));
assert!(is_path_safe(String::from("/home/user")));
}
#[test]
fn test_dotdot_passes_validation() {
assert!(validate_path(b"/home/../etc/passwd").is_ok());
assert!(validate_path(b"./file.txt").is_ok());
assert!(validate_path(b"a/./b/../c").is_ok());
}
#[test]
fn test_empty_path() {
let result = validate_path(b"");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.index, 0);
assert_eq!(
err.error,
SafeNameError::InvalidLength { len: 0, max: 4096 }
);
}
#[test]
fn test_only_slashes() {
assert!(validate_path(b"/").is_ok());
assert!(validate_path(b"//").is_ok());
assert!(validate_path(b"///").is_ok());
}
#[test]
fn test_relative_paths() {
assert!(validate_path(b"../a/b/c").is_ok());
assert!(validate_path(b"a/b/c/").is_ok());
assert!(validate_path(b"a").is_ok());
assert!(validate_path(b"..").is_ok());
assert!(validate_path(b"../../foo/bar").is_ok());
}
#[test]
fn test_path_too_long() {
let long_path = vec![b'a'; 5000];
let result = validate_path(&long_path);
assert_eq!(
result,
Err(PathError::new(
0,
SafeNameError::InvalidLength {
len: 5000,
max: 4096
}
))
);
}
#[test]
fn test_trailing_nul_disabled_by_default() {
assert!(validate_path(b"/home/user\x00").is_err());
}
proptest! {
#[test]
fn prop_absolute_paths_valid(
components in prop::collection::vec("[a-zA-Z0-9._]{1,10}", 1..5)
) {
let path = format!("/{}", components.join("/"));
prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
}
#[test]
fn prop_leading_dash_component_rejected(
prefix_components in prop::collection::vec("[a-z]{1,5}", 0..3),
suffix in "[a-z]{1,5}"
) {
let mut path = prefix_components.join("/");
if !path.is_empty() {
path.push('/');
}
path.push_str(&format!("-{}", suffix));
prop_assert!(validate_path(path.as_bytes()).is_err(), "Not rejected: {:?}", path);
}
#[test]
fn prop_consecutive_slashes_ok(
prefix in "[a-z]{1,5}",
suffix in "[a-z]{1,5}",
slashes in 2usize..5
) {
let path = format!("{}{}{}", prefix, "/".repeat(slashes), suffix);
prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
}
#[test]
fn prop_dot_components_valid(
prefix in "[a-z]{1,5}",
dot in prop_oneof![Just("."), Just("..")],
suffix in "[a-z]{1,5}"
) {
let path = format!("{}/{}/{}", prefix, dot, suffix);
prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
}
#[test]
fn prop_length_limit_enforced(extra in 1usize..100) {
let len = 4096 + extra;
let path = vec![b'a'; len];
prop_assert!(validate_path(&path).is_err(), "Length {} not rejected", len);
}
#[test]
fn prop_trailing_nuls_trimmed(nul_count in 1usize..100) {
let opts = PathValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let mut path = b"/home/user".to_vec();
path.extend(vec![0u8; nul_count]);
prop_assert!(
validate_path_with_options(&path, &opts).is_ok(),
"Path with {} trailing NULs rejected", nul_count
);
}
#[test]
fn prop_only_nuls_empty(nul_count in 1usize..100) {
let opts = PathValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let path = vec![0u8; nul_count];
prop_assert!(
validate_path_with_options(&path, &opts).is_err(),
"Path with only {} NULs should be empty error", nul_count
);
}
#[test]
fn prop_embedded_nul_blocked(
prefix in "[a-z]{1,5}",
suffix in "[a-z]{1,5}",
trailing_nuls in 0usize..10
) {
let opts = PathValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let mut path = prefix.into_bytes();
path.push(0);
path.extend_from_slice(suffix.as_bytes());
path.extend(vec![0u8; trailing_nuls]);
prop_assert!(
validate_path_with_options(&path, &opts).is_err(),
"Embedded NUL not blocked: {:?}", path
);
}
}
}