diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py index a63d05ba4e..b7811d4909 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundler/lib/yamlio.py @@ -54,10 +54,10 @@ def load_yaml(path: Path) -> Any: caller to reject. """ path = Path(path) - if not path.exists(): - raise BundlerError(f"File not found: {path}") try: text = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise BundlerError(f"File not found: {path}") from None except (OSError, UnicodeError) as exc: # A non-UTF-8 file raises UnicodeDecodeError, which is a ValueError -- # NOT an OSError -- so it escaped this module's "IO failures degrade diff --git a/tests/unit/test_bundler_yamlio.py b/tests/unit/test_bundler_yamlio.py index b3e8e592e4..0396f0a4fd 100644 --- a/tests/unit/test_bundler_yamlio.py +++ b/tests/unit/test_bundler_yamlio.py @@ -6,6 +6,7 @@ import pytest from specify_cli.bundler import BundlerError +import specify_cli.bundler.lib.yamlio as yamlio_module from specify_cli.bundler.lib.yamlio import dump_yaml, load_json, load_yaml @@ -49,6 +50,25 @@ def test_load_json_non_utf8_raises_bundler_error(tmp_path: Path): load_json(path) +def test_load_yaml_toctou_race(tmp_path: Path): + """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.""" + from unittest.mock import MagicMock, patch + + path = tmp_path / "gone.yml" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.read_text.side_effect = FileNotFoundError(str(path)) + + with patch.object(yamlio_module, "Path", return_value=mock_path): + with pytest.raises(BundlerError, match="File not found"): + load_yaml(path) + + def test_load_json_malformed_still_reports_invalid_json(tmp_path: Path): """Clause order regression guard: decodable-but-malformed JSON must keep the more specific 'Invalid JSON' message rather than the read-error one."""