diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index 58719e146..8c98243c9 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -309,7 +309,14 @@ class IntegrationManifest: if abs_path.is_symlink() or not abs_path.is_file(): modified.append(rel) continue - if _sha256(abs_path) != expected_hash: + try: + changed = _sha256(abs_path) != expected_hash + except OSError: + # Unreadable regular file (e.g. permission denied): treat as + # modified, consistent with the symlink / non-regular-file + # handling above, rather than letting the OSError escape. + changed = True + if changed: modified.append(rel) return modified @@ -358,9 +365,17 @@ class IntegrationManifest: skipped.append(path) continue else: - if not force and _sha256(path) != expected_hash: - skipped.append(path) - continue + if not force: + try: + matches = _sha256(path) == expected_hash + except OSError: + # Unreadable: can't verify it's ours, so preserve it + # (mirrors the path.unlink() OSError guard below). + skipped.append(path) + continue + if not matches: + skipped.append(path) + continue try: path.unlink() except OSError: diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index 32ff6efbd..06c1fd398 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -481,3 +481,40 @@ class TestRecordExistingNewGuards: m = IntegrationManifest("test", tmp_path) with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"): m.record_existing("dir/../file.txt") + + +class TestManifestUnreadableFile: + """A managed file that is unreadable (e.g. PermissionError) must not crash + check_modified()/uninstall() — the CLI handlers surfaced a raw traceback.""" + + def _mk(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("sub/f.md", "content") + return m + + def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch): + m = self._mk(tmp_path) + + def raise_perm(_path): + raise PermissionError("unreadable") + + monkeypatch.setattr( + "specify_cli.integrations.manifest._sha256", raise_perm + ) + # Before the fix this raised PermissionError. + assert m.check_modified() == ["sub/f.md"] + + def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch): + m = self._mk(tmp_path) + + def raise_perm(_path): + raise PermissionError("unreadable") + + monkeypatch.setattr( + "specify_cli.integrations.manifest._sha256", raise_perm + ) + removed, skipped = m.uninstall(force=False) + # Can't verify ownership => preserve, don't crash and don't delete. + assert removed == [] + assert (tmp_path / "sub" / "f.md") in skipped + assert (tmp_path / "sub" / "f.md").exists()