safename 0.1.0

Filename and path validation for security hardening
Documentation
//! Path validation.
//!
//! This module validates paths by splitting on `/` and validating each component
//! independently. It does **not** perform path canonicalization or normalization.
//!
//! # What This Module Does NOT Do
//!
//! - **No canonicalization**: Paths are not resolved to absolute paths
//! - **No symlink resolution**: Symbolic links are not followed
//! - **No `..` handling**: Parent directory references are validated as literal names
//! - **No normalization**: Consecutive slashes and `.` components are preserved
//!
//! This is intentional. Path canonicalization has security implications (TOCTOU races,
//! symlink attacks) and should be handled separately by the caller if needed.
//!
//! # Example
//!
//! ```
//! use safename::is_path_safe;
//!
//! // Validates each component, doesn't resolve the path
//! assert!(is_path_safe("/home/../etc/passwd")); // ".." is a valid component name
//! assert!(!is_path_safe("/home/-rf"));          // "-rf" starts with dash
//! ```

use crate::constants::DEFAULT_MAX_PATH_LEN;
use crate::error::{PathError, PathResult, SafeNameError};
use crate::name::{FileValidationOptions, validate_file_with_options};

/// Configuration for path validation.
#[derive(Debug, Clone)]
pub struct PathValidationOptions {
    /// Options for validating each path component (filename)
    pub file_options: FileValidationOptions,
    /// Maximum total path length (default: 4096)
    pub max_path_len: usize,
    /// Allow trailing NUL bytes (default: false)
    ///
    /// When enabled, trailing NUL bytes are trimmed before validation.
    /// The effective path is everything up to (but not including) the
    /// trailing NUL sequence. Embedded NULs (followed by non-NUL bytes)
    /// are still blocked.
    ///
    /// This is useful for validating fixed-stride null-padded strings
    /// from memory-mapped files or binary formats.
    pub allow_trailing_nul: bool,
}

impl Default for PathValidationOptions {
    fn default() -> Self {
        Self {
            file_options: FileValidationOptions::default(),
            max_path_len: DEFAULT_MAX_PATH_LEN,
            allow_trailing_nul: false,
        }
    }
}

/// Validates a full path by splitting on `/` and validating each component.
///
/// Accepts any type that can be referenced as bytes: `&[u8]`, `&str`, `String`,
/// `Vec<u8>`, etc.
///
/// # Errors
///
/// Returns [`PathError`] if any path component violates validation rules.
/// The error includes the component index and the underlying [`SafeNameError`].
///
/// [`SafeNameError`]: crate::SafeNameError
///
/// # Example
///
/// ```
/// use safename::{validate_path, SafeNameError};
///
/// assert!(validate_path(b"/home/user/file.txt").is_ok());
/// assert!(validate_path("/home/user/file.txt").is_ok());
/// assert!(validate_path(b"relative/path/file.txt").is_ok());
///
/// // Component 1 starts with dash
/// let result = validate_path(b"/home/-rf");
/// assert!(result.is_err());
/// ```
pub fn validate_path(path: impl AsRef<[u8]>) -> PathResult<()> {
    validate_path_with_options(path, &PathValidationOptions::default())
}

