Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/specify_cli/integrations/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,14 @@ def check_modified(self) -> list[str]:
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

Expand Down Expand Up @@ -358,9 +365,17 @@ def uninstall(
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:
Expand Down
37 changes: 37 additions & 0 deletions tests/integrations/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,40 @@ def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
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()
Loading