Fix 8 current Copilot review findings on registry fail-closed, rollback orphans, backup-read boundaries, and Rich escaping

1. WorkflowRegistry._load(): a symlinked .specify/.specify/workflows
   parent (or a symlinked registry file) silently returned an empty
   registry instead of raising, unlike an unreadable-file read failure.
   A read-only caller (notably the bundler's remove path) querying
   is_installed() before ever writing could conclude an installed
   workflow is absent, skip removing it, then delete the bundle
   record -- leaving the workflow untracked but still on disk. Now
   raises OSError immediately, matching the existing unreadable-file
   fail-closed behavior.

2/8. _validate_and_install_local and _install_workflow_from_catalog:
   when the destination directory already existed but had no prior
   workflow.yml (e.g. a leftover empty dir), existed_before was True
   but there were no backup bytes to restore, so the rollback closure
   did nothing on a later failure -- leaving the newly copied/
   downloaded file behind. Both now unlink the newly created file in
   this case, restoring the pre-existing directory to its prior
   (empty) state.

3/4. Both install paths read the prior workflow.yml bytes (to seed
   the reinstall rollback) *before* any try/except boundary: a read
   failure on the existing file (e.g. a transient permission/FS
   issue) leaked a raw, unescaped OSError instead of the same clean
   CLI error used by every other failure branch in these functions.
   Both reads are now guarded by their own try/except OSError, with
   no writes attempted before the read succeeds (so there is nothing
   to roll back on this specific failure).

5. remove_bundle's exception-conversion message unconditionally
   claimed "No changes were recorded," even though a failure can
   occur after earlier components in the same bundle have already
   been removed from disk (save_records never runs on this path, so
   the record is left claiming the bundle fully installed). The
   message now reports how many components were already removed
   when that happened, instead of asserting no changes occurred.

6/7. workflow_remove's new post-registry-removal directory-failure
   error and its restore-failure warning interpolated workflow_dir
   and the exception values into Rich markup unescaped. A project
   path or OS error message containing Rich-markup-like brackets
   could be parsed as markup and hide/corrupt the displayed text.
   Both now use the existing _escape_markup helper, consistent with
   every other error path in this file.

Tests (tests/test_workflows.py unless noted):
- TestWorkflowRegistry::test_load_symlinked_workflows_dir_fails_closed_not_silently_empty (1)
- TestWorkflowCliAlignment::test_add_dev_fresh_install_into_preexisting_empty_dir_cleans_new_file (2)
- TestWorkflowCliAlignment::test_add_catalog_fresh_install_into_preexisting_empty_dir_cleans_new_file (8)
- TestWorkflowCliAlignment::test_add_dev_reinstall_backup_read_failure_gives_clean_error (3)
- TestWorkflowCliAlignment::test_add_catalog_reinstall_backup_read_failure_gives_clean_error (4)
- tests/integration/test_bundler_install_flow.py::test_remove_partial_failure_message_reflects_partial_state (5)
- TestWorkflowRemoveGuard::test_remove_directory_and_restore_failure_escapes_rich_markup (6/7)

All seven were confirmed red beforehand, matching each thread's
described failure mode exactly (silent empty registry instead of a
raise; orphaned new file left behind; raw unescaped OSError leaking;
a misleading "no changes were recorded" claim; Rich markup consuming
bracketed path/exception text). Also updated
test_registry_save_refuses_symlinked_parent, a pre-existing test that
asserted the symlinked-parent raise at add()/save() time -- it now
raises at construction instead, per fix #1, so the test was adjusted
to match without weakening its guarantee (still asserts no writes
occur under the symlinked target).

Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
marcelsafin
2026-07-11 10:41:53 +02:00
parent ce3e0abacb
commit 6b593e3dd0
5 changed files with 342 additions and 15 deletions

View File

@@ -4820,6 +4820,33 @@ class TestWorkflowRegistry:
with pytest.raises(OSError):
WorkflowRegistry(project_dir)
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_load_symlinked_workflows_dir_fails_closed_not_silently_empty(
self, project_dir
):
"""A symlinked .specify/workflows is the same fail-open hazard as a
read OSError: silently reporting an empty registry lets a read-only
caller (e.g. the bundler's remove path) conclude a workflow isn't
installed, skip removing it, and then delete the bundle record --
leaving the workflow untracked but still on disk. Raise here too,
exactly like the unreadable-file case, so callers cannot act on
fabricated empty state."""
from specify_cli.workflows.catalog import WorkflowRegistry
import json as _json
outside = project_dir.parent / "outside-workflows"
outside.mkdir(parents=True, exist_ok=True)
(outside / "workflow-registry.json").write_text(
_json.dumps({"schema_version": "1.0", "workflows": {"evil": {}}}),
encoding="utf-8",
)
workflows_link = project_dir / ".specify" / "workflows"
workflows_link.rmdir()
workflows_link.symlink_to(outside, target_is_directory=True)
with pytest.raises(OSError):
WorkflowRegistry(project_dir)
# ===== Workflow Catalog Tests =====
@@ -6001,6 +6028,61 @@ class TestWorkflowRemoveGuard:
assert restored == original_entry
assert WorkflowRegistry(project_dir).is_installed("test-wf")
def test_remove_directory_and_restore_failure_escapes_rich_markup(
self, temp_dir, monkeypatch
):
"""The project path (workflow_dir) and the rmtree/restore-save
exceptions interpolated into these new Rich error/warning messages
must be escaped like every other error path here -- unescaped Rich
markup characters (e.g. brackets) in a project directory name or an
OS/registry error message could otherwise be parsed as markup and
hide or corrupt the displayed text instead of showing it verbatim."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import WorkflowRegistry
project_dir = temp_dir / "weird[project]"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "workflows").mkdir()
registry = WorkflowRegistry(project_dir)
registry.add("test-wf", {"name": "Test", "version": "1.0.0"})
workflow_dir = project_dir / ".specify" / "workflows" / "test-wf"
workflow_dir.mkdir(parents=True, exist_ok=True)
(workflow_dir / "workflow.yml").write_text("keep-me", encoding="utf-8")
def rmtree_boom(*args, **kwargs):
raise OSError("[disk] permission denied")
real_save = WorkflowRegistry.save
call_count = {"n": 0}
def save_boom(self):
# The first save() call is registry.remove()'s own persist,
# which must succeed so we reach the rmtree failure below; only
# the second call (the post-rmtree-failure restore attempt)
# should fail, to exercise the restore-failure warning path.
call_count["n"] += 1
if call_count["n"] == 1:
return real_save(self)
raise Exception("[warn] save exploded")
monkeypatch.chdir(project_dir)
with pytest.MonkeyPatch.context() as mp:
mp.setattr("shutil.rmtree", rmtree_boom)
mp.setattr(WorkflowRegistry, "save", save_boom)
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
# Rich may soft-wrap the long path across lines; compare with
# whitespace collapsed so the wrap position doesn't affect the check.
output_compact = "".join(result.output.split())
assert "".join(str(workflow_dir).split()) in output_compact
assert "[disk]permissiondenied" in output_compact
assert "[warn]saveexploded" in output_compact
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
@@ -7758,6 +7840,9 @@ steps:
assert fresh.get("align-wf")["version"] == "1.0.0"
def test_registry_save_refuses_symlinked_parent(self, project_dir, tmp_path):
"""Construction now fails closed on a symlinked .specify just like
an unreadable registry file: a symlinked parent must never be
silently tolerated up to save() -- it must raise immediately."""
from specify_cli.workflows.catalog import WorkflowRegistry
outside = tmp_path / "outside-specify"
@@ -7766,9 +7851,8 @@ steps:
if specify_dir.exists():
shutil.rmtree(specify_dir)
specify_dir.symlink_to(outside)
registry = WorkflowRegistry(project_dir)
with pytest.raises(OSError, match="symlink"):
registry.add("align-wf", {"version": "1.0.0", "source": "catalog"})
WorkflowRegistry(project_dir)
assert not (outside / "workflows").exists()
def test_add_dev_dir_with_workflow_yml_directory_errors_cleanly(self, project_dir, monkeypatch):
@@ -7866,6 +7950,75 @@ steps:
assert installed_yaml.read_bytes() == original_bytes
assert WorkflowRegistry(project_dir).get("align-wf") == original_registry_entry
def test_add_dev_reinstall_backup_read_failure_gives_clean_error(
self, project_dir, monkeypatch
):
"""The prior-file backup read (used to restore on a later install
failure) ran before any guarded section: a read failure on the
existing workflow.yml (e.g. a transient permission/FS issue) leaked
a raw OSError instead of the clean escaped CLI error used by every
other failure branch here, and left the destination untouched since
it happens before any write."""
from typer.testing import CliRunner
from specify_cli import app
monkeypatch.chdir(project_dir)
runner = CliRunner()
src = self._install_dev(runner, app, project_dir)
installed_yaml = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml"
original_bytes = installed_yaml.read_bytes()
(src / "workflow.yml").write_text(
self.WORKFLOW_YAML.format(version="2.0.0"), encoding="utf-8"
)
real_read_bytes = Path.read_bytes
def boom(self_path, *args, **kwargs):
if self_path.resolve() == installed_yaml.resolve():
raise OSError("permission denied")
return real_read_bytes(self_path, *args, **kwargs)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(Path, "read_bytes", boom)
result = runner.invoke(app, ["workflow", "add", str(src), "--dev"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.strip() != ""
assert installed_yaml.read_bytes() == original_bytes
def test_add_dev_fresh_install_into_preexisting_empty_dir_cleans_new_file(
self, project_dir, monkeypatch
):
"""When the destination directory already exists but has no
workflow.yml (e.g. an empty dir left over from elsewhere), a later
registry.add() failure must remove the newly copied file -- the
rollback previously did nothing in this case (existed_before=True
with no backup bytes), leaving the new file behind -- while leaving
the pre-existing directory itself intact."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import WorkflowRegistry
monkeypatch.chdir(project_dir)
runner = CliRunner()
src = self._write_workflow_dir(project_dir)
dest_dir = project_dir / ".specify" / "workflows" / "align-wf"
dest_dir.mkdir(parents=True) # pre-existing, but empty: no workflow.yml
def boom(self, *args, **kwargs):
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(WorkflowRegistry, "add", boom)
result = runner.invoke(app, ["workflow", "add", str(src), "--dev"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert dest_dir.is_dir()
assert not (dest_dir / "workflow.yml").exists()
def test_add_catalog_save_failure_leaves_no_orphan_directory(self, project_dir, monkeypatch):
"""Same guarantee as the local-install paths, but for a fresh catalog
install: a registry.add() failure must clean up the freshly-downloaded
@@ -7973,6 +8126,108 @@ steps:
assert registry.is_installed("align-wf")
assert registry.get("align-wf")["version"] == "1.0.0"
def test_add_catalog_reinstall_backup_read_failure_gives_clean_error(
self, project_dir, monkeypatch
):
"""Same backup-read boundary gap as the local-install path: the
prior-file read used to seed the reinstall's rollback ran before
the download/validation error boundary, so a read failure on the
existing workflow.yml (e.g. a transient permission/FS issue) leaked
a raw OSError instead of a clean escaped CLI error, and must be
caught before any download/write is attempted."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import WorkflowCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(
WorkflowCatalog,
"get_workflow_info",
lambda self, wid: {
"id": wid,
"name": "Align Workflow",
"version": "1.0.0",
"url": "https://example.com/workflow.yml",
"_install_allowed": True,
"_catalog_name": "test-catalog",
},
)
original_data = self.WORKFLOW_YAML.format(version="1.0.0").encode()
runner = CliRunner()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(
original_data, url
),
)
result = runner.invoke(app, ["workflow", "add", "align-wf"])
assert result.exit_code == 0, result.output
dest_file = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml"
real_read_bytes = Path.read_bytes
def boom(self_path, *args, **kwargs):
if self_path.resolve() == dest_file.resolve():
raise OSError("permission denied")
return real_read_bytes(self_path, *args, **kwargs)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(Path, "read_bytes", boom)
result = runner.invoke(app, ["workflow", "add", "align-wf"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.strip() != ""
assert dest_file.read_bytes() == original_data
def test_add_catalog_fresh_install_into_preexisting_empty_dir_cleans_new_file(
self, project_dir, monkeypatch
):
"""Same rollback orphan gap as the local-install path, but for a
fresh catalog install: a pre-existing empty destination directory
(no workflow.yml) sets existed_before=True with no backup bytes, so
the rollback previously did nothing on a later failure -- leaving
the freshly downloaded workflow.yml behind. It must be removed,
leaving the pre-existing directory itself intact."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry
monkeypatch.chdir(project_dir)
monkeypatch.setattr(
WorkflowCatalog,
"get_workflow_info",
lambda self, wid: {
"id": wid,
"name": "Align Workflow",
"version": "1.0.0",
"url": "https://example.com/workflow.yml",
"_install_allowed": True,
"_catalog_name": "test-catalog",
},
)
dest_dir = project_dir / ".specify" / "workflows" / "align-wf"
dest_dir.mkdir(parents=True) # pre-existing, but empty: no workflow.yml
data = self.WORKFLOW_YAML.format(version="1.0.0").encode()
def boom(self):
raise OSError("disk full")
runner = CliRunner()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(data, url),
)
mp.setattr(WorkflowRegistry, "save", boom)
result = runner.invoke(app, ["workflow", "add", "align-wf"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert dest_dir.is_dir()
assert not (dest_dir / "workflow.yml").exists()
@pytest.mark.parametrize(
"mode", ["redirect_rejected", "download_exception", "invalid_yaml", "id_mismatch"]
)