From bb836bdaf5a5452d84a5c6170db4fffe36dc4a31 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:35:53 +0200 Subject: [PATCH 1/2] fix(events): skip an unreadable command template _render_command_template() read the resolved template with a bare read_text(), so a template file that exists but cannot be read or decoded (permission error, non-UTF-8 bytes) crashed event dispatch with a raw OSError/UnicodeDecodeError. Every sibling failure in this path (missing template, unresolvable command) already returns None so the dispatcher falls back cleanly. Wrap the read and return None on OSError/UnicodeDecodeError, matching the sibling contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/events.py | 10 +++++++++- tests/integrations/test_events.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 96405a7391..3469115d6e 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -548,7 +548,15 @@ def _resolve_event_command_argv( """ from .integrations.base import IntegrationBase - content = template_path.read_text(encoding="utf-8") + try: + content = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + # An unreadable or undecodable template cannot declare a runnable + # script. Degrade to "no argv" like every other failure in this + # resolver (missing frontmatter, malformed YAML, absent scripts) + # instead of leaking a raw traceback through + # resolve_and_run_event_command. + return None m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if not m: return None diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 556e05caef..551b2fcf58 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1262,6 +1262,28 @@ def test_unparseable_script_command_returns_none(self, tmp_path): encoding="utf-8", ) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + + assert argv is None + + def test_unreadable_template_returns_none(self, tmp_path): + """A command template that cannot be read must resolve to no argv. + + Every other failure inside ``_resolve_event_command_argv`` — missing + frontmatter, malformed YAML, absent scripts — degrades to ``None`` so + the dispatcher treats the command as declaring no runnable script. + The initial ``read_text`` was the one step outside that boundary: a + non-UTF-8 template raised a raw ``UnicodeDecodeError`` through + ``resolve_and_run_event_command`` and out of ``specify event run``. + """ + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_bytes( + b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n" + ) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is None From 72c06d8f757d79e091041f09d74a404c6fcc8de4 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:13:17 +0200 Subject: [PATCH 2/2] test: cover the OSError half of the unreadable-template boundary Review follow-up: add a mocked PermissionError case so both promised exception paths are protected under privileged CI. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/integrations/test_events.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 551b2fcf58..5dfc497b95 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1287,6 +1287,31 @@ def test_unreadable_template_returns_none(self, tmp_path): argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is None + def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch): + """The same boundary must cover ``OSError`` (e.g. permission denied). + + Mocked rather than chmod-based so the case also holds under + privileged CI where permission bits are not enforced. + """ + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + template = cmd_dir / "boot.md" + template.write_text("---\ndescription: Boot\n---\nBody\n") + + original_read_text = Path.read_text + + def failing_read_text(self_path, *args, **kwargs): + if self_path == template: + raise PermissionError(13, "Permission denied") + return original_read_text(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + argv = _resolve_event_command_argv(template, tmp_path, None) + assert argv is None + def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): """S6: the ps variant prefixes argv with pwsh/powershell -File so subprocess.run(shell=False) can execute the .ps1 script."""