From 224480153acb791e41912acf5653a6955bfb9f08 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:37:00 +0200 Subject: [PATCH 1/2] fix(presets): treat an unreadable core template as missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _substitute_core_template() read the resolved core template with a bare read_text(), so one corrupted project-owned override in .specify/templates/commands/ crashed the whole wrap-strategy command registration with a raw UnicodeDecodeError. Both callers (CommandRegistrar.register_pack and _register_commands) are unguarded here, even though register_pack already skips an unreadable preset source with a warning a few lines above the call. Treat an unreadable core template like a missing one — warn and return the body unchanged with empty frontmatter — matching the function's documented no-core contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 20 ++++++++++++++++-- tests/test_presets.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index cc5308f3fc..16a7e56c0a 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -178,7 +178,7 @@ def _substitute_core_template( by the core template body and core_frontmatter holds the core template's parsed frontmatter (so callers can inherit scripts/agent_scripts from it). Both are unchanged / empty when the placeholder is absent or the core template file does - not exist. + not exist or cannot be read. """ if "{CORE_TEMPLATE}" not in body: return body, {} @@ -208,7 +208,23 @@ def _substitute_core_template( if core_file is None: return body, {} - core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8")) + # Treat an unreadable/undecodable core template like a missing one so a + # single corrupted project override cannot crash command registration — + # the wrap-strategy callers already skip an unreadable preset source with + # a warning (CommandRegistrar.register_pack). + try: + core_content = core_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + import warnings + + warnings.warn( + f"Ignoring core template for command '{cmd_name}': could not read " + f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).", + stacklevel=2, + ) + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_content) return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter diff --git a/tests/test_presets.py b/tests/test_presets.py index 243d13ab55..7ab00e0cb1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -10718,6 +10718,38 @@ def test_substitute_core_template_no_op_when_core_missing(self, project_dir): assert "{CORE_TEMPLATE}" in result assert core_fm == {} + def test_substitute_core_template_unreadable_core_treated_as_missing( + self, project_dir + ): + """An undecodable core template must not crash substitution. + + The wrap-strategy callers (``CommandRegistrar.register_pack`` and + ``_register_commands``) skip an unreadable preset source with a + warning, but the core template read inside + ``_substitute_core_template`` had no boundary, so one corrupted + project-owned override in ``.specify/templates/commands/`` crashed + the whole registration with a raw ``UnicodeDecodeError``. An + unreadable core is treated like a missing one. + """ + import warnings + + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result, core_fm = _substitute_core_template( + body, "specify", project_dir, registrar + ) + assert result == body + assert core_fm == {} + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" from specify_cli.agents import CommandRegistrar From 95a6a9558b919ad08300dac187a7b627bbf8dc77 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:13:22 +0200 Subject: [PATCH 2/2] test: assert the unreadable-core warning instead of suppressing it Review follow-up: use pytest.warns so removing or changing the promised warning fails the test. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_presets.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index 7ab00e0cb1..d7bb29b604 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -10731,8 +10731,6 @@ def test_substitute_core_template_unreadable_core_treated_as_missing( the whole registration with a raw ``UnicodeDecodeError``. An unreadable core is treated like a missing one. """ - import warnings - from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar @@ -10742,8 +10740,7 @@ def test_substitute_core_template_unreadable_core_treated_as_missing( registrar = CommandRegistrar() body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" - with warnings.catch_warnings(): - warnings.simplefilter("ignore") + with pytest.warns(UserWarning, match="Ignoring core template"): result, core_fm = _substitute_core_template( body, "specify", project_dir, registrar )