""" Deep coverage tests for security/path_validator.py. Targets uncovered paths: - sanitize_for_filesystem_ops: relative path rejection, safe_join None - validate_local_filesystem_path: tilde-only expansion, relative path, path traversal - validate_config_path: empty input, sys/dev prefix, Windows absolute, absolute non-config ext - validate_safe_path: exception re-raise wrapping """ import sys import tempfile from pathlib import Path from unittest.mock import patch import pytest from local_deep_research.security.path_validator import PathValidator class TestSanitizeForFilesystemOps: """Cover sanitize_for_filesystem_ops.""" def test_rejects_relative_path(self): """Relative paths raise ValueError.""" with pytest.raises(ValueError, match="must be absolute"): PathValidator.sanitize_for_filesystem_ops(Path("relative/path")) def test_accepts_absolute_path(self): """Valid absolute path passes through.""" result = PathValidator.sanitize_for_filesystem_ops(Path("/tmp/test")) assert result == Path("/tmp/test") def test_returns_none_when_safe_join_fails(self): """Raises ValueError when safe_join returns None.""" with patch( "local_deep_research.security.path_validator.safe_join", return_value=None, ): with pytest.raises(ValueError, match="security sanitization"): PathValidator.sanitize_for_filesystem_ops( Path("/some/absolute/path") ) class TestValidateLocalFilesystemPathAdditional: """Cover additional branches in validate_local_filesystem_path.""" def test_tilde_only_expands_to_home(self): """~ alone expands to home directory.""" home = Path.home() # Skip if home is a restricted dir restricted = [ Path("/etc"), Path("/sys"), Path("/proc"), Path("/dev"), Path("/root"), Path("/boot"), Path("/var/log"), ] for r in restricted: if home == r or str(home).startswith(str(r)): pytest.skip(f"Home dir {home} is restricted") result = PathValidator.validate_local_filesystem_path("~") assert result == home.resolve() def test_path_traversal_double_dot_blocked(self): """Paths with .. are blocked.""" with pytest.raises(ValueError, match="traversal"): PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd") def test_relative_path_resolved_from_cwd(self): """Relative paths resolve from cwd.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("os.getcwd", return_value=tmpdir): result = PathValidator.validate_local_filesystem_path( "subdir", restricted_dirs=[] ) assert result == Path(tmpdir).resolve() / "subdir" def test_relative_path_safe_join_none(self): """Relative path raises when safe_join returns None.""" with patch( "local_deep_research.security.path_validator.safe_join", return_value=None, ): with pytest.raises(ValueError, match="security validation"): PathValidator.validate_local_filesystem_path("some_path") @pytest.mark.skipif(sys.platform == "win32", reason="Unix test") def test_var_log_blocked(self): """Blocks /var/log directory.""" with pytest.raises(ValueError, match="system"): PathValidator.validate_local_filesystem_path("/var/log/syslog") @pytest.mark.skipif(sys.platform == "win32", reason="Unix test") def test_boot_blocked(self): """Blocks /boot directory.""" with pytest.raises(ValueError, match="system"): PathValidator.validate_local_filesystem_path("/boot/vmlinuz") class TestValidateConfigPathAdditional: """Cover additional branches in validate_config_path.""" def test_empty_config_path_rejected(self): """Empty string raises ValueError.""" with pytest.raises(ValueError, match="Invalid config path"): PathValidator.validate_config_path("") def test_none_config_path_rejected(self): """None raises ValueError.""" with pytest.raises(ValueError): PathValidator.validate_config_path(None) def test_sys_prefix_blocked(self): """Paths starting with /sys/ are blocked.""" with pytest.raises(ValueError, match="restricted"): PathValidator.validate_config_path("/sys/config.json") def test_dev_prefix_blocked(self): """Paths starting with /dev are blocked.""" with pytest.raises(ValueError, match="restricted"): PathValidator.validate_config_path("/dev/null") def test_bare_restricted_dir_blocked(self): """Bare restricted dir name (no trailing slash) is blocked.""" with pytest.raises(ValueError, match="restricted"): PathValidator.validate_config_path("/etc") def test_absolute_path_wrong_extension_rejected(self): """Absolute path with non-config extension is rejected.""" # Create temp file in a path that safe_join("/", ...) can resolve with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: path = f.name try: with pytest.raises(ValueError) as exc_info: PathValidator.validate_config_path(path) error_msg = str(exc_info.value).lower() assert ( "config file type" in error_msg or "absolute path" in error_msg ) finally: import os os.unlink(path) def test_absolute_path_nonexistent_file_rejected(self): """Absolute path to nonexistent config file is rejected.""" # safe_join("/", path) may return None for some temp paths # so we just check for ValueError regardless of exact message with pytest.raises(ValueError): PathValidator.validate_config_path("/tmp/nonexistent_config.json") def test_absolute_safe_join_returns_none(self): """Absolute path raises when safe_join returns None.""" with patch( "local_deep_research.security.path_validator.safe_join", return_value=None, ): with pytest.raises(ValueError, match="absolute path"): PathValidator.validate_config_path("/valid/config.json") class TestValidateSafePathExceptionWrapping: """Cover the exception wrapping in validate_safe_path.""" def test_null_byte_in_safe_path(self): """Null bytes in path raise ValueError.""" with tempfile.TemporaryDirectory() as tmpdir: with pytest.raises(ValueError, match="Null bytes"): PathValidator.validate_safe_path("file\x00.txt", Path(tmpdir))