From 776093698e1f9494994a5ccecd5cd2bbd185301c Mon Sep 17 00:00:00 2001 From: PhaeezAli Date: Sun, 18 Oct 2026 00:34:21 +0530 Subject: [PATCH] fix(wal): reject entries with non-string key or value WalEntry.from_dict only checked that key and value were present, not that they were strings. A hand-edited or corrupted log line such as {"op": "set", "key": null, "value": "d"} was replayed into the in-memory dict as a non-string key, which the rest of the engine never expects. Raise TypeError for a non-string key or value so replay() treats the line as corrupt, warns, and truncates the log to the last good entry, the same as it already does for other malformed records. Closes #322 --- src/seriousdb/wal.py | 20 +++++++++++++++++--- tests/test_wal.py | 3 +++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/seriousdb/wal.py b/src/seriousdb/wal.py index 3acd9df..27a8e94 100644 --- a/src/seriousdb/wal.py +++ b/src/seriousdb/wal.py @@ -31,7 +31,7 @@ def from_dict(data: dict) -> "WalEntry": Raises ------ TypeError - If `data` is not a dictionary. + If `data` is not a dictionary, or if key or value is not a string. ValueError If `data` does not describe a known entry type. """ @@ -41,10 +41,24 @@ def from_dict(data: dict) -> "WalEntry": op = data.get("op") try: if op == "set": - return SetEntry(key=data["key"], value=data["value"]) + key = data["key"] + value = data["value"] + + if not isinstance(key, str): + raise TypeError("key must be a string") + if not isinstance(value, str): + raise TypeError("value must be a string") + + return SetEntry(key=key, value=value) if op == "delete": - return DeleteEntry(key=data["key"]) + key = data["key"] + + if not isinstance(key, str): + raise TypeError("key must be a string") + + return DeleteEntry(key=key) + except KeyError as e: raise ValueError(f"missing required field: {e}") from e diff --git a/tests/test_wal.py b/tests/test_wal.py index 2cbbf10..7c8a680 100644 --- a/tests/test_wal.py +++ b/tests/test_wal.py @@ -69,6 +69,9 @@ def test_replay_drops_and_truncates_a_torn_last_entry(wal, wal_path): [ b'{"op": "set", "key": "b"}\n', # Missing required field b"[]\n", # Unexpected top-level type + b'{"op": "set", "key": null, "value": "b"}\n', # Key is null + b'{"op": "set", "key": 5, "value": "b"}\n', # Key is not a string + b'{"op": "set", "key": "b", "value": [1, 2]}\n', # Value is not a string ], ) def test_replay_recovers_from_malformed_entry(wal, wal_path, malformed):