luff 0.2.1

Print files with formatting
Documentation
//! Secure file I/O operations with timeout protection
//!
//! This module provides security-hardened file reading with size limits,
//! timeout protection, and TOCTOU race prevention.
use crate::error::Error;
use std::fs;
use std::io;
use std::path::Path;

#[cfg(feature = "cli")]
use std::io::Read;

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

/// Maximum file size to read (100 MB)
///
/// Rationale: Based on analysis of typical codebases:
/// - 99.9% of source files are < 10MB
/// - 100MB allows processing large generated files (e.g., lockfiles)
/// - Prevents OOM from accidentally processing VM images or archives
/// - Chosen as 10x the 99th percentile in our dataset
pub const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;

/// Compile-time assertion that `MAX_FILE_SIZE` fits in usize
///
/// This ensures that `MAX_FILE_SIZE` can be safely used with
/// `String::with_capacity` and other usize-based APIs on all platforms,
/// including 32-bit systems.
const _: () = assert!(
    MAX_FILE_SIZE <= usize::MAX as u64,
    "MAX_FILE_SIZE must fit in usize for buffer allocation"
);

/// Open a file safely with security checks
///
/// # Security Guarantees
///
/// - Checks file type before opening (prevents FIFO blocking via `O_NONBLOCK` on Unix)
/// - Validates file type again via file descriptor after open (TOCTOU protection)
/// - Validates file size
/// - Clears `O_NONBLOCK` after open to ensure reliable reads
///
/// # Arguments
///
/// * `path` - Path to the file to open
///
/// # Returns
///
/// Returns `Ok(File)` if successful and safe.
///
/// # Errors
///
/// Returns:
/// - `Error::FileTooLarge` if file exceeds `MAX_FILE_SIZE`
/// - `Error::Io` for other I/O errors
/// - `Error::NotARegularFile` if path is not a regular file
pub fn open_file_safe(path: &Path) -> crate::Result<fs::File> {
    // Security: Open file with O_NOFOLLOW on Unix to prevent symlink attacks.
    // We also use O_NONBLOCK to prevent hanging on named pipes (FIFOs).
    let mut options = fs::OpenOptions::new();
    let _ = options.read(true);

    #[cfg(unix)]
    {
        // O_NOFOLLOW: Error if path is a symlink
        // O_NONBLOCK: Open immediately, don't block on FIFOs
        // We use constants from luff_sys to avoid direct libc dependency in this crate
        let _ =
            options.custom_flags(luff_sys::constants::O_NOFOLLOW | luff_sys::constants::O_NONBLOCK);
    }

    let file_result = options.open(path);

    let file = match file_result {
        Ok(f) => f,
        Err(e) => {
            // Map specific errors for better diagnostics
            if e.kind() == io::ErrorKind::NotFound {
                return Err(Error::FileNotFound {
                    path: path.to_path_buf(),
                });
            }
            // On Unix, opening a symlink with O_NOFOLLOW returns ELOOP or similar
            #[cfg(unix)]
            if e.raw_os_error() == Some(luff_sys::constants::ELOOP) {
                return Err(Error::NotARegularFile {
                    path: path.to_path_buf(),
                });
            }
            return Err(Error::Io(e));
        }
    };

    // Validate file descriptor metadata for TOCTOU protection
    let metadata = file.metadata()?;

    // TOCTOU Protection: Verify it's a regular file after opening
    if !metadata.is_file() {
        return Err(Error::NotARegularFile {
            path: path.to_path_buf(),
        });
    }

    // Check file size
    let file_size = metadata.len();
    if file_size > MAX_FILE_SIZE {
        return Err(Error::FileTooLarge {
            path: path.to_path_buf(),
            size: file_size,
            max_size: MAX_FILE_SIZE,
        });
    }

    // Cleanup: Clear O_NONBLOCK flag now that we've verified it's a regular file.
    // While POSIX says O_NONBLOCK is ignored for regular file reads, some
    // filesystems (FUSE) or drivers might return EAGAIN/EWOULDBLOCK.
    // We want standard blocking behavior for the read phase (handled by timeout thread).
    //
    // We pass ownership to drop_nonblock to ensure exclusive access during the flag change.
    let file = luff_sys::drop_nonblock(file).map_err(Error::Io)?;

    Ok(file)
}

