fix(memory): parse a UTF-8 BOM in the DeerMem summary instead of raising corruption (#7083) (#7084)

This commit is contained in:
Jay0130-a 2026-10-19 14:44:15 +08:00 committed by GitHub
parent afc96c667a
commit 0a1c86a371
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 49 additions and 2 deletions

View File

@ -53,7 +53,9 @@ class MarkdownMemoryStorage(FileMemoryStorage):
if not path.exists():
return None
try:
raw = path.read_text(encoding="utf-8")
# utf-8-sig, matching the base loader: a BOM does not make the
# summary unreadable, so it must not trigger the quarantine path.
raw = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as exc:
logger.warning("Cannot read memory summary %s: %s", path, exc)
return None

View File

@ -1143,7 +1143,11 @@ class FileMemoryStorage(MemoryStorage):
if not path.exists():
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
# utf-8-sig: a summary hand-edited on Windows may carry a BOM. The
# payload is still valid JSON, so raising MemoryStorageCorruption
# here would brick the backend (and the opt-in Markdown loader
# would quarantine recoverable state) over an encoding artifact.
value = json.loads(path.read_text(encoding="utf-8-sig"))
except (json.JSONDecodeError, OSError, UnicodeError) as exc:
raise MemoryStorageCorruption(f"Failed to load global memory JSON {path}: {exc}") from exc
if not isinstance(value, dict):

View File

@ -117,6 +117,31 @@ class TestFileMemoryStorage:
assert isinstance(memory, dict)
assert memory["version"] == "1.0"
def test_summary_saved_with_utf8_bom_loads(self, tmp_path, monkeypatch):
"""A summary hand-edited into "UTF-8 with BOM" (Windows Notepad /
PowerShell ``Set-Content -Encoding UTF8``) is valid JSON, not corruption:
the BOM is consumed instead of raising MemoryStorageCorruption."""
monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path))
memory_file = tmp_path / "memory.json"
manifest = create_empty_memory()
manifest["revision"] = 7
manifest["user"]["workContext"] = {"summary": "edited in Notepad"}
memory_file.write_bytes(b"\xef\xbb\xbf" + json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
assert memory_file.read_bytes().startswith(b"\xef\xbb\xbf"), "fixture must carry a real BOM"
storage = FileMemoryStorage(DeerMemConfig())
loaded = storage.load()
assert loaded["user"]["workContext"]["summary"] == "edited in Notepad"
def test_summary_without_bom_is_unaffected(self, tmp_path, monkeypatch):
"""The BOM-tolerant decoder must not alter plain UTF-8 summaries."""
monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path))
memory_file = tmp_path / "memory.json"
manifest = create_empty_memory()
manifest["user"]["workContext"] = {"summary": "plain"}
memory_file.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
loaded = FileMemoryStorage(DeerMemConfig()).load()
assert loaded["user"]["workContext"]["summary"] == "plain"
def test_save_writes_to_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path))
memory_file = tmp_path / "memory.json"
@ -323,6 +348,22 @@ class TestMarkdownMemoryStorage:
assert loaded["revision"] == expected_revision
assert loaded["user"]["workContext"]["summary"] == "recovered"
def test_bom_prefixed_valid_json_is_not_quarantined(self, tmp_path):
"""A BOM does not make the summary unreadable, so the tolerant loader
must parse it instead of quarantining recoverable state."""
memory_file = tmp_path / "memory.json"
manifest = create_empty_memory()
manifest["version"] = "2.0"
manifest.pop("facts")
manifest["revision"] = 7
manifest["user"]["workContext"] = {"summary": "kept"}
memory_file.write_bytes(b"\xef\xbb\xbf" + json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
storage = self._markdown_storage_at(memory_file)
loaded = storage.load()
assert loaded["revision"] == 7
assert loaded["user"]["workContext"]["summary"] == "kept"
assert not list(tmp_path.glob("memory.json.corrupt-*")), "a readable summary must not be quarantined"
def test_hand_edited_markdown_without_fence_is_quarantined(self, tmp_path):
"""Markdown without a fenced block cannot be mapped onto the manifest
schema losslessly: load() must not crash AND must not return an