/// Validates a full path with custom options.
///
/// # Errors
///
/// Returns [`PathError`] if the path is empty, exceeds max length, or any component
/// violates validation rules.
pub fn validate_path_with_options(
    path: impl AsRef<[u8]>,
    options: &PathValidationOptions,
) -> PathResult<()> {
    let original = path.as_ref();

    // Trim trailing NUL bytes if enabled
    let path = if options.allow_trailing_nul {
        let end = original.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
        &original[..end]
    } else {
        original
    };

    // Empty path is invalid
    if path.is_empty() {
        return Err(PathError::new(
            0,
            SafeNameError::InvalidLength {
                len: 0,
                max: options.max_path_len,
            },
        ));
    }

    // Check total path length
    if path.len() > options.max_path_len {
        return Err(PathError::new(
            0,
            SafeNameError::InvalidLength {
                len: path.len(),
                max: options.max_path_len,
            },
        ));
    }

    let mut component_idx = 0;
    let mut start = 0;

    for (idx, &byte) in path.iter().enumerate() {
        if byte == b'/' {
            // Skip empty components (leading/trailing/consecutive slashes)
            if idx > start {
                let component = &path[start..idx];
                validate_file_with_options(component, &options.file_options)
                    .map_err(|err| PathError::new(component_idx, err))?;
                component_idx += 1;
            }
            start = idx + 1;
        }
    }

    // Handle final component (if any)
    if start < path.len() {
        let component = &path[start..];
        validate_file_with_options(component, &options.file_options)
            .map_err(|err| PathError::new(component_idx, err))?;
    }

    Ok(())
}

