"""Coverage tests for security/path_validator.py. Focuses on adversarial inputs and uncovered branches: - Null bytes in all methods - Path traversal attempts (../) - URL-encoded traversal - validate_local_filesystem_path: control chars, ~ expansion, restricted dirs, relative paths - validate_safe_path: extension filtering, None result - sanitize_for_filesystem_ops: non-absolute path - validate_config_path: restricted prefixes, absolute paths, missing config files - validate_model_path: missing/non-file model - validate_data_path success/failure """ from pathlib import Path from unittest.mock import patch import pytest from local_deep_research.security.path_validator import PathValidator MODULE = "local_deep_research.security.path_validator" # --------------------------------------------------------------------------- # validate_safe_path # --------------------------------------------------------------------------- class TestValidateSafePath: def test_null_byte_raises(self): with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_safe_path("file\x00name.txt", "/tmp") def test_empty_string_raises(self): with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_safe_path("", "/tmp") def test_none_input_raises(self): with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_safe_path(None, "/tmp") def test_path_traversal_blocked(self): with pytest.raises(ValueError, match="traversal|Invalid path"): PathValidator.validate_safe_path("../etc/passwd", "/tmp") def test_double_dot_deeply_nested_allowed_by_werkzeug(self): """werkzeug safe_join resolves a/b/../../etc/passwd within the base, so it's accepted (it resolves to base/etc/passwd, not an escape). No exception expected.""" result = PathValidator.validate_safe_path( "a/b/../../c/file.txt", "/tmp" ) assert result is not None def test_valid_relative_path_accepted(self, tmp_path): result = PathValidator.validate_safe_path( "subdir/file.txt", str(tmp_path) ) assert result is not None assert "subdir/file.txt" in str(result) def test_wrong_extension_raises(self, tmp_path): with pytest.raises(ValueError, match="Invalid file type"): PathValidator.validate_safe_path( "config.txt", str(tmp_path), required_extensions=(".json", ".yaml"), ) def test_correct_extension_accepted(self, tmp_path): result = PathValidator.validate_safe_path( "config.json", str(tmp_path), required_extensions=(".json", ".yaml"), ) assert result.suffix == ".json" def test_whitespace_stripped_from_input(self, tmp_path): result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path)) assert result is not None # --------------------------------------------------------------------------- # validate_local_filesystem_path # --------------------------------------------------------------------------- class TestValidateLocalFilesystemPath: def test_null_byte_raises(self): with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt") def test_control_characters_raise(self): with pytest.raises(ValueError, match="Control characters"): PathValidator.validate_local_filesystem_path("/tmp/file\x01.txt") def test_double_dot_traversal_blocked(self): with pytest.raises(ValueError, match="traversal"): PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd") def test_tilde_expansion(self, tmp_path): """~ is expanded to home directory.""" with patch.object(Path, "home", return_value=tmp_path): result = PathValidator.validate_local_filesystem_path("~/subdir") assert str(tmp_path) in str(result) def test_tilde_only_expansion(self, tmp_path): """~ alone expands to home directory.""" with patch.object(Path, "home", return_value=tmp_path): result = PathValidator.validate_local_filesystem_path("~") assert str(tmp_path) in str(result) def test_restricted_etc_blocked(self): with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path("/etc/passwd") def test_restricted_proc_blocked(self): with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path("/proc/cpuinfo") def test_restricted_sys_blocked(self): with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path("/sys/kernel") def test_restricted_dev_blocked(self): with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path("/dev/null") def test_home_directory_allowed(self, tmp_path): """User home dir is not restricted.""" result = PathValidator.validate_local_filesystem_path(str(tmp_path)) assert result is not None def test_empty_string_raises(self): with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_local_filesystem_path("") def test_none_raises(self): with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_local_filesystem_path(None) def test_relative_path_resolves(self, tmp_path): """Relative paths are resolved against cwd.""" with patch("os.getcwd", return_value=str(tmp_path)): result = PathValidator.validate_local_filesystem_path("subdir") assert result is not None def test_custom_restricted_dirs(self, tmp_path): """Custom restricted_dirs are respected.""" restricted = tmp_path / "secret" restricted.mkdir() with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path( str(restricted), restricted_dirs=[restricted] ) # --------------------------------------------------------------------------- # sanitize_for_filesystem_ops # --------------------------------------------------------------------------- class TestSanitizeForFilesystemOps: def test_non_absolute_path_raises(self): with pytest.raises(ValueError, match="must be absolute"): PathValidator.sanitize_for_filesystem_ops(Path("relative/path")) def test_absolute_path_returned(self, tmp_path): result = PathValidator.sanitize_for_filesystem_ops(tmp_path) assert result.is_absolute() assert str(tmp_path) in str(result) # --------------------------------------------------------------------------- # validate_config_path # --------------------------------------------------------------------------- class TestValidateConfigPath: def test_null_byte_raises(self): with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_config_path("config\x00.json") def test_empty_string_raises(self): with pytest.raises(ValueError, match="Invalid config path input"): PathValidator.validate_config_path("") def test_none_raises(self): with pytest.raises(ValueError, match="Invalid config path input"): PathValidator.validate_config_path(None) def test_double_dot_traversal_blocked(self): with pytest.raises(ValueError, match="traversal"): PathValidator.validate_config_path("../../etc/passwd.json") def test_restricted_etc_prefix_blocked(self): with pytest.raises(ValueError, match="restricted system directory"): PathValidator.validate_config_path("/etc/shadow.json") def test_restricted_proc_prefix_blocked(self): with pytest.raises(ValueError, match="restricted system directory"): PathValidator.validate_config_path("proc/something") def test_absolute_path_invalid_extension_blocked(self): """Absolute path starting with / causes safe_join(/, path) -> None -> error.""" # safe_join("/", "/some/path") returns None for any absolute path # so any absolute /path causes "Invalid absolute path" or a traversal error with pytest.raises(ValueError): PathValidator.validate_config_path("/some/config.exe") def test_absolute_path_causes_safe_join_failure(self): """validate_config_path with /absolute/path raises ValueError since safe_join('/','/'+ path) returns None (werkzeug treats it as traversal).""" with pytest.raises(ValueError): PathValidator.validate_config_path("/home/user/config.json") def test_relative_path_with_valid_extension_accepted(self, tmp_path): """Relative config path with valid extension goes through validate_safe_path.""" # Create a json file inside tmp_path so the path is valid config_file = tmp_path / "my_config.json" config_file.write_text("{}") result = PathValidator.validate_config_path( "my_config.json", config_root=str(tmp_path) ) assert result.suffix == ".json" # --------------------------------------------------------------------------- # validate_model_path # --------------------------------------------------------------------------- class TestValidateModelPath: def test_missing_model_file_raises(self, tmp_path): with pytest.raises(ValueError, match="not found"): PathValidator.validate_model_path( "nonexistent.gguf", model_root=str(tmp_path) ) def test_path_is_directory_not_file_raises(self, tmp_path): subdir = tmp_path / "models" subdir.mkdir() with pytest.raises(ValueError, match="not a file|not found"): PathValidator.validate_model_path( "models", model_root=str(tmp_path) ) def test_valid_model_file_accepted(self, tmp_path): model = tmp_path / "model.gguf" model.write_bytes(b"fake gguf data") result = PathValidator.validate_model_path( "model.gguf", model_root=str(tmp_path) ) assert result == model def test_path_traversal_in_model_path_blocked(self, tmp_path): with pytest.raises(ValueError, match="traversal|Invalid path"): PathValidator.validate_model_path( "../etc/passwd", model_root=str(tmp_path) ) # --------------------------------------------------------------------------- # validate_data_path # --------------------------------------------------------------------------- class TestValidateDataPath: def test_traversal_blocked(self, tmp_path): with pytest.raises(ValueError, match="traversal|Invalid path"): PathValidator.validate_data_path("../../secret", str(tmp_path)) def test_valid_data_path_accepted(self, tmp_path): result = PathValidator.validate_data_path("data/file.db", str(tmp_path)) assert result is not None assert "data/file.db" in str(result) def test_null_byte_blocked(self, tmp_path): with pytest.raises(ValueError, match="Null bytes|Invalid path"): PathValidator.validate_data_path("file\x00.db", str(tmp_path))