From 6fff6386246708f733673ac7573bb6d4bc334d97 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:34:16 +0200 Subject: [PATCH 1/2] fix(extensions): start fresh on a non-UTF-8 extension registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtensionRegistry._load() catches json.JSONDecodeError and FileNotFoundError to start fresh on a corrupted or missing registry, but a registry file with invalid UTF-8 bytes raised UnicodeDecodeError before JSON parsing began, crashing every extension command. Catch UnicodeDecodeError in the same clause: undecodable bytes are the same corruption class as unparseable JSON. OSError stays uncaught on purpose — the data may be intact on disk, and starting fresh would let a later _save() wipe it (same fail-closed reasoning as the workflow catalog cache loader). Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 8 ++++++-- tests/test_extensions.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6d78354809..a7c880425b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -641,8 +641,12 @@ def _load(self) -> dict: if not isinstance(data.get("extensions"), dict): data["extensions"] = {} return data - except (json.JSONDecodeError, FileNotFoundError): - # Corrupted or missing registry, start fresh + except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError): + # Corrupted or missing registry, start fresh. A registry whose + # bytes cannot be decoded as UTF-8 is the same corruption class + # as malformed JSON — only the exception type differs. OSError is + # deliberately not caught: the data may be intact on disk, and + # starting fresh would let a later _save() wipe it. return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} def _save(self): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3d9146d52b..5aba4a0f07 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1241,6 +1241,28 @@ def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): result = registry.list() assert result == {} + def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir): + """A registry file with undecodable bytes must start fresh, not raise. + + ``_load()`` documents "Corrupted or missing registry, start fresh" and + already treats malformed JSON that way, but a registry whose *bytes* + cannot be decoded as UTF-8 raised a raw ``UnicodeDecodeError`` from the + same boundary — the same corruption class reaching a different + exception type. + """ + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + (extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes( + b"\xff\xfe not utf-8 \xc3\x28" + ) + + registry = ExtensionRegistry(extensions_dir) + + assert registry.data == { + "schema_version": ExtensionRegistry.SCHEMA_VERSION, + "extensions": {}, + } + # ===== ExtensionManager Tests ===== From 565bb93eaed018bdf0a235970e53c2d83cf5ff36 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:13:15 +0200 Subject: [PATCH 2/2] docs: update workaround rationale now that _load handles UnicodeDecodeError Review follow-up: _sibling_extension_ids and test_non_utf8_registry_does_not_crash documented the pre-fix _load() contract; reword both as defense-in-depth so the rationale stays true. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 10 +++++----- tests/test_extensions.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a7c880425b..3a64c74092 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4259,11 +4259,11 @@ def _sibling_extension_ids(self) -> list[str]: Returns an empty list if the registry is missing or corrupted (fresh project, ad-hoc test harness) so ``_get_env_config`` degrades to its pre-fix behaviour rather than crashing. ``UnicodeError`` is - caught alongside ``OSError`` because ``ExtensionRegistry._load()`` - opens the file in text mode and only handles ``JSONDecodeError`` / - ``FileNotFoundError``, so a registry file with non-UTF-8 bytes would - otherwise surface a ``UnicodeDecodeError`` here and break *every* - config read instead of degrading gracefully. + caught alongside ``OSError`` as defense in depth: + ``ExtensionRegistry._load()`` now starts fresh on a non-UTF-8 + registry itself, but this scan must degrade gracefully even if that + contract regresses, because a failure here would break *every* + config read. Used by ``_get_env_config`` to detect env vars whose remainder claims a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5aba4a0f07..07cc3a6759 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10472,10 +10472,10 @@ def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch): """A registry file with invalid text encoding must NOT propagate ``UnicodeDecodeError`` out of the sibling scan and abort every - config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError`` - / ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must - additionally swallow ``UnicodeError`` and degrade to the documented - pre-fix behaviour. + config read. ``ExtensionRegistry._load()`` now starts fresh on a + non-UTF-8 registry itself, but ``_sibling_extension_ids`` keeps + swallowing ``UnicodeError`` as defense in depth so a regression of + that contract still degrades to the documented pre-fix behaviour. """ extensions_dir = tmp_path / ".specify" / "extensions" extensions_dir.mkdir(parents=True)