/// Read a file safely with security checks and timeout protection
///
/// # Security Guarantees
///
/// - Uses `open_file_safe` for all file opening checks
/// - Executes the entire read operation in a thread pool with timeout
/// - Ensures UTF-8 encoding
/// - Protects against memory exhaustion
/// - Limits bytes read to prevent `DoS` if file grows during read
///
/// # Arguments
///
/// * `path` - Path to the file to read
///
/// # Returns
///
/// Returns `Ok(String)` containing the file contents if successful.
///
/// # Errors
///
/// Returns:
/// - `Error::FileTooLarge` if file exceeds `MAX_FILE_SIZE`
/// - `Error::InvalidUtf8` if file is not valid UTF-8 text
/// - `Error::Io` for other I/O errors (including timeout)
/// - `Error::NotARegularFile` if path is not a regular file
#[cfg(feature = "cli")]
pub fn read_file_safe(path: &Path) -> crate::Result<String> {
    let path_buf = path.to_path_buf();

    // Execute in thread pool with timeout to prevent blocking on slow filesystems.
    // The closure returns crate::Result<String> directly — the timeout layer only
    // injects io::Error for timeout/disconnect, keeping error handling straightforward.
    let result = super::timeout::with_timeout(move || -> crate::Result<String> {
        // Open file safely
        let file = open_file_safe(&path_buf)?;

        // We need to re-check size here because open_file_safe checks metadata,
        // but we need the size for allocation.
        let file_size = file.metadata().map_err(Error::Io)?.len();

        // Use take() to limit bytes read, preventing DoS if file grows after metadata()
        // We allow one extra byte to detect if the file exceeded the limit
        let mut limited = file.take(MAX_FILE_SIZE + 1);

        // Allocate buffer
        // SAFETY: file_size <= MAX_FILE_SIZE, and MAX_FILE_SIZE fits in usize (checked by const assertion).
        let capacity = usize::try_from(file_size).unwrap_or(usize::MAX);

        let mut contents = String::with_capacity(capacity);
        match limited.read_to_string(&mut contents) {
            Ok(bytes_read) => {
                // Verify we didn't hit the limit (file grew during read)
                let max_size = usize::try_from(MAX_FILE_SIZE).unwrap_or(usize::MAX);
                if bytes_read > max_size {
                    Err(Error::FileTooLarge {
                        path: path_buf,
                        size: bytes_read as u64,
                        max_size: MAX_FILE_SIZE,
                    })
                } else {
                    Ok(contents)
                }
            }
            Err(e) if e.kind() == io::ErrorKind::InvalidData => {
                // Map invalid UTF-8 to specific error
                Err(Error::InvalidUtf8 { path: path_buf })
            }
            Err(e) => Err(Error::Io(e)),
        }
    });

    // Unwrap the timeout layer: Ok(inner) is the closure's result,
    // Err(io_err) is a timeout or thread disconnect
    match result {
        Ok(inner) => inner,
        Err(io_err) => Err(Error::Io(io_err)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[cfg(feature = "cli")]
    #[test]
    fn test_read_file_safe() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.txt");
        fs::write(&file_path, "Hello, World!").unwrap();

        let contents = read_file_safe(&file_path).unwrap();
        assert_eq!(contents, "Hello, World!");
    }

    #[cfg(feature = "cli")]
    #[test]
    fn test_read_file_safe_nonexistent() {
        let path = Path::new("/nonexistent/file.txt");
        let err = read_file_safe(path).unwrap_err();
        assert!(matches!(err, Error::FileNotFound { .. }));
    }

    #[cfg(feature = "cli")]
    #[test]
    fn test_read_file_safe_rejects_directory() {
        let temp = TempDir::new().unwrap();
        let result = read_file_safe(temp.path());
        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            Error::Io(_) | Error::NotARegularFile { .. }
        ));
    }

    #[cfg(feature = "cli")]
    #[test]
    fn test_read_file_safe_reports_size_error() {
        let temp = TempDir::new().unwrap();

        // Create a file that's too large
        let large_path = temp.path().join("large.bin");
        #[allow(clippy::cast_possible_truncation)]
        fs::write(
            &large_path,
            vec![0u8; usize::try_from(MAX_FILE_SIZE + 1).unwrap()],
        )
        .unwrap();

        let result = read_file_safe(&large_path);
        assert!(result.is_err());

        if let Err(Error::FileTooLarge {
            path,
            size,
            max_size,
        }) = result
        {
            assert_eq!(path, large_path);
            assert_eq!(size, MAX_FILE_SIZE + 1);
            assert_eq!(max_size, MAX_FILE_SIZE);
        } else {
            panic!("Expected FileTooLarge error");
        }
    }

    #[test]
    fn test_max_file_size_constant() {
        assert_eq!(MAX_FILE_SIZE, 100 * 1024 * 1024);
    }

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

        let file = open_file_safe(&file_path).unwrap();
        assert!(file.metadata().unwrap().is_file());
    }
}