test_security_paths.pyβ’21.2 kB
"""Comprehensive tests for security.paths module."""
import tempfile
from pathlib import Path
import pytest
from mnemex.security.paths import (
ensure_within_directory,
sanitize_filename,
validate_folder_path,
validate_vault_path,
)
class TestValidateFolderPath:
"""Tests for validate_folder_path function."""
def test_valid_single_level_folder(self):
"""Test valid single-level folder paths."""
assert validate_folder_path("notes") == "notes"
assert validate_folder_path("projects") == "projects"
assert validate_folder_path("work") == "work"
def test_valid_nested_folder_paths(self):
"""Test valid nested folder paths."""
assert validate_folder_path("notes/personal") == "notes/personal"
assert validate_folder_path("work/projects/2024") == "work/projects/2024"
assert validate_folder_path("a/b/c/d/e") == "a/b/c/d/e"
def test_empty_folder_allow_empty_true(self):
"""Test empty folder with allow_empty=True."""
assert validate_folder_path("", allow_empty=True) == ""
def test_empty_folder_allow_empty_false(self):
"""Test empty folder with allow_empty=False."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_folder_path("", allow_empty=False)
def test_whitespace_only_folder(self):
"""Test that whitespace-only folders become empty after stripping."""
with pytest.raises(ValueError, match="empty path component"):
validate_folder_path(" ", allow_empty=True)
with pytest.raises(ValueError, match="empty path component"):
validate_folder_path(" ", allow_empty=False)
def test_path_traversal_double_dots(self):
"""Test rejection of path traversal with .. patterns."""
with pytest.raises(ValueError, match="path traversal patterns"):
validate_folder_path("..")
with pytest.raises(ValueError, match="path traversal patterns"):
validate_folder_path("../etc")
with pytest.raises(ValueError, match="path traversal patterns"):
validate_folder_path("../../etc/passwd")
with pytest.raises(ValueError, match="path traversal patterns"):
validate_folder_path("notes/../../../etc")
def test_absolute_paths_unix(self):
"""Test rejection of absolute Unix paths."""
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("/")
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("/etc/passwd")
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("/home/user/notes")
def test_absolute_paths_windows(self):
"""Test rejection of absolute Windows paths."""
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("C:\\")
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("C:\\Users\\test")
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("D:/projects")
def test_network_paths(self):
"""Test rejection of UNC network paths."""
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("\\\\server\\share")
with pytest.raises(ValueError, match="must be a relative path"):
validate_folder_path("\\\\network\\folder")
def test_control_characters(self):
"""Test rejection of control characters."""
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test\x00folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test\x1ffolder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test\x0bfolder")
def test_dangerous_characters(self):
"""Test rejection of dangerous characters."""
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test<folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test>folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test:folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path('test"folder')
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test|folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test?folder")
with pytest.raises(ValueError, match="forbidden characters"):
validate_folder_path("test*folder")
def test_double_slashes(self):
"""Test rejection of double slashes (empty path components)."""
with pytest.raises(ValueError, match="consecutive slashes"):
validate_folder_path("a//b")
with pytest.raises(ValueError, match="consecutive slashes"):
validate_folder_path("test//folder")
with pytest.raises(ValueError, match="consecutive slashes"):
validate_folder_path("a///b///c")
def test_current_directory_reference(self):
"""Test rejection of current directory references (.)."""
with pytest.raises(ValueError, match="current directory reference"):
validate_folder_path(".")
with pytest.raises(ValueError, match="current directory reference"):
validate_folder_path("./notes")
with pytest.raises(ValueError, match="current directory reference"):
validate_folder_path("notes/./subfolder")
with pytest.raises(ValueError, match="current directory reference"):
validate_folder_path("a/b/./c")
def test_whitespace_handling(self):
"""Test whitespace trimming."""
assert validate_folder_path(" notes ") == "notes"
assert validate_folder_path("\tnotes\t") == "notes"
assert validate_folder_path(" notes/personal ") == "notes/personal"
def test_trailing_slashes_removed(self):
"""Test that trailing slashes are removed (leading / is absolute path)."""
assert validate_folder_path("notes/") == "notes"
assert validate_folder_path("notes/personal/") == "notes/personal"
assert validate_folder_path("a/b/c/") == "a/b/c"
def test_backslash_to_forward_slash_normalization(self):
"""Test backslash to forward slash conversion."""
assert validate_folder_path("notes\\personal") == "notes/personal"
assert validate_folder_path("a\\b\\c") == "a/b/c"
assert validate_folder_path("mixed/path\\test") == "mixed/path/test"
def test_custom_field_name_in_errors(self):
"""Test that custom field_name appears in error messages."""
with pytest.raises(ValueError, match="custom_field"):
validate_folder_path("..", field_name="custom_field")
with pytest.raises(ValueError, match="custom_field"):
validate_folder_path("", field_name="custom_field", allow_empty=False)
class TestSanitizeFilename:
"""Tests for sanitize_filename function."""
def test_valid_normal_filename(self):
"""Test valid normal filenames."""
assert sanitize_filename("normal-file.md") == "normal-file.md"
assert sanitize_filename("document.txt") == "document.txt"
assert sanitize_filename("my_file.py") == "my_file.py"
def test_valid_filename_with_extensions(self):
"""Test valid filenames with various extensions."""
assert sanitize_filename("readme.md") == "readme.md"
assert sanitize_filename("data.json") == "data.json"
assert sanitize_filename("image.png") == "image.png"
assert sanitize_filename("archive.tar.gz") == "archive.tar.gz"
def test_valid_filename_with_hyphens(self):
"""Test valid filenames with hyphens."""
assert sanitize_filename("my-test-file.txt") == "my-test-file.txt"
assert sanitize_filename("dash-separated-name.md") == "dash-separated-name.md"
def test_path_separators_removed(self):
"""Test that path separators are replaced with hyphens."""
assert sanitize_filename("../../etc/passwd") == "etc-passwd"
assert sanitize_filename("path/to/file.txt") == "path-to-file.txt"
assert sanitize_filename("windows\\path\\file.txt") == "windows-path-file.txt"
assert sanitize_filename("a/b\\c/d.txt") == "a-b-c-d.txt"
def test_control_characters_removed(self):
"""Test that control characters are removed."""
assert sanitize_filename("test\x00file.txt") == "testfile.txt"
assert sanitize_filename("test\x1ffile.txt") == "testfile.txt"
assert sanitize_filename("test\x0bfile.txt") == "testfile.txt"
assert sanitize_filename("test\x7ffile.txt") == "testfile.txt"
def test_dangerous_characters_replaced(self):
"""Test that dangerous characters are replaced with hyphens."""
assert sanitize_filename("file<name>.txt") == "file-name-.txt"
assert sanitize_filename("file>name.txt") == "file-name.txt"
assert sanitize_filename("file:name.txt") == "file-name.txt"
assert sanitize_filename('file"name.txt') == "file-name.txt"
assert sanitize_filename("file|name.txt") == "file-name.txt"
assert sanitize_filename("file?name.txt") == "file-name.txt"
assert sanitize_filename("file*name.txt") == "file-name.txt"
def test_windows_reserved_names(self):
"""Test rejection of Windows reserved names."""
reserved = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]
for name in reserved:
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename(name)
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename(name.lower())
def test_reserved_names_with_extensions(self):
"""Test rejection of Windows reserved names with extensions."""
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename("CON.txt")
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename("PRN.md")
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename("NUL.json")
with pytest.raises(ValueError, match="Windows reserved name"):
sanitize_filename("COM1.log")
def test_length_validation_within_limit(self):
"""Test that filenames within the length limit are accepted."""
filename_200 = "a" * 200 + ".txt"
assert len(sanitize_filename(filename_200)) == 204
def test_length_validation_exceeds_limit(self):
"""Test rejection of filenames exceeding max length."""
filename_too_long = "a" * 256
with pytest.raises(ValueError, match="exceeds maximum length"):
sanitize_filename(filename_too_long)
filename_260 = "x" * 260
with pytest.raises(ValueError, match="exceeds maximum length"):
sanitize_filename(filename_260)
def test_custom_max_length(self):
"""Test custom max_length parameter."""
with pytest.raises(ValueError, match="exceeds maximum length of 10"):
sanitize_filename("verylongfilename.txt", max_length=10)
assert sanitize_filename("short.txt", max_length=20) == "short.txt"
def test_empty_filename(self):
"""Test rejection of empty filenames."""
with pytest.raises(ValueError, match="cannot be empty"):
sanitize_filename("")
with pytest.raises(ValueError, match="cannot be empty"):
sanitize_filename(" ")
with pytest.raises(ValueError, match="cannot be empty"):
sanitize_filename("\t\n")
def test_filename_becomes_empty_after_sanitization(self):
"""Test rejection when filename becomes empty after sanitization."""
with pytest.raises(ValueError, match="empty after sanitization"):
sanitize_filename("///")
with pytest.raises(ValueError, match="empty after sanitization"):
sanitize_filename("<<<>>>")
with pytest.raises(ValueError, match="empty after sanitization"):
sanitize_filename(".....")
with pytest.raises(ValueError, match="empty after sanitization"):
sanitize_filename("---")
def test_duplicate_hyphens_collapsed(self):
"""Test that duplicate hyphens are collapsed."""
assert sanitize_filename("test---file.txt") == "test-file.txt"
assert sanitize_filename("a----b----c.txt") == "a-b-c.txt"
def test_leading_trailing_hyphens_removed(self):
"""Test that leading/trailing hyphens and dots are removed."""
assert sanitize_filename("-file.txt") == "file.txt"
assert sanitize_filename("file.txt-") == "file.txt"
assert sanitize_filename(".hidden") == "hidden"
assert sanitize_filename("..file.txt") == "file.txt"
def test_custom_field_name_in_errors(self):
"""Test that custom field_name appears in error messages."""
with pytest.raises(ValueError, match="my_file"):
sanitize_filename("", field_name="my_file")
with pytest.raises(ValueError, match="my_file"):
sanitize_filename("a" * 300, field_name="my_file")
class TestEnsureWithinDirectory:
"""Tests for ensure_within_directory function."""
def test_path_within_base_directory_relative(self):
"""Test that paths within base directory are accepted (relative)."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
test_path = base_dir / "notes" / "test.md"
result = ensure_within_directory(test_path, base_dir)
assert result.is_absolute()
# Resolve both paths to handle symlinks (e.g., /var -> /private/var on macOS)
assert str(result).startswith(str(base_dir.resolve()))
def test_path_within_base_directory_absolute(self):
"""Test that paths within base directory are accepted (absolute)."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir).resolve()
test_path = base_dir / "subfolder" / "file.txt"
result = ensure_within_directory(test_path, base_dir)
assert result.is_absolute()
assert str(result).startswith(str(base_dir))
def test_nested_paths_within_base(self):
"""Test deeply nested paths within base directory."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
test_path = base_dir / "a" / "b" / "c" / "d" / "e" / "file.txt"
result = ensure_within_directory(test_path, base_dir)
assert str(result).startswith(str(base_dir.resolve()))
def test_path_traversal_escaping_base(self):
"""Test that path traversal attempts are rejected."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
test_path = base_dir / ".." / "etc" / "passwd"
with pytest.raises(ValueError, match="escapes base directory"):
ensure_within_directory(test_path, base_dir)
test_path2 = base_dir / "notes" / ".." / ".." / "etc"
with pytest.raises(ValueError, match="escapes base directory"):
ensure_within_directory(test_path2, base_dir)
def test_absolute_path_outside_base(self):
"""Test that absolute paths outside base are rejected."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
outside_path = Path("/etc/passwd")
with pytest.raises(ValueError, match="escapes base directory"):
ensure_within_directory(outside_path, base_dir)
def test_symlink_resolution_enabled(self):
"""Test symlink resolution with resolve_symlinks=True."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
real_file = base_dir / "real_file.txt"
real_file.write_text("test content")
symlink_file = base_dir / "symlink.txt"
symlink_file.symlink_to(real_file)
result = ensure_within_directory(symlink_file, base_dir, resolve_symlinks=True)
assert result.is_absolute()
assert str(result).startswith(str(base_dir.resolve()))
def test_symlink_resolution_disabled(self):
"""Test symlink resolution with resolve_symlinks=False."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
real_file = base_dir / "real_file.txt"
real_file.write_text("test content")
symlink_file = base_dir / "symlink.txt"
symlink_file.symlink_to(real_file)
result = ensure_within_directory(symlink_file, base_dir, resolve_symlinks=False)
assert result.is_absolute()
assert str(result).startswith(str(base_dir.absolute()))
def test_symlink_escaping_base_with_resolution(self):
"""Test that symlinks escaping base are caught with resolve_symlinks=True."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir) / "vault"
base_dir.mkdir()
outside_dir = Path(tmpdir) / "outside"
outside_dir.mkdir()
outside_file = outside_dir / "secret.txt"
outside_file.write_text("secret")
symlink = base_dir / "link_to_secret.txt"
symlink.symlink_to(outside_file)
with pytest.raises(ValueError, match="escapes base directory"):
ensure_within_directory(symlink, base_dir, resolve_symlinks=True)
def test_custom_field_name_in_errors(self):
"""Test that custom field_name appears in error messages."""
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = Path(tmpdir)
outside_path = Path("/etc/passwd")
with pytest.raises(ValueError, match="my_path"):
ensure_within_directory(outside_path, base_dir, field_name="my_path")
class TestValidateVaultPath:
"""Tests for validate_vault_path function."""
def test_valid_absolute_path(self):
"""Test valid absolute paths."""
with tempfile.TemporaryDirectory() as tmpdir:
vault_path = Path(tmpdir) / "vault"
vault_path.mkdir()
result = validate_vault_path(vault_path)
assert result.is_absolute()
assert result == vault_path.resolve()
def test_valid_absolute_path_as_string(self):
"""Test valid absolute paths passed as strings."""
with tempfile.TemporaryDirectory() as tmpdir:
vault_dir = Path(tmpdir) / "vault"
vault_dir.mkdir()
result = validate_vault_path(str(vault_dir))
assert result.is_absolute()
assert isinstance(result, Path)
def test_relative_path_rejected(self):
"""Test that relative paths are rejected."""
with pytest.raises(ValueError, match="must be an absolute path"):
validate_vault_path("relative/path")
with pytest.raises(ValueError, match="must be an absolute path"):
validate_vault_path("vault")
with pytest.raises(ValueError, match="must be an absolute path"):
validate_vault_path("./vault")
def test_path_expansion_tilde(self):
"""Test that tilde (~) paths are expanded."""
vault_path = Path("~/vault")
result = validate_vault_path(vault_path)
assert result.is_absolute()
assert "~" not in str(result)
def test_path_expansion_tilde_string(self):
"""Test that tilde paths as strings are expanded."""
result = validate_vault_path("~/test/vault")
assert result.is_absolute()
assert "~" not in str(result)
def test_nonexistent_path_allowed(self):
"""Test that non-existent paths are allowed (will be created later)."""
with tempfile.TemporaryDirectory() as tmpdir:
vault_path = Path(tmpdir) / "nonexistent" / "vault"
result = validate_vault_path(vault_path)
assert result.is_absolute()
def test_custom_field_name_in_errors(self):
"""Test that custom field_name appears in error messages."""
with pytest.raises(ValueError, match="my_vault"):
validate_vault_path("relative/path", field_name="my_vault")