diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index ca39a2489b..4bd741b4a9 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -18,7 +18,7 @@ from ..._assets import _locate_core_pack, _repo_root from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited from .. import BundlerError -from ..lib.yamlio import load_json, loads_json +from ..lib.yamlio import loads_json from ..models.catalog import CatalogSource from ..models.manifest import ComponentRef @@ -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..32f411af90 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,24 @@ 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): + """A missing file at read time must retain the fetcher's BundlerError contract. + + The mocked Path raises FileNotFoundError from read_text(), simulating a + deletion immediately before the catalog is opened while verifying that no + existence pre-check is needed.""" + 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))