safename 0.1.0

Filename and path validation for security hardening
Documentation
//! Path sanitization.

use crate::constants::DEFAULT_MAX_PATH_LEN;
use crate::error::{PathError, PathResult, SafeNameError};
use crate::name::{FileSanitizationOptions, sanitize_file_with_options};

/// Configuration for path sanitization.
#[derive(Debug, Clone)]
pub struct PathSanitizationOptions<'a> {
    /// Options for sanitizing each path component (filename)
    pub file_options: FileSanitizationOptions<'a>,
    /// Maximum total path length (default: 4096)
    pub max_path_len: usize,
}

impl Default for PathSanitizationOptions<'_> {
    fn default() -> Self {
        Self {
            file_options: FileSanitizationOptions::default(),
            max_path_len: DEFAULT_MAX_PATH_LEN,
        }
    }
}

/// Sanitizes a full path by sanitizing each component.
///
/// Accepts any type that can be referenced as bytes: `&[u8]`, `&str`, `String`,
/// `Vec<u8>`, etc.
///
/// # Errors
///
/// Returns [`PathError`] if the sanitized path exceeds max length.
///
/// # Example
///
/// ```
/// use safename::sanitize_path;
///
/// assert_eq!(sanitize_path(b"/home/-rf").unwrap(), b"/home/_rf");
/// assert_eq!(sanitize_path("/home/-rf").unwrap(), b"/home/_rf");
/// ```
pub fn sanitize_path(path: impl AsRef<[u8]>) -> PathResult<Vec<u8>> {
    sanitize_path_with_options(path, &PathSanitizationOptions::default())
}

/// Sanitizes a full path with custom options.
///
/// # Errors
///
/// Returns [`PathError`] if any component exceeds max length after sanitization,
/// or if the total path exceeds max path length.
pub fn sanitize_path_with_options(
    path: impl AsRef<[u8]>,
    options: &PathSanitizationOptions<'_>,
) -> PathResult<Vec<u8>> {
    let path = path.as_ref();

    // Empty path becomes the replacement character
    if path.is_empty() {
        return Ok(options.file_options.replacement.to_vec());
    }

    let mut result = Vec::with_capacity(path.len());
    let mut component_idx = 0;
    let mut start = 0;

    for (idx, &byte) in path.iter().enumerate() {
        if byte == b'/' {
            // Add any accumulated component
            if idx > start {
                let component = &path[start..idx];
                let sanitized = sanitize_file_with_options(component, &options.file_options)
                    .map_err(|err| PathError::new(component_idx, err))?;
                result.extend_from_slice(&sanitized);
                component_idx += 1;
            }
            result.push(b'/');
            start = idx + 1;
        }
    }

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

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

    Ok(result)
}

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

    #[test]
    fn test_sanitize_path() {
        assert_eq!(sanitize_path(b"/home/user").unwrap(), b"/home/user");
        assert_eq!(sanitize_path("/home/user").unwrap(), b"/home/user");

        let result = sanitize_path(b"/home/-rf").unwrap();
        assert!(validate_path(&result).is_ok());
    }

    #[test]
    fn test_sanitize_empty_path() {
        // Empty path sanitizes to the replacement character
        assert_eq!(sanitize_path(b"").unwrap(), b"_");
    }

    #[test]
    fn test_sanitize_relative_paths() {
        // Relative path with parent directory - unchanged
        assert_eq!(sanitize_path(b"../a/b/c").unwrap(), b"../a/b/c");
        // Relative path with trailing slash - unchanged
        assert_eq!(sanitize_path(b"a/b/c/").unwrap(), b"a/b/c/");
        // Single component - unchanged
        assert_eq!(sanitize_path(b"a").unwrap(), b"a");
        // Dotdot only - unchanged
        assert_eq!(sanitize_path(b"..").unwrap(), b"..");
        // Relative with invalid component gets sanitized
        let result = sanitize_path(b"../-rf/file").unwrap();
        assert_eq!(result, b"../_rf/file");
        assert!(validate_path(&result).is_ok());
    }

    #[test]
    fn test_sanitize_path_too_long() {
        let long_path = vec![b'a'; 5000];
        let result = sanitize_path(&long_path);
        assert!(result.is_err());
    }

    #[test]
    fn test_sanitize_path_exceeds_max_after_replacement() {
        // Create a path that's under the limit but will exceed it after sanitization
        // Using Unicode replacement char (3 bytes) makes each bad byte expand
        use crate::REPLACEMENT_CHAR;
        use crate::name::FileSanitizationOptions;

        let file_opts = FileSanitizationOptions {
            replacement: REPLACEMENT_CHAR,
            ..Default::default()
        };
        let opts = PathSanitizationOptions {
            file_options: file_opts,
            max_path_len: 20, // Small limit for testing
        };

        // Path with control chars that will be replaced with 3-byte sequences
        // "/a\x00\x00\x00\x00\x00\x00\x00" = 10 bytes input
        // After sanitization: "/a" + 7 * 3 bytes = 23 bytes (exceeds 20)
        let path = b"/a\x00\x00\x00\x00\x00\x00\x00";
        let result = sanitize_path_with_options(path, &opts);
        assert!(result.is_err());
        if let Err(err) = result {
            assert!(matches!(err.error, SafeNameError::InvalidLength { .. }));
        }
    }

    // Property-based tests
    proptest! {
        /// Any sanitized path must pass validation
        #[test]
        fn prop_sanitized_path_always_valid(input in prop::collection::vec(any::<u8>(), 0..256)) {
            if let Ok(sanitized) = sanitize_path(&input) {
                prop_assert!(
                    validate_path(&sanitized).is_ok(),
                    "sanitize_path({:?}) = {:?} failed validation",
                    input, sanitized
                );
            }
        }

        /// Valid paths should remain unchanged after sanitization
        #[test]
        fn prop_valid_paths_unchanged(
            components in prop::collection::vec("[a-zA-Z0-9._]{1,10}", 1..5)
        ) {
            let path = components.join("/");
            let bytes = path.as_bytes();
            if validate_path(bytes).is_ok() {
                let sanitized = sanitize_path(bytes).unwrap();
                prop_assert_eq!(sanitized.as_slice(), bytes, "Path modified by sanitization");
            }
        }
    }
}