Skip to main content
Glama
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")

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mnemexai/mnemex'

If you have feedback or need assistance with the MCP directory API, please join our Discord server