from __future__ import annotations from pathlib import Path, PurePosixPath import pytest from app.modules.data_process import storage as storage_module from app.modules.data_process.storage import ( DataProcessStorageError, LocalDataProcessStorage, StagedSourceObject, ) def _stage( storage: LocalDataProcessStorage, *, batch_id: str = "batch-main", task_id: str = "task-1", source_file_id: str = "source-1", version: int = 1, name: str = "source.txt", content: bytes = b"payload", ) -> StagedSourceObject: return storage.stage_bytes( batch_id=batch_id, task_id=task_id, source_file_id=source_file_id, version=version, name=name, content=content, ) def _create_symlink(link: Path, target: Path, *, target_is_directory: bool = False) -> None: try: link.symlink_to(target, target_is_directory=target_is_directory) except (NotImplementedError, OSError) as exc: pytest.skip(f"当前平台不支持创建测试所需的符号链接: {exc}") def _assert_staging_empty(storage: LocalDataProcessStorage) -> None: assert list((storage.root / ".staging").iterdir()) == [] def test_stage_publish_read_delete_roundtrip_with_unicode_filename(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") content = "第一行\n第二行,100% 完成".encode() staged = _stage( storage, name="中文 数据 100%.csv", content=content, ) assert "%20" in staged.reference assert "%25" in staged.reference storage.publish([staged]) assert storage.read(staged.reference) == content assert storage.delete(staged.reference) is True assert storage.delete(staged.reference) is False _assert_staging_empty(storage) def test_db_reference_is_left_to_database_storage(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") assert storage.read("db://source-files/source-1") is None assert storage.delete("db://source-files/source-1") is False def test_owned_source_can_be_streamed_by_byte_range(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") content = b"0123456789abcdef" staged = _stage(storage, content=content) storage.publish([staged]) assert storage.file_size( staged.reference, expected_task_id="task-1", expected_source_file_id="source-1", ) == len(content) assert b"".join(storage.iter_bytes( staged.reference, expected_task_id="task-1", expected_source_file_id="source-1", expected_size=len(content), start=4, length=6, chunk_size=2, )) == b"456789" with pytest.raises(DataProcessStorageError, match="owner mismatch"): storage.file_size( staged.reference, expected_task_id="another-task", expected_source_file_id="source-1", ) with pytest.raises(DataProcessStorageError, match="does not match metadata"): b"".join(storage.iter_bytes( staged.reference, expected_task_id="task-1", expected_source_file_id="source-1", expected_size=len(content) + 1, )) @pytest.mark.parametrize( "reference", [ "local://data-process/../source-1/v1/file.txt", "local://data-process/task-1/source-1/v1/file%2Fname.txt", "local://data-process/task-1/source-1/v1/file.txt?download=1", "local://data-process/task-1/source-1/v1/file.txt#fragment", "https://data-process/task-1/source-1/v1/file.txt", ], ids=[ "parent-traversal", "percent-encoded-slash", "query", "fragment", "wrong-scheme", ], ) def test_unsafe_references_are_rejected(tmp_path: Path, reference: str) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") with pytest.raises(DataProcessStorageError): storage.read(reference) with pytest.raises(DataProcessStorageError): storage.delete(reference) def test_publish_rejects_intermediate_directory_symlink(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") outside = tmp_path / "outside" outside.mkdir() staged = _stage(storage, task_id="linked-task") _create_symlink( storage.root / "linked-task", outside, target_is_directory=True, ) with pytest.raises(DataProcessStorageError, match="symlink|non-directory"): storage.publish([staged]) assert list(outside.iterdir()) == [] _assert_staging_empty(storage) def test_target_symlink_is_never_followed_or_deleted(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") staged = _stage(storage, task_id="task-link", source_file_id="source-link") outside_file = tmp_path / "outside.txt" outside_file.write_bytes(b"outside sentinel") final_path = storage.root.joinpath(*staged._relative_path.parts) final_path.parent.mkdir(parents=True) _create_symlink(final_path, outside_file) with pytest.raises(DataProcessStorageError, match="already exists"): storage.publish([staged]) with pytest.raises(DataProcessStorageError, match="regular file"): storage.read(staged.reference) with pytest.raises(DataProcessStorageError, match="non-regular"): storage.delete(staged.reference) assert final_path.is_symlink() assert outside_file.read_bytes() == b"outside sentinel" _assert_staging_empty(storage) def test_publish_rolls_back_first_object_when_second_target_collides(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") existing = _stage( storage, batch_id="batch-existing", source_file_id="source-existing", content=b"existing content", ) storage.publish([existing]) first = _stage( storage, batch_id="batch-new", source_file_id="source-new", content=b"must be rolled back", ) colliding_second = _stage( storage, batch_id="batch-new", source_file_id="source-existing", content=b"must not replace existing content", ) with pytest.raises(DataProcessStorageError, match="already exists"): storage.publish([first, colliding_second]) with pytest.raises(DataProcessStorageError, match="does not exist"): storage.read(first.reference) assert storage.read(existing.reference) == b"existing content" _assert_staging_empty(storage) def test_publish_rejects_manually_forged_staged_object(tmp_path: Path) -> None: storage = LocalDataProcessStorage(tmp_path / "storage") temporary_path = storage.root / ".staging" / "batch-forged" / "forged.tmp" temporary_path.parent.mkdir() temporary_path.write_bytes(b"forged content") relative_path = PurePosixPath("task-forged", "source-forged", "v1", "forged.txt") forged = StagedSourceObject( reference="local://data-process/task-forged/source-forged/v1/forged.txt", _temporary_path=temporary_path, _relative_path=relative_path, ) with pytest.raises(DataProcessStorageError, match="was not issued"): storage.publish([forged]) with pytest.raises(DataProcessStorageError, match="was not issued"): storage.discard([forged]) assert temporary_path.read_bytes() == b"forged content" assert not storage.root.joinpath(*relative_path.parts).exists() def test_relative_storage_configuration_is_anchored_to_backend_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: relative_configuration = Path("relative-storage") / tmp_path.name backend_root = Path(storage_module.__file__).resolve().parents[3] monkeypatch.chdir(tmp_path) monkeypatch.setenv("DATA_PROCESS_STORAGE_DIR", str(relative_configuration)) storage_module.get_data_process_storage.cache_clear() try: configured_root = storage_module._configured_storage_root() assert configured_root == backend_root / relative_configuration assert not configured_root.exists() finally: storage_module.get_data_process_storage.cache_clear()