luff 0.2.1

Print files with formatting
Documentation
//! Path validation for security
//!
//! Provides validation functions to detect potentially dangerous paths
//! including null bytes, control characters, and platform-reserved names.

use crate::env::{EnvProvider, RealEnv};
use std::path::Path;

/// Maximum length for individual path components (filesystem limit)
const MAX_COMPONENT_LENGTH: usize = 255;

/// Maximum total path length for security (prevent `DoS`)
const MAX_PATH_LENGTH: usize = 4096;

/// Validate a path for security issues
///
/// # Security Checks
///
/// - No null bytes in path
/// - No control characters in path components
/// - No suspicious patterns (platform-reserved names)
/// - Reasonable component and total length limits
/// - Valid UTF-8
///
/// # Arguments
///
/// * `path` - Path to validate
/// * `env` - Environment provider for configurable limits
///
/// # Returns
///
/// `true` if path is safe to use, `false` otherwise
///
/// # Platform-Specific Behavior
///
/// This function applies cross-platform validation rules, including
/// Windows-reserved names, even on Unix systems. This ensures consistent
/// behavior and prevents issues when configs are shared across platforms.
#[must_use]
pub fn validate_path_with_env(path: &Path, env: &dyn EnvProvider) -> bool {
    // Check path can be converted to string (no invalid UTF-8).
    // This validates the *entire* path, so individual components are
    // guaranteed to be valid UTF-8 as well — no per-component re-check
    // is needed below.
    let Some(path_str) = path.to_str() else {
        return false;
    };

    // Check for null bytes
    if path_str.contains('\0') {
        return false;
    }

    // Check total path length (prevent `DoS`)
    let max_path = env
        .var("LUFF_MAX_PATH_LENGTH")
        .and_then(|s| s.parse().ok())
        .unwrap_or(MAX_PATH_LENGTH);

    if path_str.len() > max_path {
        return false;
    }

    // Read component limit once before the loop
    let max_component = env
        .var("LUFF_MAX_COMPONENT_LENGTH")
        .and_then(|s| s.parse().ok())
        .unwrap_or(MAX_COMPONENT_LENGTH);

    // Validate each path component
    for component in path.components() {
        // The whole-path UTF-8 check above guarantees every component is
        // valid UTF-8, so this `to_str()` cannot fail.  We treat a failure
        // as a validation rejection (defense-in-depth) rather than a panic.
        let Some(comp_str) = component.as_os_str().to_str() else {
            return false;
        };

        // Check component length (filesystem limit)
        let encoded_len = comp_str.len();
        if encoded_len > max_component {
            return false;
        }

        // Reject control characters in components
        // Allow only printable ASCII and valid UTF-8 sequences
        if comp_str.bytes().any(|b| b < 32 || b == 127) {
            return false;
        }

        // Check for platform-reserved names (cross-platform safety)
        // Delegates to platform module to maintain a single source of truth
        if super::platform::is_reserved_name(comp_str) {
            return false;
        }
    }

    true
}

/// Validate a path using the real environment
///
/// Convenience wrapper for production code.
#[must_use]
pub fn validate_path(path: &Path) -> bool {
    validate_path_with_env(path, &RealEnv)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::env::MockEnv;
    use std::path::Path;

    #[test]
    fn test_validate_path_valid() {
        let env = MockEnv::new();
        let path = Path::new("src/main.rs");
        assert!(validate_path_with_env(path, &env));
    }

    #[test]
    fn test_validate_path_component_length() {
        // Valid component length
        let env = MockEnv::new();
        let valid_comp = "a".repeat(255);
        let path_str = format!("/tmp/{valid_comp}");
        let path = Path::new(&path_str);
        assert!(validate_path_with_env(path, &env));

        // Invalid component length (> 255)
        let invalid_comp = "a".repeat(256);
        let path_str = format!("/tmp/{invalid_comp}");
        let path = Path::new(&path_str);
        assert!(!validate_path_with_env(path, &env));
    }

    #[test]
    fn test_validate_path_windows_reserved_names() {
        let env = MockEnv::new();

        // Test exact reserved names
        assert!(!validate_path_with_env(Path::new("/tmp/CON"), &env));
        assert!(!validate_path_with_env(Path::new("/tmp/PRN"), &env));
        assert!(!validate_path_with_env(Path::new("/tmp/AUX"), &env));
        assert!(!validate_path_with_env(Path::new("/tmp/NUL"), &env));

        // Test reserved names with extensions
        assert!(!validate_path_with_env(Path::new("/tmp/CON.txt"), &env));
        assert!(!validate_path_with_env(Path::new("/tmp/COM1.log"), &env));

        // Test case-insensitivity
        assert!(!validate_path_with_env(Path::new("/tmp/con"), &env));
        assert!(!validate_path_with_env(Path::new("/tmp/CoM1"), &env));

        // Non-reserved names should pass
        assert!(validate_path_with_env(Path::new("/tmp/config.txt"), &env));
        assert!(validate_path_with_env(Path::new("/tmp/LPT10"), &env));
    }

    #[test]
    fn test_validate_path_control_characters() {
        let env = MockEnv::new();

        // Control characters should be rejected
        assert!(!validate_path_with_env(
            Path::new("/tmp/test\x01file.txt"),
            &env
        ));
        assert!(!validate_path_with_env(
            Path::new("/tmp/test\x1Ffile.txt"),
            &env
        ));
        assert!(!validate_path_with_env(
            Path::new("/tmp/test\x7Ffile.txt"),
            &env
        ));

        // Normal characters should pass
        assert!(validate_path_with_env(
            Path::new("/tmp/test_file.txt"),
            &env
        ));
        assert!(validate_path_with_env(
            Path::new("/tmp/test-file.txt"),
            &env
        ));
    }

    #[test]
    fn test_validate_path_max_length() {
        use std::iter;

        let mut env = MockEnv::new();

        // The real test should be a path that is at the total limit but
        // has valid components. Let's create 16 components of 255 chars each.
        let comp_255 = "a".repeat(255);
        let components: Vec<String> = iter::repeat_n(comp_255, 16).collect();
        let valid_path = components.join("/");

        assert_eq!(valid_path.len(), 4095); // 16*255 + 15 separators = 4095
        assert!(validate_path_with_env(Path::new(&valid_path), &env));

        // Now test over limit
        let invalid_path = valid_path + "/a";
        assert!(!validate_path_with_env(Path::new(&invalid_path), &env));

        // Test with custom limit
        env = env.with_var("LUFF_MAX_PATH_LENGTH", "100");
        let long_path = "a".repeat(101);
        assert!(!validate_path_with_env(Path::new(&long_path), &env));
    }

    #[test]
    fn test_validate_path_null_bytes() {
        let env = MockEnv::new();

        // Paths with null bytes should be rejected
        let path_str = "test\0file.txt";
        let path = Path::new(path_str);
        assert!(!validate_path_with_env(path, &env));
    }

    #[test]
    fn test_empty_path_validation() {
        let env = MockEnv::new();
        let path = Path::new("");

        // Empty path should be considered valid (it represents current directory)
        assert!(validate_path_with_env(path, &env));
    }

    #[test]
    fn test_mock_env_functionality() {
        let env = MockEnv::new()
            .with_var("TEST_VAR", "value")
            .without_var("MISSING_VAR");

        assert_eq!(env.var("TEST_VAR"), Some("value".to_string()));
        assert_eq!(env.var("MISSING_VAR"), None);
    }
}