fix(presets): start fresh on a non-UTF-8 preset registry (#3955)

PresetRegistry._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 preset 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>
This commit is contained in:
Marsel Safin
2026-08-04 09:07:13 -05:00
committed by GitHub
co-authored by Copilot
parent 0824a09d0f
commit 6e7818f837
2 changed files with 27 additions and 1 deletions
+6 -1
View File
@@ -550,7 +550,12 @@ class PresetRegistry:
if not isinstance(data.get("presets"), dict):
data["presets"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError):
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,
"presets": {}
+21
View File
@@ -515,6 +515,27 @@ class TestPresetRegistry:
assert registry.list() == {}
assert not registry.is_installed("test-pack")
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 same boundary — the same
corruption class reaching a different exception type.
"""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
(packs_dir / PresetRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe not utf-8 \xc3\x28"
)
registry = PresetRegistry(packs_dir)
assert registry.data == {
"schema_version": PresetRegistry.SCHEMA_VERSION,
"presets": {},
}
def test_add_and_get(self, temp_dir):
"""Test adding and retrieving a pack."""
packs_dir = temp_dir / "packs"