cultcache-py: only a missing store reads as empty; an empty file is refused
`Path.exists()` returns False for ELOOP and ENOTDIR, so a symlink loop or a file where the parent directory should be read as an empty store. The read now catches FileNotFoundError only. An existing zero-byte file is refused with ValueError, like any other undecodable store, matching the Rust and C# runtimes. `[0x90]` stays an empty legacy store. Refusal text no longer points at a variant-aware runtime: an unsupported header names the format found, and a v1 record with a fifth slot says the file is not a valid store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
271801106b
commit
202c4bbd63
2 changed files with 47 additions and 8 deletions
|
|
@ -88,10 +88,11 @@ class SingleFileMessagePackBackingStore:
|
|||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
msgpack = self._msgpack()
|
||||
with self._lock:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
data = self.path.read_bytes()
|
||||
if not data:
|
||||
# Only a store that is not there is empty. An empty file is not a store (no
|
||||
# writer leaves one), and any other failure to reach the file stays an OSError.
|
||||
try:
|
||||
data = self.path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
decoded = msgpack.unpackb(data, raw=False)
|
||||
snapshot = _decode_v1_snapshot(decoded)
|
||||
|
|
@ -159,8 +160,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
|||
return None
|
||||
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
|
||||
raise ValueError(
|
||||
f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. "
|
||||
"The store needs a runtime that resolves document variants."
|
||||
f"CultCache store format {decoded[0]!r} is not one this runtime reads; it reads {STORE_FORMAT_VERSION!r} only"
|
||||
)
|
||||
if decoded[0] != STORE_FORMAT_VERSION:
|
||||
return None
|
||||
|
|
@ -180,8 +180,8 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
|||
raise ValueError("CultCache persisted records must be MessagePack arrays")
|
||||
if len(raw_record) > _PERSISTED_RECORD_SLOTS:
|
||||
raise ValueError(
|
||||
f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; "
|
||||
f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants."
|
||||
f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots, more than "
|
||||
f"the {_PERSISTED_RECORD_SLOTS} of a {STORE_FORMAT_VERSION} record, so this is not a valid store"
|
||||
)
|
||||
key, schema_id, stored_at, payload = raw_record
|
||||
if not isinstance(key, str) or not key:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
|
@ -841,6 +842,44 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
|
||||
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
|
||||
|
||||
def test_single_file_refusals_say_what_was_found_without_blaming_variants(self) -> None:
|
||||
with self.assertRaises(ValueError) as unsupported:
|
||||
self._pull_vector("unknown-header.msgpack")
|
||||
self.assertIn("not one this runtime reads", str(unsupported.exception))
|
||||
self.assertNotIn("variant", str(unsupported.exception))
|
||||
with self.assertRaises(ValueError) as invalid:
|
||||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||||
self.assertIn("not a valid store", str(invalid.exception))
|
||||
self.assertNotIn("variant", str(invalid.exception))
|
||||
|
||||
def test_single_file_only_a_missing_store_reads_as_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(SingleFileMessagePackBackingStore(Path(tmp) / "store.cc").pull_all(), [])
|
||||
|
||||
# An existing empty file is not a store: no CultCache writer leaves one.
|
||||
empty = Path(tmp) / "empty.cc"
|
||||
empty.write_bytes(b"")
|
||||
with self.assertRaises(ValueError):
|
||||
SingleFileMessagePackBackingStore(empty).pull_all()
|
||||
self.assertEqual(empty.read_bytes(), b"")
|
||||
|
||||
# An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
|
||||
legacy_empty = Path(tmp) / "legacy-empty.cc"
|
||||
legacy_empty.write_bytes(b"\x90")
|
||||
self.assertEqual(SingleFileMessagePackBackingStore(legacy_empty).pull_all(), [])
|
||||
|
||||
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
|
||||
def test_single_file_store_that_cannot_be_reached_is_an_os_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
loop = Path(tmp) / "loop.cc"
|
||||
loop.symlink_to(loop)
|
||||
parent = Path(tmp) / "body"
|
||||
parent.write_bytes(b"not a directory")
|
||||
for path in (loop, parent / "store.cc"):
|
||||
with self.assertRaises(OSError) as caught:
|
||||
SingleFileMessagePackBackingStore(path).pull_all()
|
||||
self.assertNotIsInstance(caught.exception, FileNotFoundError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue