"""Tests for path traversal protection in PDF storage manager (PR #1890). Verifies: - _get_safe_file_path blocks traversal, symlinks, and sentinel values - library_root is resolved on init - get_absolute_path_from_settings validates resolved paths """ import pytest from unittest.mock import MagicMock, patch from local_deep_research.constants import ( FILE_PATH_METADATA_ONLY, FILE_PATH_TEXT_ONLY, FILE_PATH_BLOB_DELETED, ) from local_deep_research.research_library.services.pdf_storage_manager import ( PDFStorageManager, ) class TestValidateFilesystemPath: """Test _get_safe_file_path blocks unsafe paths.""" def test_traversal_blocked(self, tmp_path): """Path with .. escaping library root returns None.""" manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path("../../etc/passwd") assert result is None def test_valid_relative_path(self, tmp_path): """Valid relative path inside library root returns resolved Path.""" sub = tmp_path / "docs" sub.mkdir(exist_ok=True) test_file = sub / "paper.pdf" test_file.write_text("pdf content") manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path("docs/paper.pdf") assert result is not None assert result.is_relative_to(tmp_path) def test_metadata_only_returns_none(self, tmp_path): manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path(FILE_PATH_METADATA_ONLY) assert result is None def test_text_only_returns_none(self, tmp_path): manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path(FILE_PATH_TEXT_ONLY) assert result is None def test_blob_deleted_returns_none(self, tmp_path): """blob_deleted sentinel should return None (not construct a path).""" manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path(FILE_PATH_BLOB_DELETED) assert result is None def test_empty_string_returns_none(self, tmp_path): manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path("") assert result is None def test_none_returns_none(self, tmp_path): manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) result = manager._get_safe_file_path(None) assert result is None def test_symlink_blocked(self, tmp_path): """Symlinks are blocked before resolve() dereferences them.""" manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) # Create a symlink pointing outside library root link_path = tmp_path / "evil_link" try: link_path.symlink_to("/etc/passwd") except OSError: pytest.skip("Cannot create symlinks in this environment") result = manager._get_safe_file_path("evil_link") assert result is None class TestLibraryRootResolved: """Verify library_root is resolved on init (prevents late traversal).""" def test_library_root_is_resolved(self, tmp_path): """library_root should be an absolute resolved path.""" manager = PDFStorageManager( library_root=str(tmp_path), storage_mode="filesystem" ) assert manager.library_root == tmp_path.resolve() assert manager.library_root.is_absolute() class TestGetAbsolutePathFromSettings: """Test path traversal protection in get_absolute_path_from_settings.""" @patch("local_deep_research.utilities.db_utils.get_settings_manager") def test_traversal_returns_none(self, mock_settings_mgr, tmp_path): from local_deep_research.research_library.utils import ( get_absolute_path_from_settings, ) mock_mgr = MagicMock() mock_mgr.get_setting.return_value = str(tmp_path) mock_settings_mgr.return_value = mock_mgr result = get_absolute_path_from_settings("../../etc/passwd") assert result is None @patch("local_deep_research.utilities.db_utils.get_settings_manager") def test_valid_path_returns_resolved(self, mock_settings_mgr, tmp_path): from local_deep_research.research_library.utils import ( get_absolute_path_from_settings, ) mock_mgr = MagicMock() mock_mgr.get_setting.return_value = str(tmp_path) mock_settings_mgr.return_value = mock_mgr result = get_absolute_path_from_settings("docs/file.pdf") assert result.is_relative_to(tmp_path.resolve()) @patch("local_deep_research.utilities.db_utils.get_settings_manager") def test_empty_path_returns_library_root(self, mock_settings_mgr, tmp_path): from local_deep_research.research_library.utils import ( get_absolute_path_from_settings, ) mock_mgr = MagicMock() mock_mgr.get_setting.return_value = str(tmp_path) mock_settings_mgr.return_value = mock_mgr result = get_absolute_path_from_settings("") assert result == tmp_path.resolve()