"""Tests for PathValidator — security-sensitive path validation edge cases.""" from pathlib import Path import pytest from local_deep_research.security.path_validator import PathValidator # =========================================================================== # 1. validate_safe_path # =========================================================================== class TestValidateSafePath: """Verify validate_safe_path input validation and traversal blocking.""" def test_non_string_input_rejected(self): """Non-string input raises ValueError.""" with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_safe_path(123, "/tmp") def test_none_input_rejected(self): """None input raises ValueError.""" with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_safe_path(None, "/tmp") def test_empty_string_rejected(self): """Empty string raises ValueError.""" with pytest.raises(ValueError, match="Invalid path input"): PathValidator.validate_safe_path("", "/tmp") def test_null_bytes_rejected_in_safe_path(self, tmp_path): """Null bytes in validate_safe_path raise ValueError.""" with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_safe_path("file\x00.txt", str(tmp_path)) def test_traversal_blocked(self, tmp_path): """Path traversal attempt via .. is blocked.""" with pytest.raises(ValueError): PathValidator.validate_safe_path("../../etc/passwd", str(tmp_path)) def test_valid_relative_path(self, tmp_path): """Valid relative path returns resolved Path.""" result = PathValidator.validate_safe_path( "subdir/file.txt", str(tmp_path) ) assert result is not None assert str(tmp_path) in str(result) def test_extension_check_rejects_wrong_extension(self, tmp_path): """Wrong extension raises ValueError.""" with pytest.raises(ValueError, match="Invalid file type"): PathValidator.validate_safe_path( "file.txt", str(tmp_path), required_extensions=(".json", ".yaml"), ) def test_extension_check_accepts_correct_extension(self, tmp_path): """Correct extension passes validation.""" result = PathValidator.validate_safe_path( "config.json", str(tmp_path), required_extensions=(".json", ".yaml"), ) assert result.suffix == ".json" def test_extension_case_sensitivity(self, tmp_path): """Extension check is case-sensitive — .JSON != .json.""" with pytest.raises(ValueError, match="Invalid file type"): PathValidator.validate_safe_path( "config.JSON", str(tmp_path), required_extensions=(".json",), ) def test_whitespace_stripped(self, tmp_path): """Leading/trailing whitespace in input is stripped.""" result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path)) assert result is not None assert "file.txt" in str(result) # =========================================================================== # 2. validate_local_filesystem_path — home dir expansion # =========================================================================== class TestHomeDirExpansion: """Verify ~ expansion edge cases.""" def test_tilde_slash_expands_to_home(self): """'~/' expands to home directory.""" home = str(Path.home()) result = PathValidator.validate_local_filesystem_path( "~/documents", restricted_dirs=[], ) assert str(result).startswith(home) def test_tilde_alone_expands_to_home(self): """'~' alone expands to home directory.""" home = Path.home().resolve() result = PathValidator.validate_local_filesystem_path( "~", restricted_dirs=[], ) assert result == home def test_tilde_with_trailing_slashes(self): """'~/' with no relative part yields home directory.""" home = Path.home().resolve() result = PathValidator.validate_local_filesystem_path( "~/", restricted_dirs=[], ) assert result == home def test_null_bytes_rejected(self): """Null bytes in path raise ValueError.""" with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt") def test_control_characters_rejected(self): """Control characters raise ValueError.""" with pytest.raises(ValueError, match="Control characters"): PathValidator.validate_local_filesystem_path("/tmp/file\x01name") def test_path_traversal_blocked(self): """.. in path raises ValueError.""" with pytest.raises(ValueError, match="traversal"): PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd") def test_restricted_dir_blocked(self): """Access to /etc is blocked by default restrictions.""" with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path("/etc/passwd") def test_custom_restricted_dir(self, tmp_path): """Custom restricted dirs are enforced.""" restricted = tmp_path / "secret" restricted.mkdir() target = restricted / "file.txt" target.touch() with pytest.raises(ValueError, match="system directories"): PathValidator.validate_local_filesystem_path( str(target), restricted_dirs=[restricted], ) # =========================================================================== # 3. sanitize_for_filesystem_ops — entirely uncovered # =========================================================================== class TestSanitizeForFilesystemOps: """Verify sanitize_for_filesystem_ops validation.""" def test_relative_path_rejected(self): """Relative path raises ValueError.""" with pytest.raises(ValueError, match="must be absolute"): PathValidator.sanitize_for_filesystem_ops(Path("relative/path")) def test_absolute_path_passes(self, tmp_path): """Absolute path is returned as a Path.""" result = PathValidator.sanitize_for_filesystem_ops(tmp_path) assert isinstance(result, Path) assert result.is_absolute() def test_root_path(self): """Root path '/' passes sanitization.""" result = PathValidator.sanitize_for_filesystem_ops(Path("/")) assert result == Path("/") def test_deep_path_preserved(self, tmp_path): """Deep nested path structure is preserved.""" deep = tmp_path / "a" / "b" / "c" result = PathValidator.sanitize_for_filesystem_ops(deep) assert str(result).endswith("a/b/c") # =========================================================================== # 4. validate_model_path # =========================================================================== class TestValidateModelPath: """Verify validate_model_path checks.""" def test_nonexistent_model_file(self, tmp_path): """Non-existent model file raises ValueError.""" with pytest.raises(ValueError, match="Model file not found"): PathValidator.validate_model_path( "model.bin", model_root=str(tmp_path) ) def test_directory_not_file_rejected(self, tmp_path): """Directory path raises 'not a file' error.""" subdir = tmp_path / "model_dir" subdir.mkdir() with pytest.raises(ValueError, match="not a file"): PathValidator.validate_model_path( "model_dir", model_root=str(tmp_path) ) def test_valid_model_file(self, tmp_path): """Valid model file returns resolved path.""" model_file = tmp_path / "model.gguf" model_file.touch() result = PathValidator.validate_model_path( "model.gguf", model_root=str(tmp_path) ) assert result == model_file.resolve() def test_traversal_in_model_path(self, tmp_path): """Path traversal in model path is blocked.""" with pytest.raises(ValueError): PathValidator.validate_model_path( "../../../etc/passwd", model_root=str(tmp_path) ) def test_model_root_created_if_missing(self, tmp_path): """Model root directory is created if it doesn't exist.""" new_root = tmp_path / "new_models" # Will fail because file doesn't exist, but root should be created try: PathValidator.validate_model_path( "test.bin", model_root=str(new_root) ) except ValueError: pass assert new_root.exists() # =========================================================================== # 5. validate_config_path — restricted prefixes and null byte rejection # =========================================================================== class TestValidateConfigPath: """Verify validate_config_path security checks.""" def test_non_string_rejected(self): """Non-string input raises ValueError.""" with pytest.raises(ValueError, match="Invalid config path"): PathValidator.validate_config_path(123) def test_empty_string_rejected(self): """Empty string raises ValueError.""" with pytest.raises(ValueError, match="Invalid config path"): PathValidator.validate_config_path("") def test_null_bytes_rejected(self, tmp_path): """Null bytes in config paths raise ValueError.""" with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_config_path( "config\x00.json", config_root=str(tmp_path), ) def test_traversal_rejected(self): """.. in config path raises ValueError.""" with pytest.raises(ValueError, match="traversal"): PathValidator.validate_config_path( "../etc/passwd", config_root="/tmp" ) @pytest.mark.parametrize( "restricted", ["etc/passwd", "proc/self", "sys/class", "dev/null"] ) def test_restricted_prefixes_blocked(self, restricted): """System directory prefixes are blocked.""" with pytest.raises(ValueError, match="restricted system directory"): PathValidator.validate_config_path(f"/{restricted}") def test_restricted_prefix_exact_match(self): """Exact restricted directory name (no trailing path) is blocked.""" with pytest.raises(ValueError, match="restricted system directory"): PathValidator.validate_config_path("/etc") def test_invalid_extension_rejected(self, tmp_path): """Non-config extension is rejected.""" txt_file = tmp_path / "file.txt" txt_file.touch() with pytest.raises(ValueError, match="Invalid"): PathValidator.validate_config_path( "file.txt", config_root=str(tmp_path) ) def test_valid_config_extensions(self, tmp_path): """All valid config extensions are accepted.""" for ext in (".json", ".yaml", ".yml", ".toml", ".ini", ".conf"): config_file = tmp_path / f"config{ext}" config_file.write_text("") result = PathValidator.validate_config_path( f"config{ext}", config_root=str(tmp_path), ) assert result is not None def test_absolute_config_path_rejected_by_safe_join(self, tmp_path): """Absolute config paths fail safe_join validation (safe_join rejects absolute second args).""" config_file = tmp_path / "app.json" config_file.write_text("{}") # safe_join("/", "/tmp/.../app.json") returns None because the path is absolute with pytest.raises(ValueError, match="Invalid absolute path"): PathValidator.validate_config_path(str(config_file)) def test_restricted_prefix_case_insensitive(self): """Restricted prefix check is case-insensitive.""" with pytest.raises(ValueError, match="restricted system directory"): PathValidator.validate_config_path("/ETC/passwd") # =========================================================================== # 6. validate_data_path # =========================================================================== class TestValidateDataPath: """Verify validate_data_path basics.""" def test_valid_data_path(self, tmp_path): """Valid relative path returns resolved path.""" result = PathValidator.validate_data_path("data.db", str(tmp_path)) assert "data.db" in str(result) def test_traversal_in_data_path(self, tmp_path): """Path traversal is blocked.""" with pytest.raises(ValueError): PathValidator.validate_data_path("../../etc/passwd", str(tmp_path))