diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index 498ea62..3c047c0 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -203,8 +203,9 @@ class LocalHotFolder: def put(self, name: str, data: bytes) -> None: """Атомарная запись без замены: `..part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/` и - удаление `.part`. Назначение уже есть — файл считается доставленным (повтор задания), `.part` остаётся, если - очистка выключена. Корень не создаётся (нет корня — `FileNotFoundError`).""" + удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным + (повтор задания), `.part` остаётся при ошибке линковки, если очистка выключена. Корень не создаётся + (нет корня — `FileNotFoundError`).""" validate_name(name) self.tmp_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True) @@ -227,7 +228,10 @@ class LocalHotFolder: except BaseException: self._drop_part(part) raise - part.unlink(missing_ok=True) # завершение успешной записи, а не «очистка следов»: флаг не действует + # завершение успешной записи (как rename в старой версии), не управляется флагом очистки: + # файл уже доставлен, ошибка удаления .part её не должна отменить (R-8) + with contextlib.suppress(OSError): + part.unlink(missing_ok=True) def _drop_part(self, part: Path) -> None: if self.cleanup_enabled: @@ -237,6 +241,7 @@ class LocalHotFolder: def cleanup(self, job_id: str, name: str) -> bool: if not self.cleanup_enabled: return False + validate_name(name) # защита от выхода за пределы папки (T11.4) folder = self.answer_dir(job_id) if folder.is_dir(): shutil.rmtree(folder) diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index 35673c6..52d48d7 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -122,6 +122,14 @@ class TestCleanup: with pytest.raises(PermissionError): hf.cleanup(job_id, f"{job_id}.pdf") + def test_rejects_unsafe_names(self, tmp_path: Path) -> None: + """cleanup: validate_name защищает от выхода за пределы папки (T11.4).""" + hf = LocalHotFolder(tmp_path, cleanup=True) + with pytest.raises(ValueError, match="имя файла"): + hf.cleanup(_job_id(), "../x.pdf") + # ничего не удалено + assert not hf.input_dir.exists() + class TestPaths: def test_default_layout(self, tmp_path: Path) -> None: @@ -276,6 +284,7 @@ class TestPut: assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """При cleanup=True и ошибке link: .part удаляется.""" hf = LocalHotFolder(tmp_path, cleanup=True) def boom(src: Any, dst: Any) -> None: @@ -287,11 +296,46 @@ class TestPut: assert list(hf.input_dir.iterdir()) == [] assert list(hf.tmp_dir.iterdir()) == [] + def test_keeps_part_file_on_failure_when_cleanup_disabled( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """При cleanup=False и ошибке link: .part остаётся в Tmp (T11.1).""" + hf = LocalHotFolder(tmp_path, cleanup=False) + + def boom(src: Any, dst: Any) -> None: + raise OSError("диск недоступен") + + monkeypatch.setattr("src.gateway.hotfolder.os.link", boom) + with pytest.raises(OSError, match="диск недоступен"): + hf.put("a.pdf", b"data") + assert list(hf.input_dir.iterdir()) == [] + assert [p.name for p in hf.tmp_dir.iterdir()] == [".a.pdf.part"] + + def test_successful_put_despite_final_unlink_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Успешный link, но ошибка в завершающем unlink .part (R-8): put НЕ падает, файл доставлен (T11.2).""" + hf = LocalHotFolder(tmp_path) + + unlink_calls = [] + + real_unlink = os.unlink + + def unlink_with_failure(path: Any) -> None: + unlink_calls.append(path) + if str(path).endswith(".part"): + raise PermissionError("удаление запрещено") + real_unlink(path) + + monkeypatch.setattr("src.gateway.hotfolder.os.unlink", unlink_with_failure) + hf.put("a.pdf", b"data") # НЕ падает несмотря на ошибку unlink + assert (hf.input_dir / "a.pdf").read_bytes() == b"data" + assert (hf.tmp_dir / ".a.pdf.part").exists() # .part не удалён из-за ошибки + assert unlink_calls # попытка удаления была + def test_cleanup_failure_does_not_mask_original_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" - hf = LocalHotFolder(tmp_path) + """Ошибка удаления `.part` на пути ошибки link подавляется (T11.1); исходное исключение проходит.""" + hf = LocalHotFolder(tmp_path, cleanup=True) def boom(src: Any, dst: Any) -> None: raise OSError("диск недоступен")