use std::borrow::Cow;
use std::path::{Component, Path, PathBuf};
#[must_use]
pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
if path.as_os_str().is_empty() {
return Cow::Owned(PathBuf::from("."));
}
let needs_normalization = path
.components()
.any(|c| matches!(c, Component::CurDir | Component::ParentDir));
if !needs_normalization {
return Cow::Borrowed(path);
}
let mut components = Vec::new();
let is_absolute = path.is_absolute();
for component in path.components() {
match component {
Component::CurDir => {
}
Component::ParentDir => {
if is_absolute {
let has_non_root = components
.iter()
.any(|c| !matches!(c, Component::RootDir | Component::Prefix(_)));
if has_non_root {
let _ = components.pop();
}
} else {
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
let _ = components.pop();
} else {
components.push(component);
}
}
}
_ => {
components.push(component);
}
}
}
if !is_absolute && components.is_empty() {
return Cow::Owned(PathBuf::from("."));
}
Cow::Owned(components.iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs_utils::validate_path;
use proptest::prelude::*;
#[test]
fn test_normalize_path_simple() {
let path = Path::new("./src/../README.md");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("README.md"));
}
#[test]
fn test_normalize_path_absolute() {
let path = Path::new("/home/user/../other/file.txt");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("/home/other/file.txt"));
}
#[test]
fn test_normalize_path_no_change() {
let path = Path::new("src/main.rs");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("src/main.rs"));
assert!(matches!(normalized, Cow::Borrowed(_)));
}
#[test]
fn test_normalize_path_cannot_escape_root() {
let path = Path::new("/../../etc/passwd");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("/etc/passwd"));
let path = Path::new("/../../../etc");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("/etc"));
let path = Path::new("/../../../..");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("/"));
}
#[test]
fn test_normalize_relative_path_preserves_parent_dirs() {
let path = Path::new("../../other/file.txt");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("../../other/file.txt"));
}
#[test]
fn test_normalize_path_returns_borrowed_when_possible() {
let path = Path::new("src/main.rs");
let normalized = normalize_path(path);
assert!(matches!(normalized, Cow::Borrowed(_)));
assert_eq!(normalized.as_ref(), path);
}
#[test]
fn test_normalize_path_returns_owned_when_needed() {
let path = Path::new("./src/../main.rs");
let normalized = normalize_path(path);
assert!(matches!(normalized, Cow::Owned(_)));
assert_eq!(normalized.as_ref(), Path::new("main.rs"));
}
#[test]
fn test_normalize_curdir_returns_dot() {
let normalized = normalize_path(Path::new("."));
assert_eq!(normalized.as_ref(), Path::new("."));
}
#[test]
fn test_normalize_empty_returns_dot() {
let normalized = normalize_path(Path::new(""));
assert_eq!(normalized.as_ref(), Path::new("."));
}
#[test]
fn test_normalize_collapsing_relative_returns_dot() {
let normalized = normalize_path(Path::new("foo/.."));
assert_eq!(normalized.as_ref(), Path::new("."));
let normalized = normalize_path(Path::new("a/b/../../"));
assert_eq!(normalized.as_ref(), Path::new("."));
}
#[test]
fn test_normalize_dot_is_within_root_is_sound() {
use crate::fs_utils::is_within_root;
assert!(
!is_within_root(Path::new("../../etc/passwd"), Path::new(".")),
"path traversal must not be considered within '.'"
);
}
#[test]
fn test_normalize_empty_is_within_root_is_sound() {
use crate::fs_utils::is_within_root;
assert!(
!is_within_root(Path::new("../../etc/passwd"), Path::new("")),
"path traversal must not be considered within ''"
);
assert!(
!is_within_root(Path::new("/etc/passwd"), Path::new("")),
"absolute path must not be considered within ''"
);
}
#[test]
fn test_never_panics_on_empty_path() {
let path = Path::new("");
let normalized = normalize_path(path);
assert_eq!(normalized.as_ref(), Path::new("."));
}
proptest! {
#[test]
fn test_normalize_never_panics(s in "\\PC{0,100}") {
let path = Path::new(&s);
let _ = normalize_path(path);
}
#[test]
fn test_validate_path_never_panics(
s in "[a-zA-Z0-9_/-]{0,1000}"
) {
let path = Path::new(&s);
let _ = validate_path(path);
}
#[test]
fn test_normalize_path_idempotent(s in "[a-zA-Z0-9_/-]{1,50}") {
let path = Path::new(&s);
let normalized1 = normalize_path(path);
let normalized2 = normalize_path(normalized1.as_ref());
assert_eq!(normalized1.as_ref(), normalized2.as_ref());
}
#[test]
fn test_normalize_never_produces_empty_path(s in "\\PC{0,100}") {
let path = Path::new(&s);
let normalized = normalize_path(path);
assert!(
!normalized.as_os_str().is_empty(),
"normalize_path({path:?}) produced empty path",
);
}
#[test]
fn test_absolute_paths_never_escape_root(components in prop::collection::vec("[a-z]{1,5}", 1..10)) {
let mut path_str = String::from("/");
for _ in 0..20 {
path_str.push_str("../");
}
for comp in &components {
path_str.push_str(comp);
path_str.push('/');
}
let path = Path::new(&path_str);
let normalized = normalize_path(path);
assert!(normalized.as_ref().is_absolute());
let normalized_str = normalized.as_ref().to_str().unwrap();
assert!(normalized_str.starts_with('/'));
assert!(!normalized_str.starts_with("/.."));
}
#[test]
fn test_normalize_preserves_leading_dotdot_in_relative_paths(
dotdot_count in 1usize..5,
components in prop::collection::vec("[a-z]{1,5}", 1..5)
) {
let mut path_str = String::new();
for _ in 0..dotdot_count {
path_str.push_str("../");
}
for comp in &components {
path_str.push_str(comp);
path_str.push('/');
}
let path = Path::new(&path_str);
let normalized = normalize_path(path);
let expected_dotdots = std::iter::repeat_n("..", dotdot_count).collect::<Vec<_>>().join("/");
assert!(normalized.as_ref().to_str().unwrap().starts_with(&expected_dotdots));
}
}
}