From 4cd709f420c2447a02bd9b7db197af37028292dc Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Wed, 5 Aug 2026 21:32:04 +0500 Subject: [PATCH] fix(extensions): start fresh on a non-UTF-8 extension registry 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 from the text-mode read before JSON parsing began. Because the registry is loaded in __init__, that bare traceback broke every extension command -- `specify extension list` on such a project exits with a raw UnicodeDecodeError instead of the module's clean path. Catch UnicodeDecodeError in the same clause: undecodable bytes are the same corruption class as unparseable JSON, only the exception type differs. OSError stays uncaught on purpose -- the data may be intact on disk, and starting fresh would let a later _save() wipe it. This is the exact twin of the PresetRegistry._load() fix in #3955; the two registries are parallel implementations and only the preset side was corrected. _get_installed_sibling_ids() already worked around this gap locally by catching UnicodeError at its own call site; its comment is updated to reflect that _load() now handles the case itself, with the local catch kept as belt-and-braces against regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions/__init__.py | 18 +++++++++++------- tests/test_extensions.py | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2e985a0878..9fa44d3809 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -660,8 +660,13 @@ 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, and it is + # raised by the text-mode read before JSON parsing begins. 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): @@ -4310,11 +4315,10 @@ 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. + kept alongside ``OSError`` as belt-and-braces: ``_load()`` now starts + fresh on non-UTF-8 registry bytes itself, but catching it here too + keeps this call site degrading gracefully rather than breaking *every* + config read if that handling ever regresses. 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 9442f0bfbe..d668019087 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1291,6 +1291,31 @@ 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()`` already treats malformed JSON as "corrupted registry, + start fresh", but a registry whose *bytes* cannot be decoded as UTF-8 + raised a raw ``UnicodeDecodeError`` from the text-mode read before + JSON parsing began — the same corruption class reaching a different + exception type. Because the registry is loaded in ``__init__``, that + traceback broke *every* extension command on the project. + """ + 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": {}, + } + assert registry.list() == {} + assert not registry.is_installed("test-ext") + # ===== ExtensionManager Tests =====