diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6d78354809..214a08ef7f 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2093,8 +2093,19 @@ def _matches_source_config_baseline(config_name: str) -> bool: _staged_modes = _loaded_modes for staged_name in sorted(staged_names): staged_file = rescue_staging_dir / staged_name - staged_stat = staged_file.stat() - staged_bytes = staged_file.read_bytes() + # A staged backup that cannot be read or stat'ed must not + # crash the retry with a raw OSError: like an uncomparable + # live config below, treat it as a conflict so both copies + # are preserved and the user resolves it while dest_dir is + # still untouched. Every sibling read in this path (live + # twin, packaged baseline, mode sidecar) already catches + # OSError. + try: + staged_stat = staged_file.stat() + staged_bytes = staged_file.read_bytes() + except OSError: + conflicting.add(staged_name) + continue # Prefer the sidecar-recorded mode; fall back to the staged # file's own mode for backwards-compat with staging dirs # written before the sidecar was introduced. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3d9146d52b..2a93b10b52 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2091,6 +2091,87 @@ def flaky_copytree(*args, **kwargs): assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes assert not manager.registry.is_installed("test-ext") + def test_retry_with_unreadable_staged_config_aborts_and_preserves_both( + self, extension_dir, project_dir, monkeypatch + ): + """An unreadable staged backup must abort the retry, not crash it. + + Every sibling read in the retry path (the live twin, the packaged + baseline check, the mode sidecar) already catches ``OSError``, but the + staged file's own ``stat()``/``read_bytes()`` had no boundary, so a + staged config that cannot be read crashed the reinstall with a raw + ``OSError`` instead of the conflict guidance. It must be treated like + an uncomparable live config: preserve both copies and abort while + dest_dir is untouched. + """ + manager = ExtensionManager(project_dir) + + packaged_config = extension_dir / "test-ext-config.yml" + packaged_config.write_text("model: default-model\n") + + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("model: custom-model\nmax_iterations: 99\n") + live_bytes = config_file.read_bytes() + + manager.remove("test-ext", keep_config=True) + assert not manager.registry.is_installed("test-ext") + + staging_dir = manager._rescue_staging_dir("test-ext") + + original_copytree = shutil.copytree + copytree_calls = 0 + + def flaky_copytree(*args, **kwargs): + nonlocal copytree_calls + copytree_calls += 1 + if copytree_calls == 1: + dst = args[1] + Path(dst).mkdir(parents=True, exist_ok=True) + (Path(dst) / "_partial.txt").write_text("partial") + raise OSError("simulated disk full") + return original_copytree(*args, **kwargs) + + monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree) + + with pytest.raises(OSError, match="simulated disk full"): + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + assert staging_dir.exists() + assert (staging_dir / ".rescue-complete").exists() + staged_file = staging_dir / "test-ext-config.yml" + assert staged_file.is_file() + + # Simulate a staged backup that can no longer be read (e.g. a + # permission or I/O error) without touching real permissions so the + # test also runs on platforms where chmod is a no-op. + original_read_bytes = Path.read_bytes + + def failing_read_bytes(self_path, *args, **kwargs): + if self_path == staged_file: + raise PermissionError(13, "Permission denied") + return original_read_bytes(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_bytes", failing_read_bytes) + + with pytest.raises(ValidationError, match="Preserved extension config conflict"): + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Both copies must survive: the live config and the staged backup. + monkeypatch.undo() + assert config_file.read_bytes() == live_bytes + assert staging_dir.exists() + assert staged_file.is_file() + assert not manager.registry.is_installed("test-ext") + @pytest.mark.parametrize( "failure_mode", [