soft-canonicalize 0.5.5

Path canonicalization that works with non-existing paths.
Documentation
// Unicode, null byte, and encoding edge case tests
use crate::soft_canonicalize;
use std::path::Path;
use tempfile::TempDir;

#[test]
fn test_unicode_path_edge_cases() -> std::io::Result<()> {
    // WHITE-BOX: Test Unicode edge cases that might break path handling
    let temp_dir = TempDir::new()?;
    let base = temp_dir.path();

    // Test various Unicode edge cases
    let unicode_paths = vec![
        "café/naïve.txt",         // Accented characters
        "🦀/rust.txt",            // Emoji
        "test\u{200B}hidden.txt", // Zero-width space
        "file\u{FEFF}bom.txt",    // BOM character
        "résumé/тест.txt",        // Mixed scripts
        "🏴/flag.txt",            // Complex emoji sequence
    ];

    for unicode_path in unicode_paths {
        let full_path = base.join(unicode_path);
        let result = soft_canonicalize(&full_path);

        match result {
            Ok(canonical) => {
                assert!(canonical.is_absolute());
                // Should preserve Unicode correctly
                assert!(canonical
                    .to_string_lossy()
                    .contains(unicode_path.split('/').next_back().unwrap()));
            }
            Err(e) => {
                // Some Unicode might be rejected by filesystem, that's OK
                println!("Unicode path rejected (acceptable): {unicode_path} - {e}");
            }
        }
    }

    Ok(())
}

#[test]
fn test_null_byte_injection() -> std::io::Result<()> {
    // WHITE-BOX: Test null byte injection attempts
    #[cfg(unix)]
    {
        use std::ffi::OsStr;
        use std::os::unix::ffi::OsStrExt;

        // Try to create path with embedded null byte
        let null_path = OsStr::from_bytes(b"test\0hidden.txt");
        let path = Path::new(null_path);

        let result = soft_canonicalize(path);
        assert!(result.is_err(), "Should reject null bytes in path");

        let error = result.unwrap_err();
        assert!(
            error.to_string().contains("null byte")
                || error.kind() == std::io::ErrorKind::InvalidInput
        );
    }

    #[cfg(windows)]
    {
        use std::ffi::OsString;
        use std::os::windows::ffi::OsStringExt;

        // Try to create path with embedded null wide character
        let null_path: OsString = OsString::from_wide(&[116, 101, 115, 116, 0, 46, 116, 120, 116]); // "test\0.txt"
        let path = Path::new(&null_path);

        let result = soft_canonicalize(path);
        assert!(result.is_err(), "Should reject null wide chars in path");
    }

    Ok(())
}

#[test]
fn test_null_byte_injection_extended() {
    // A path containing a null byte.
    let path_with_null = "a/b\0c/d";
    let result = soft_canonicalize(path_with_null);

    // The behavior of `soft_canonicalize` should be to either return an error
    // or to handle the path correctly without truncation.
    // On Unix, this will likely result in an `InvalidInput` error.
    // On Windows, the behavior might differ, but it should not truncate the path.
    if let Ok(canonical_path) = result {
        // If it succeeds, it should not be truncated at the null byte.
        assert!(!canonical_path.to_str().unwrap().contains('\0'));
    } else {
        // An error is an acceptable outcome.
        assert!(result.is_err());
    }
}

#[test]
fn test_null_byte_error_consistency() {
    // Test that soft_canonicalize behaves consistently with std::fs::canonicalize
    // for null byte handling
    let path_with_null = "test\0path";

    // Get the error from std::fs::canonicalize
    let std_result = std::fs::canonicalize(path_with_null);
    let soft_result = soft_canonicalize(path_with_null);

    // Both should fail
    assert!(
        std_result.is_err(),
        "std::fs::canonicalize should reject null bytes"
    );
    assert!(
        soft_result.is_err(),
        "soft_canonicalize should reject null bytes"
    );

    let std_error = std_result.unwrap_err();
    let soft_error = soft_result.unwrap_err();

    // Both should use the same error kind
    assert_eq!(
        std_error.kind(),
        soft_error.kind(),
        "Error kinds should match: std={:?}, soft={:?}",
        std_error.kind(),
        soft_error.kind()
    );

    // Both should be InvalidInput
    assert_eq!(std_error.kind(), std::io::ErrorKind::InvalidInput);
    assert_eq!(soft_error.kind(), std::io::ErrorKind::InvalidInput);

    // The error messages should be meaningful (though they may differ slightly)
    let std_msg = std_error.to_string().to_lowercase();
    let soft_msg = soft_error.to_string().to_lowercase();

    // Both should mention null or nul in some form
    assert!(
        std_msg.contains("nul") || soft_msg.contains("null"),
        "Error messages should reference null bytes: std='{std_error}', soft='{soft_error}'"
    );
}

