fix(events): skip an unreadable command template (#3956)

* 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>

* 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>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marsel Safin
2026-08-07 12:16:09 -05:00
committed by GitHub
co-authored by Copilot
parent 920ed7546d
commit 5d9ac6a3f5
2 changed files with 56 additions and 1 deletions
+9 -1
View File
@@ -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
+47
View File
@@ -1263,6 +1263,53 @@ class TestCommandRunner:
)
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
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):