luff 0.2.1

Print files with formatting
Documentation
//! Pure path normalization without filesystem access
//!
//! Provides idempotent path normalization that removes `.` and `..` components
//! without requiring filesystem operations or path existence.

use std::borrow::Cow;
use std::path::{Component, Path, PathBuf};

/// Normalize a path by removing `.` and `..` components
///
/// This function provides a safe normalization that prevents path
/// traversal attacks while not requiring the path to exist.
///
/// **Warning**: This function performs purely syntactic normalization. It does
/// NOT access the filesystem and therefore **does not resolve symlinks**.
/// A path like `/a/b/../c` will be normalized to `/a/c` even if `/a/b` is a
/// symlink pointing elsewhere. For security checks involving physical file
/// locations, use `std::fs::canonicalize` (or `canonicalize_safe`) instead.
///
/// Returns `Cow::Borrowed` when no normalization is needed (zero allocation),
/// or `Cow::Owned` when normalization is required.
///
/// # Security
///
/// - Removes all `.` components
/// - Collapses `..` components safely
/// - Prevents escaping the root directory on absolute paths
/// - Does not follow symlinks (syntactic only)
/// - Empty paths normalize to `"."`, never `""`
///   (prevents vacuous `Path::starts_with("")` → `true` in callers)
/// - Relative paths that collapse to empty always return `"."`, never `""`
///   (prevents vacuous `Path::starts_with("")` → `true` in callers like
///   `is_within_root`)
///
/// # Performance
///
/// Fast-paths when the path is already normalized, avoiding allocation.
///
/// # Examples
///
/// ```
/// use luff::fs_utils::normalize_path;
/// use std::path::Path;
///
/// let normalized = normalize_path(Path::new("./src/../README.md"));
/// assert_eq!(normalized.as_ref(), Path::new("README.md"));
///
/// // Already normalized paths return borrowed (no allocation)
/// let normalized = normalize_path(Path::new("src/main.rs"));
/// assert!(matches!(normalized, std::borrow::Cow::Borrowed(_)));
///
/// // Absolute paths cannot escape root
/// let normalized = normalize_path(Path::new("/../../etc/passwd"));
/// assert_eq!(normalized.as_ref(), Path::new("/etc/passwd"));
///
/// // Empty and "." paths normalize to ".", not ""
/// let normalized = normalize_path(Path::new(""));
/// assert_eq!(normalized.as_ref(), Path::new("."));
///
/// let normalized = normalize_path(Path::new("."));
/// assert_eq!(normalized.as_ref(), Path::new("."));
///
/// let normalized = normalize_path(Path::new("foo/.."));
/// assert_eq!(normalized.as_ref(), Path::new("."));
/// ```
#[must_use]
pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
    // Empty paths must produce "." — an empty PathBuf causes
    // `Path::starts_with("")` to be vacuously true, which would make
    // `is_within_root` accept every path — a path-traversal bypass.
    if path.as_os_str().is_empty() {
        return Cow::Owned(PathBuf::from("."));
    }

    // Fast path: Check if normalization is needed
    let needs_normalization = path
        .components()
        .any(|c| matches!(c, Component::CurDir | Component::ParentDir));

    if !needs_normalization {
        return Cow::Borrowed(path);
    }

    // Slow path: Normalize the path
    let mut components = Vec::new();
    let is_absolute = path.is_absolute();

    for component in path.components() {
        match component {
            Component::CurDir => {
                // Skip current directory markers
            }
            Component::ParentDir => {
                // For absolute paths, check if we can safely pop
                if is_absolute {
                    // Count non-root components (RootDir, Prefix are "root" components)
                    let has_non_root = components
                        .iter()
                        .any(|c| !matches!(c, Component::RootDir | Component::Prefix(_)));

                    if has_non_root {
                        // Safe to pop - we have components above root
                        let _ = components.pop();
                    }
                    // Otherwise ignore the .. (can't escape root directory)
                } else {
                    // Relative path logic
                    if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
                        let _ = components.pop();
                    } else {
                        // Preserve .. for relative paths that go above start
                        components.push(component);
                    }
                }
            }
            _ => {
                components.push(component);
            }
        }
    }

    // A relative path that collapsed to zero components (e.g. ".", "foo/..")
    // must produce "." rather than "".  An empty PathBuf causes
    // `Path::starts_with("")` to be vacuously true, which would make
    // `is_within_root` accept every path — a path-traversal bypass.
    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"));
        // Should be borrowed (no allocation)
        assert!(matches!(normalized, Cow::Borrowed(_)));
    }

    #[test]
    fn test_normalize_path_cannot_escape_root() {
        // Critical security test: absolute paths 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"));

        // Multiple parent dirs at root should just stay at root
        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() {
        // Relative paths should preserve leading .. components
        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() {
        // "." must normalize to ".", NOT to "" (empty PathBuf).
        // An empty path makes Path::starts_with("") vacuously true,
        // which would break is_within_root.
        let normalized = normalize_path(Path::new("."));
        assert_eq!(normalized.as_ref(), Path::new("."));
    }

    #[test]
    fn test_normalize_empty_returns_dot() {
        // "" must normalize to "." for the same reason as ".".
        // Path::starts_with("") is vacuously true for all paths,
        // which would be a path-traversal bypass in is_within_root.
        let normalized = normalize_path(Path::new(""));
        assert_eq!(normalized.as_ref(), Path::new("."));
    }

    #[test]
    fn test_normalize_collapsing_relative_returns_dot() {
        // "foo/.." collapses to nothing — should produce "." not ""
        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() {
        // Regression: before the fix, normalize(".") → "" caused
        // is_within_root("../../etc/passwd", ".") to return true.
        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() {
        // Regression: normalize("") → "" caused
        // is_within_root("../../etc/passwd", "") to return true.
        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);
        // Must produce ".", not ""
        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);
            // Just verify the function completes without panic
            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);
            // normalize_path must never produce an empty path.
            // Empty paths cause Path::starts_with("") to be vacuously true,
            // which is a path-traversal bypass in is_within_root.
            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)) {
            // Build a path with many parent directory components
            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);

            // Should start with root and never escape it
            assert!(normalized.as_ref().is_absolute());
            let normalized_str = normalized.as_ref().to_str().unwrap();
            assert!(normalized_str.starts_with('/'));

            // Should not have any parent directory components at start
            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);

            // Relative paths should preserve leading ..
            let expected_dotdots = std::iter::repeat_n("..", dotdot_count).collect::<Vec<_>>().join("/");
            assert!(normalized.as_ref().to_str().unwrap().starts_with(&expected_dotdots));
        }
    }
}