luff 0.2.1

Print files with formatting
Documentation
//! Platform-specific file system operations
//!
//! Provides safe wrappers around platform-specific file system functionality
//! with consistent error handling across Unix and Windows.

use std::io;
use std::path::{Path, PathBuf};

/// Windows reserved device names that cannot be used as filenames
///
/// These names are reserved even with extensions (e.g., "CON.txt" is invalid).
/// Source: <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
const WINDOWS_RESERVED_NAMES: &[&str] = &[
    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];

/// Safely canonicalize a path with security checks
///
/// # Security
///
/// - Resolves symlinks safely (limited to 40 levels per POSIX)
/// - Validates path exists
/// - Ensures result is absolute
/// - Checks for symlink attacks
///
/// # Arguments
///
/// * `path` - Path to canonicalize
///
/// # Errors
///
/// Returns error if:
/// - Path does not exist
/// - Too many symlink levels (possible attack, limit: 40 per POSIX)
/// - Permission denied
pub fn canonicalize_safe(path: &Path) -> io::Result<PathBuf> {
    // std::fs::canonicalize resolves symlinks, verifies existence, and
    // returns an absolute path. Any failure (not found, permission denied,
    // symlink loop) is surfaced as io::Error directly — no redundant
    // post-checks needed.
    let canonical = path.canonicalize()?;

    debug_assert!(
        canonical.is_absolute(),
        "Canonicalized path must be absolute"
    );

    Ok(canonical)
}

/// Check if a name is a platform-reserved name that cannot be used as a filename
///
/// Currently checks Windows reserved device names (CON, PRN, AUX, NUL, COM1–9,
/// LPT1–9), which are reserved even with extensions (e.g., "CON.txt" is invalid).
///
/// This check is applied on **all platforms** for cross-platform safety — configs
/// and paths shared between Unix and Windows systems should be validated
/// consistently.
///
/// # Performance
///
/// Uses ASCII case-insensitive comparison — no heap allocation.
#[must_use]
pub fn is_reserved_name(name: &str) -> bool {
    // Fast path: check exact match (case-insensitive, no allocation)
    if WINDOWS_RESERVED_NAMES
        .iter()
        .any(|r| r.eq_ignore_ascii_case(name))
    {
        return true;
    }

    // Check for reserved name with extension (e.g., CON.txt, COM1.tar.gz)
    // Extract base name before first dot and check against reserved list
    if let Some(dot_pos) = name.find('.') {
        let base = &name[..dot_pos];
        return WINDOWS_RESERVED_NAMES
            .iter()
            .any(|r| r.eq_ignore_ascii_case(base));
    }

    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_canonicalize_safe() {
        let temp = tempfile::TempDir::new().unwrap();
        let file_path = temp.path().join("test.txt");
        std::fs::write(&file_path, "test").unwrap();

        let canonical = canonicalize_safe(&file_path).unwrap();
        assert!(canonical.is_absolute());
        assert!(canonical.exists());
    }

    #[test]
    fn test_canonicalize_safe_nonexistent() {
        let result = canonicalize_safe(Path::new("/nonexistent/file.txt"));
        assert!(result.is_err());
    }

    #[test]
    fn test_is_reserved_name_exact() {
        assert!(is_reserved_name("CON"));
        assert!(is_reserved_name("PRN"));
        assert!(is_reserved_name("AUX"));
        assert!(is_reserved_name("NUL"));
        assert!(is_reserved_name("COM1"));
        assert!(is_reserved_name("LPT1"));
    }

    #[test]
    fn test_is_reserved_name_with_extension() {
        assert!(is_reserved_name("CON.txt"));
        assert!(is_reserved_name("COM1.log"));
    }

    #[test]
    fn test_is_reserved_name_case_insensitive() {
        assert!(is_reserved_name("con"));
        assert!(is_reserved_name("CoM1"));
    }

    #[test]
    fn test_is_reserved_name_not_reserved() {
        // "con.txt" is reserved because base "CON" is reserved
        assert!(is_reserved_name("con.txt"));
        // "con" IS reserved (case-insensitive)
        assert!(is_reserved_name("con"));
        assert!(!is_reserved_name("LPT10")); // LPT10+ not reserved
        assert!(!is_reserved_name("normal_file"));
    }

    #[test]
    fn test_multiple_extensions() {
        assert!(is_reserved_name("CON.txt.bak")); // Base is still CON
        assert!(is_reserved_name("COM1.tar.gz"));
    }

    #[test]
    fn test_no_extension() {
        assert!(!is_reserved_name("CONFIG")); // Not in reserved list
        assert!(!is_reserved_name("README"));
    }
}