Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====

Expand Down