from __future__ import annotations import hashlib import stat import uuid from collections.abc import AsyncIterator from pathlib import Path import pytest from app.adapters.local_storage import ( LocalStorageError, LocalUploadStorage, StorageErrorCode, ) async def _chunks(*values: bytes) -> AsyncIterator[bytes]: for value in values: yield value @pytest.mark.asyncio async def test_stream_is_hash_checked_fsynced_read_only_and_idempotent(tmp_path: Path) -> None: root = tmp_path / "uploads" storage = LocalUploadStorage(root, max_bytes=1024) key = uuid.uuid4() content = b"synthetic geological document" digest = hashlib.sha256(content).hexdigest() first = await storage.store( storage_key=key, chunks=_chunks(content[:10], b"", content[10:]), expected_size=len(content), expected_sha256=digest, ) second = await storage.store( storage_key=key, chunks=_chunks(content), expected_size=len(content), expected_sha256=digest, ) assert first == second files = [path for path in root.rglob("*") if path.is_file()] assert len(files) == 1 assert files[0].name == key.hex assert files[0].read_bytes() == content assert stat.S_IMODE(files[0].stat().st_mode) == 0o440 assert not list(root.rglob("*.upload")) assert ( await storage.read_verified( storage_key=key, expected_size=len(content), expected_sha256=digest, ) == content ) @pytest.mark.asyncio @pytest.mark.parametrize( ("content", "expected_size", "expected_sha", "code"), [ (b"too long", 3, hashlib.sha256(b"too").hexdigest(), StorageErrorCode.TOO_LARGE), (b"short", 6, hashlib.sha256(b"short!").hexdigest(), StorageErrorCode.SIZE_MISMATCH), (b"same-size", 9, "0" * 64, StorageErrorCode.HASH_MISMATCH), ], ) async def test_failed_streams_remove_temporary_files( tmp_path: Path, content: bytes, expected_size: int, expected_sha: str, code: StorageErrorCode, ) -> None: root = tmp_path / "uploads" storage = LocalUploadStorage(root, max_bytes=1024) with pytest.raises(LocalStorageError) as captured: await storage.store( storage_key=uuid.uuid4(), chunks=_chunks(content), expected_size=expected_size, expected_sha256=expected_sha, ) assert captured.value.code is code assert not [path for path in root.rglob("*") if path.is_file()] @pytest.mark.asyncio async def test_existing_different_object_fails_without_overwrite(tmp_path: Path) -> None: root = tmp_path / "uploads" storage = LocalUploadStorage(root, max_bytes=1024) key = uuid.uuid4() original = b"first" await storage.store( storage_key=key, chunks=_chunks(original), expected_size=len(original), expected_sha256=hashlib.sha256(original).hexdigest(), ) with pytest.raises(LocalStorageError) as captured: await storage.store( storage_key=key, chunks=_chunks(b"other-content"), expected_size=len(b"other-content"), expected_sha256=hashlib.sha256(b"other-content").hexdigest(), ) assert captured.value.code is StorageErrorCode.OBJECT_CONFLICT only_file = next(path for path in root.rglob("*") if path.is_file()) assert only_file.read_bytes() == original with pytest.raises(LocalStorageError) as read_error: await storage.read_verified( storage_key=key, expected_size=len(original), expected_sha256="0" * 64, ) assert read_error.value.code is StorageErrorCode.OBJECT_CONFLICT @pytest.mark.asyncio async def test_symlink_root_is_rejected_and_error_never_contains_path(tmp_path: Path) -> None: real = tmp_path / "real" real.mkdir() root = tmp_path / ("sk-" + "A" * 24) root.symlink_to(real, target_is_directory=True) storage = LocalUploadStorage(root, max_bytes=1024) with pytest.raises(LocalStorageError) as captured: await storage.store( storage_key=uuid.uuid4(), chunks=_chunks(b"x"), expected_size=1, expected_sha256=hashlib.sha256(b"x").hexdigest(), ) assert captured.value.code is StorageErrorCode.ROOT_UNSAFE assert str(root) not in str(captured.value) assert str(root) not in repr(captured.value)