"""Extended tests for security/path_validator.py - targeting untested paths. Covers: - sanitize_for_filesystem_ops() (entirely untested) - validate_local_filesystem_path() relative path handling - validate_local_filesystem_path() path traversal via .. explicit check - validate_config_path() absolute path branch - validate_config_path() non-string input - validate_config_path() default config_root - validate_model_path() default model_root """ from pathlib import Path from unittest.mock import patch import pytest from local_deep_research.security.path_validator import PathValidator # ── sanitize_for_filesystem_ops ────────────────────────────────── class TestSanitizeForFilesystemOps: """Tests for sanitize_for_filesystem_ops (lines 225-235, entirely untested).""" def test_returns_path_for_valid_absolute(self): """Valid absolute path returns a Path object.""" result = PathValidator.sanitize_for_filesystem_ops(Path("/tmp/test")) assert isinstance(result, Path) assert str(result).startswith("/") def test_preserves_path_components(self): """Path components are preserved after sanitization.""" p = Path("/usr/local/share/data.txt") result = PathValidator.sanitize_for_filesystem_ops(p) assert result.name == "data.txt" assert "local" in str(result) def test_raises_for_relative_path(self): """Relative path raises ValueError.""" with pytest.raises(ValueError, match="absolute"): PathValidator.sanitize_for_filesystem_ops(Path("relative/path")) def test_raises_for_dot_relative(self): """Dot-relative path raises ValueError.""" with pytest.raises(ValueError, match="absolute"): PathValidator.sanitize_for_filesystem_ops(Path("./here")) # ── validate_local_filesystem_path: relative path handling ─────── class TestLocalFilesystemPathRelative: """Tests for relative path handling in validate_local_filesystem_path (lines 160-167).""" def test_relative_path_resolved_from_cwd(self, tmp_path): """Relative path is resolved relative to current working directory.""" with patch("os.getcwd", return_value=str(tmp_path)): result = PathValidator.validate_local_filesystem_path( "some_folder", restricted_dirs=[] ) assert result == (tmp_path / "some_folder").resolve() def test_relative_nested_path(self, tmp_path): """Nested relative path resolves correctly.""" with patch("os.getcwd", return_value=str(tmp_path)): result = PathValidator.validate_local_filesystem_path( "a/b/c", restricted_dirs=[] ) assert str(result).endswith("a/b/c") def test_path_traversal_in_input_blocked(self): """Explicit .. in path input is blocked before resolution.""" with pytest.raises(ValueError, match="traversal"): PathValidator.validate_local_filesystem_path("../escape") def test_double_dot_in_middle_blocked(self): """Path with .. in the middle is blocked.""" with pytest.raises(ValueError, match="traversal"): PathValidator.validate_local_filesystem_path("safe/../escape") # ── validate_local_filesystem_path: home expansion edge cases ──── class TestLocalFilesystemPathHomeExpansion: """Tests for home directory expansion edge cases.""" @pytest.mark.skipif( Path.home() == Path("/root"), reason="Skipping in Docker/CI where home is /root (restricted)", ) def test_bare_tilde_resolves_to_home(self): """Bare ~ resolves to home directory.""" result = PathValidator.validate_local_filesystem_path("~") assert result == Path.home().resolve() @pytest.mark.skipif( Path.home() == Path("/root"), reason="Skipping in Docker/CI where home is /root (restricted)", ) def test_tilde_with_relative_part(self): """~/path expands home and joins relative part.""" result = PathValidator.validate_local_filesystem_path("~/some/path") expected = (Path.home() / "some/path").resolve() assert result == expected # ── validate_config_path: absolute path branch ────────────────── class TestConfigPathAbsolute: """Tests for absolute config path validation (lines 361-380). Note: werkzeug's safe_join("/", "/absolute/path") returns None, so absolute paths are always rejected as "Invalid absolute path". This test documents that security-conservative behavior. """ def test_absolute_config_rejected_by_safe_join(self, tmp_path): """Absolute path is rejected because safe_join returns None.""" config_file = tmp_path / "settings.json" config_file.write_text('{"key": "value"}') with pytest.raises(ValueError, match="Invalid absolute path"): PathValidator.validate_config_path(str(config_file)) def test_absolute_config_wrong_extension_also_rejected(self, tmp_path): """Absolute path with wrong extension is also rejected (before ext check).""" script = tmp_path / "evil.py" script.write_text("import os") with pytest.raises(ValueError, match="Invalid absolute path"): PathValidator.validate_config_path(str(script)) def test_absolute_config_nonexistent_also_rejected(self, tmp_path): """Absolute path to non-existent config is also rejected (before existence check).""" with pytest.raises(ValueError, match="Invalid absolute path"): PathValidator.validate_config_path(str(tmp_path / "missing.json")) # ── validate_config_path: input validation ─────────────────────── class TestConfigPathInputValidation: """Tests for config path input validation edge cases.""" def test_non_string_input_raises(self): """Non-string input raises ValueError.""" with pytest.raises(ValueError, match="Invalid"): PathValidator.validate_config_path(123) def test_none_input_raises(self): """None input raises ValueError.""" with pytest.raises(ValueError, match="Invalid"): PathValidator.validate_config_path(None) def test_empty_string_raises(self): """Empty string raises ValueError.""" with pytest.raises(ValueError, match="Invalid"): PathValidator.validate_config_path("") def test_sys_prefix_blocked(self): """Path starting with sys/ is blocked as restricted.""" with pytest.raises(ValueError, match="restricted"): PathValidator.validate_config_path("/sys/class/net") def test_dev_prefix_blocked(self): """Path starting with dev/ is blocked as restricted.""" with pytest.raises(ValueError, match="restricted"): PathValidator.validate_config_path("/dev/null") # ── validate_config_path: default config_root ──────────────────── class TestConfigPathDefaultRoot: """Tests for config path with default config_root (lines 383-386).""" def test_uses_default_root_when_none(self, tmp_path): """When config_root is None, get_data_directory() is used.""" config_file = tmp_path / "test.json" config_file.write_text("{}") # get_data_directory is imported locally inside validate_config_path with patch( "local_deep_research.config.paths.get_data_directory", return_value=str(tmp_path), ): result = PathValidator.validate_config_path("test.json") assert result.name == "test.json" # ── validate_model_path: default model_root ────────────────────── class TestModelPathDefaultRoot: """Tests for model path with default model_root (line 256).""" def test_uses_default_root_when_none(self, tmp_path): """When model_root is None, get_models_directory() is used.""" model_file = tmp_path / "model.gguf" model_file.write_text("fake model") with patch( "local_deep_research.security.path_validator.get_models_directory", return_value=tmp_path, ): result = PathValidator.validate_model_path("model.gguf") assert result.exists() assert result.name == "model.gguf"