diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index ca39a2489b..4f8e84f186 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -143,15 +143,21 @@ def fetch(source: CatalogSource) -> dict: if scheme == "file": path = _file_url_to_path(parsed) - if not path.exists(): - raise BundlerError(f"Catalog file not found: {path}") - return load_json(path) + try: + return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) + except FileNotFoundError: + raise BundlerError(f"Catalog file not found: {path}") from None + except (OSError, UnicodeError) as exc: + raise BundlerError(f"Could not read {path}: {exc}") from exc if scheme == "" or _is_windows_drive_path(url): path = Path(url) - if not path.exists(): - raise BundlerError(f"Catalog file not found: {path}") - return load_json(path) + try: + return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) + except FileNotFoundError: + raise BundlerError(f"Catalog file not found: {path}") from None + except (OSError, UnicodeError) as exc: + raise BundlerError(f"Could not read {path}: {exc}") from exc if scheme in ("http", "https"): if not allow_network: diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index 854e60df3f..8727330068 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -1,6 +1,9 @@ """Unit tests for catalog-fetch adapters (auth + redirect safety).""" from __future__ import annotations +from pathlib import Path +from unittest.mock import MagicMock, patch + import pytest from specify_cli.bundler import BundlerError @@ -201,3 +204,25 @@ def test_validate_remote_url_rejects_malformed_url_cleanly(url): caller. Bundler sibling of #3369.""" with pytest.raises(BundlerError): adapters._validate_remote_url("team", url) + + +@pytest.mark.parametrize("use_file_url", [False, True], ids=["path", "file-url"]) +def test_local_catalog_toctou_race(tmp_path, use_file_url): + """Regression guard: a file that disappears between the old exists() pre-check + and read_text() must raise BundlerError, not a raw FileNotFoundError. + + The mocked Path is observable as present (exists() returns True) but + read_text() raises FileNotFoundError, simulating a deletion between the two + calls — the exact race window the exists() removal eliminates.""" + catalog_path = tmp_path / "catalog.json" + url = catalog_path.as_uri() if use_file_url else str(catalog_path) + + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.read_text.side_effect = FileNotFoundError(str(catalog_path)) + + fetcher = adapters.make_catalog_fetcher(allow_network=False) + + with patch.object(adapters.Path, "__new__", return_value=mock_path): + with pytest.raises(BundlerError, match="Catalog file not found"): + fetcher(_source(url))