from __future__ import annotations import hashlib import os import threading from collections.abc import Iterator from pathlib import Path import pytest from game_data_capture.storage.blobs import ( BlobCorruptionError, BlobLimitError, BlobLockError, ContentAddressedBlobStore, ) def test_blob_is_verified_deduplicated_and_private(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) payload = b"capture-bytes" digest, size = store.put([payload], max_bytes=100) assert digest == hashlib.sha256(payload).hexdigest() assert store.put([payload], max_bytes=100) == (digest, size) assert b"".join(store.read(digest, max_bytes=100)) == payload assert (store._path(digest).stat().st_mode & 0o777) == 0o600 def test_corruption_and_partial_files_are_detected_or_reconciled(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) digest, _ = store.put([b"good"], max_bytes=100) store._path(digest).write_bytes(b"bad") with pytest.raises(BlobCorruptionError): store.verify(digest) store.temporary.mkdir(parents=True, exist_ok=True) partial = store.temporary / ".crash.partial" partial.write_bytes(b"partial") assert store.reconcile() == [".crash.partial"] assert not partial.exists() def test_rejects_hash_and_generator_failures_without_partial_file(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) with pytest.raises(BlobCorruptionError): store.put([b"value"], max_bytes=100, expected_sha256="0" * 64) def broken() -> Iterator[bytes]: yield b"prefix" raise RuntimeError("source failed") with pytest.raises(RuntimeError): store.put(broken(), max_bytes=100) assert not list(store.temporary.glob("*.partial")) def test_root_symlinks_are_not_traversed(tmp_path: Path) -> None: outside = tmp_path / "outside" outside.mkdir() root = (tmp_path / "data").resolve() root.symlink_to(outside, target_is_directory=True) store = ContentAddressedBlobStore(root) with pytest.raises(BlobCorruptionError): store.put([b"x"], max_bytes=1) def test_limits_and_root_symlink_reconciliation_are_safe(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) for limit in (0, -1, True): with pytest.raises(BlobLimitError): store.put([b"x"], max_bytes=limit) digest, _ = store.put([b"x"], max_bytes=1) for limit in (0, -1, True): with pytest.raises(BlobLimitError): list(store.read(digest, max_bytes=limit)) outside = tmp_path / "outside" outside.mkdir() partial = outside / ".outside.partial" partial.write_bytes(b"outside") store.temporary.rmdir() store.temporary.symlink_to(outside, target_is_directory=True) assert store.reconcile() == [] assert partial.read_bytes() == b"outside" def test_corrupt_read_rejects_before_yielding(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) digest, _ = store.put([b"good"], max_bytes=10) store._path(digest).write_bytes(b"bad") with pytest.raises(BlobCorruptionError): list(store.read(digest, max_bytes=10)) def test_oversize_and_valid_digest_mismatch_leave_no_partial(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) with pytest.raises(BlobLimitError): store.put([b"too-large"], max_bytes=1) assert not list(store.temporary.glob("*.partial")) with pytest.raises(BlobCorruptionError): store.put([b"value"], max_bytes=100, expected_sha256="0" * 64) assert not list(store.temporary.glob("*.partial")) def test_blob_root_symlink_purge_does_not_inspect_outside(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) store.put([b"inside"], max_bytes=100) outside = tmp_path / "outside" outside.mkdir() outside_file = outside / "outside-blob" outside_file.write_bytes(b"outside") for entry in store.blobs.iterdir(): if entry.is_file(): entry.unlink() else: for child in entry.iterdir(): child.unlink() entry.rmdir() store.blobs.rmdir() store.blobs.symlink_to(outside, target_is_directory=True) assert store.purge_dry_run(set()) == [] assert outside_file.read_bytes() == b"outside" def test_purge_dry_run_does_not_follow_symlink(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) digest, _ = store.put([b"inside"], max_bytes=100) outside = tmp_path / "outside" outside.write_bytes(b"outside") unsafe = store.blobs / "ff" unsafe.symlink_to(tmp_path) assert store.purge_dry_run(set()) == [store._path(digest).relative_to(store.root)] assert outside.read_bytes() == b"outside" def test_operation_lock_rejects_symlink_and_unsafe_mode(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) store.root.mkdir(mode=0o700) outside = tmp_path / "outside" outside.write_bytes(b"x") store.lock_path.symlink_to(outside) with pytest.raises(BlobLockError), store.operation_lock(): pass store.lock_path.unlink() store.lock_path.write_bytes(b"") store.lock_path.chmod(0o644) with pytest.raises(BlobLockError), store.operation_lock(): pass def test_operation_lock_timeout_and_release_after_exception(tmp_path: Path) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) entered = threading.Event() outcome: list[type[Exception]] = [] def contender() -> None: entered.set() try: with store.operation_lock(timeout_seconds=0.02): pass except BlobLockError as error: outcome.append(type(error)) with store.operation_lock(): thread = threading.Thread(target=contender) thread.start() assert entered.wait(0.2) thread.join(0.5) assert not thread.is_alive() assert outcome == [BlobLockError] with pytest.raises(RuntimeError), store.operation_lock(): raise RuntimeError("fault") with store.operation_lock(timeout_seconds=0.02): pass for invalid in (True, "1", 0, 6): with ( pytest.raises(ValueError), store.operation_lock( timeout_seconds=invalid # type: ignore[arg-type] ), ): pass def test_expected_size_mismatch_rejects_before_blob_read( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: store = ContentAddressedBlobStore((tmp_path / "data").resolve()) digest, _ = store.put([b"value"], max_bytes=100) store._path(digest).write_bytes(b"x" * 4096) reads = 0 original = os.read def forbidden_read(fd: int, size: int) -> bytes: nonlocal reads reads += 1 return original(fd, size) monkeypatch.setattr(os, "read", forbidden_read) with pytest.raises(BlobCorruptionError): store.verify(digest, expected_size=5) assert reads == 0