diff --git a/src/key_value/aio/stores/filetree/store.py b/src/key_value/aio/stores/filetree/store.py index 29c26db1..84c864a4 100644 --- a/src/key_value/aio/stores/filetree/store.py +++ b/src/key_value/aio/stores/filetree/store.py @@ -270,7 +270,12 @@ async def get_entry(self, *, key: str) -> ManagedEntry | None: if not await key_path.exists(): return None - data_dict: dict[str, Any] = await read_file(file=key_path) + try: + data_dict: dict[str, Any] = await read_file(file=key_path) + except FileNotFoundError: + # Another actor removed the entry between the exists() check and the read. + # Report a miss, the same as delete_entry does when the file is already gone. + return None return self.serialization_adapter.load_dict(data=data_dict) diff --git a/tests/stores/filetree/test_filetree.py b/tests/stores/filetree/test_filetree.py index 2b2b77e1..766aaa68 100644 --- a/tests/stores/filetree/test_filetree.py +++ b/tests/stores/filetree/test_filetree.py @@ -60,6 +60,26 @@ async def unlink_after_external_removal(path: AsyncPath) -> None: assert await store.delete(collection="test", key="race_key") is False assert await store.get(collection="test", key="race_key") is None + async def test_get_returns_none_when_file_disappears_after_exists_check( + self, + store: FileTreeStore, + monkeypatch: pytest.MonkeyPatch, + ): + """get should report a miss when another actor removes the file mid-read.""" + await store.put(collection="test", key="race_key", value={"data": "value"}) + + original_exists = AsyncPath.exists + + async def exists_then_external_removal(path: AsyncPath) -> bool: + result = await original_exists(path) + if Path(path).name == "race_key.json": + Path(path).unlink() + return result + + monkeypatch.setattr(AsyncPath, "exists", exists_then_external_removal) + + assert await store.get(collection="test", key="race_key") is None + class TestFileTreeStorePathTraversal: """Test suite for FileTreeStore path traversal security."""