/// Checks if a full path is safe according to all enabled validation rules.
///
/// Accepts any type that can be referenced as bytes: `&[u8]`, `&str`, `String`,
/// `Vec<u8>`, etc.
///
/// # Example
///
/// ```
/// use safename::is_path_safe;
///
/// assert!(is_path_safe("/home/user/file.txt"));
/// assert!(is_path_safe(b"/home/user/file.txt"));
/// assert!(!is_path_safe("/home/-rf"));
/// ```
pub fn is_path_safe(path: impl AsRef<[u8]>) -> bool {
    validate_path(path).is_ok()
}

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

    #[test]
    fn test_path_validation() {
        assert!(validate_path(b"/home/user/file.txt").is_ok());
        assert!(validate_path("/home/user/file.txt").is_ok());
        assert!(validate_path(b"relative/path/file.txt").is_ok());
        assert!(validate_path(b"/").is_ok());
        assert!(validate_path(b"single").is_ok());

        let result = validate_path(b"/home/-rf");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert_eq!(err.index, 1);
        assert_eq!(
            err.error,
            SafeNameError::InvalidByte {
                index: 0,
                byte: b'-'
            }
        );

        // Empty components are skipped (consecutive slashes)
        assert!(validate_path(b"/home//user").is_ok());
    }

    #[test]
    fn test_is_path_safe_various_types() {
        assert!(is_path_safe("/home/user"));
        assert!(is_path_safe(b"/home/user"));
        assert!(is_path_safe(String::from("/home/user")));
    }

    #[test]
    fn test_dotdot_passes_validation() {
        // ".." is a valid filename component, not resolved
        assert!(validate_path(b"/home/../etc/passwd").is_ok());
        assert!(validate_path(b"./file.txt").is_ok());
        assert!(validate_path(b"a/./b/../c").is_ok());
    }

    #[test]
    fn test_empty_path() {
        // Empty path is invalid
        let result = validate_path(b"");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert_eq!(err.index, 0);
        assert_eq!(
            err.error,
            SafeNameError::InvalidLength { len: 0, max: 4096 }
        );
    }

    #[test]
    fn test_only_slashes() {
        // Root paths are valid (no components to fail)
        assert!(validate_path(b"/").is_ok());
        assert!(validate_path(b"//").is_ok());
        assert!(validate_path(b"///").is_ok());
    }

    #[test]
    fn test_relative_paths() {
        // Relative path with parent directory
        assert!(validate_path(b"../a/b/c").is_ok());
        // Relative path with trailing slash (empty final component is skipped)
        assert!(validate_path(b"a/b/c/").is_ok());
        // Single component (no slashes)
        assert!(validate_path(b"a").is_ok());
        // Single component with leading dotdot
        assert!(validate_path(b"..").is_ok());
        // Deeper relative traversal
        assert!(validate_path(b"../../foo/bar").is_ok());
    }

    #[test]
    fn test_path_too_long() {
        let long_path = vec![b'a'; 5000];
        let result = validate_path(&long_path);
        assert_eq!(
            result,
            Err(PathError::new(
                0,
                SafeNameError::InvalidLength {
                    len: 5000,
                    max: 4096
                }
            ))
        );
    }

    #[test]
    fn test_trailing_nul_disabled_by_default() {
        // NUL is blocked by default
        assert!(validate_path(b"/home/user\x00").is_err());
    }

    // Property-based tests
    proptest! {
        /// Absolute paths with valid components pass validation
        #[test]
        fn prop_absolute_paths_valid(
            components in prop::collection::vec("[a-zA-Z0-9._]{1,10}", 1..5)
        ) {
            let path = format!("/{}", components.join("/"));
            prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
        }

        /// Paths with leading-dash components are rejected
        #[test]
        fn prop_leading_dash_component_rejected(
            prefix_components in prop::collection::vec("[a-z]{1,5}", 0..3),
            suffix in "[a-z]{1,5}"
        ) {
            let mut path = prefix_components.join("/");
            if !path.is_empty() {
                path.push('/');
            }
            path.push_str(&format!("-{}", suffix));
            prop_assert!(validate_path(path.as_bytes()).is_err(), "Not rejected: {:?}", path);
        }

        /// Consecutive slashes are handled (empty components skipped)
        #[test]
        fn prop_consecutive_slashes_ok(
            prefix in "[a-z]{1,5}",
            suffix in "[a-z]{1,5}",
            slashes in 2usize..5
        ) {
            let path = format!("{}{}{}", prefix, "/".repeat(slashes), suffix);
            prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
        }

        /// Dot and dotdot components are valid in paths
        #[test]
        fn prop_dot_components_valid(
            prefix in "[a-z]{1,5}",
            dot in prop_oneof![Just("."), Just("..")],
            suffix in "[a-z]{1,5}"
        ) {
            let path = format!("{}/{}/{}", prefix, dot, suffix);
            prop_assert!(validate_path(path.as_bytes()).is_ok(), "Rejected: {:?}", path);
        }

        /// Path length limit is enforced
        #[test]
        fn prop_length_limit_enforced(extra in 1usize..100) {
            let len = 4096 + extra;
            let path = vec![b'a'; len];
            prop_assert!(validate_path(&path).is_err(), "Length {} not rejected", len);
        }

        /// Trailing NULs are trimmed when allow_trailing_nul is enabled
        #[test]
        fn prop_trailing_nuls_trimmed(nul_count in 1usize..100) {
            let opts = PathValidationOptions {
                allow_trailing_nul: true,
                ..Default::default()
            };
            let mut path = b"/home/user".to_vec();
            path.extend(vec![0u8; nul_count]);
            prop_assert!(
                validate_path_with_options(&path, &opts).is_ok(),
                "Path with {} trailing NULs rejected", nul_count
            );
        }

        /// Only-NUL paths are empty after trimming
        #[test]
        fn prop_only_nuls_empty(nul_count in 1usize..100) {
            let opts = PathValidationOptions {
                allow_trailing_nul: true,
                ..Default::default()
            };
            let path = vec![0u8; nul_count];
            prop_assert!(
                validate_path_with_options(&path, &opts).is_err(),
                "Path with only {} NULs should be empty error", nul_count
            );
        }

        /// Embedded NULs still blocked even with allow_trailing_nul
        #[test]
        fn prop_embedded_nul_blocked(
            prefix in "[a-z]{1,5}",
            suffix in "[a-z]{1,5}",
            trailing_nuls in 0usize..10
        ) {
            let opts = PathValidationOptions {
                allow_trailing_nul: true,
                ..Default::default()
            };
            let mut path = prefix.into_bytes();
            path.push(0);
            path.extend_from_slice(suffix.as_bytes());
            path.extend(vec![0u8; trailing_nuls]);
            prop_assert!(
                validate_path_with_options(&path, &opts).is_err(),
                "Embedded NUL not blocked: {:?}", path
            );
        }
    }
}