#[test]
fn test_unicode_normalization_bypass_prevention() {
    // Test protection against Unicode normalization bypass attacks
    // Some path canonicalization functions can be bypassed using Unicode
    // characters that normalize differently

    // Test with Unicode that could potentially bypass path restrictions
    let unicode_path = "documents/user\u{0041}\u{0301}/file.txt"; // A with combining acute accent
    let normalized_path = "documents/userÁ/file.txt"; // Precomposed A with acute

    let result1 = soft_canonicalize(unicode_path);
    let result2 = soft_canonicalize(normalized_path);

    assert!(result1.is_ok());
    assert!(result2.is_ok());

    // Our function should handle Unicode consistently
    // (The exact behavior may vary by OS, but should be consistent)
    let path1 = result1.unwrap();
    let path2 = result2.unwrap();

    // Both should be absolute paths
    assert!(path1.is_absolute());
    assert!(path2.is_absolute());
}

#[test]
fn test_double_encoding_bypass_prevention() {
    // Test protection against double-encoding bypass attacks
    // Attackers sometimes use URL encoding or other encoding schemes
    // to bypass path validation

    // Test with various encoding attempts that should not be automatically decoded
    let encoded_paths = [
        "documents/%2e%2e/%2e%2e/etc/passwd", // URL encoded ../..
        "documents/..%2f..%2fetc%2fpasswd",   // Mixed encoding
        "documents/../%2e%2e/etc/passwd",     // Partial encoding
    ];

    for encoded_path in &encoded_paths {
        let result = soft_canonicalize(encoded_path);
        assert!(result.is_ok(), "Should handle encoded path: {encoded_path}");

        let resolved = result.unwrap();
        assert!(resolved.is_absolute());

        // The resolved path should contain the encoded characters as-is
        // (not automatically decoded, which would be a security vulnerability)
        let path_str = resolved.to_string_lossy();
        assert!(
            path_str.contains("%2e") || path_str.contains("%2f") || path_str.contains(".."),
            "Encoded characters should not be automatically decoded"
        );
    }
}

#[test]
fn test_case_sensitivity_bypass_prevention() {
    // Test protection against case sensitivity bypass attacks
    // On case-insensitive filesystems, attackers might use case variations
    // to bypass path restrictions

    let paths = [
        "Documents/file.txt",
        "documents/file.txt",
        "DOCUMENTS/file.txt",
        "Documents/FILE.TXT",
    ];

    let results: Vec<_> = paths.iter().map(soft_canonicalize).collect();

    // All should succeed
    for (i, result) in results.iter().enumerate() {
        assert!(result.is_ok(), "Path should canonicalize: {}", paths[i]);
    }

    // On case-insensitive systems, these should normalize to the same path
    // On case-sensitive systems, they'll be different
    // Either way, the behavior should be consistent with the filesystem
    #[cfg(windows)]
    {
        // Windows is case-insensitive - paths should normalize
        let canonical_results: Vec<_> = results.iter().map(|r| r.as_ref().unwrap()).collect();

        // Check that case normalization happens consistently
        for result in &canonical_results {
            assert!(result.is_absolute());
        }
    }
}

#[test]
fn test_zero_width_and_control_character_handling() {
    // Test handling of zero-width and control characters that might
    // be used to confuse path parsing or display

    let malicious_chars = [
        "\u{200B}", // Zero-width space
        "\u{200C}", // Zero-width non-joiner
        "\u{200D}", // Zero-width joiner
        "\u{FEFF}", // Zero-width no-break space (BOM)
        "\u{0001}", // Start of heading (control char)
        "\u{007F}", // Delete character
    ];

    for &char in &malicious_chars {
        let malicious_path = format!("documents/file{char}.txt");
        let result = soft_canonicalize(&malicious_path);

        assert!(result.is_ok(), "Should handle control character path");
        let resolved = result.unwrap();
        assert!(resolved.is_absolute());

        // The path should preserve the character (not strip it, which could
        // lead to unexpected path collisions)
        let path_str = resolved.to_string_lossy();
        assert!(
            path_str.contains(char),
            "Control character should be preserved, not stripped"
        );
    }
}