From cb8c71fc26a3d38ccf0bed8339e53c80ad9cb396 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 03:44:41 +0400 Subject: [PATCH] =?UTF-8?q?fix(hotfolder):=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B5=D0=BC=20=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BD=D0=B5=20=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0=D0=B5=D1=82=20.part=20(R-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalHotFolder.put сначала проверяет назначение: если файл во входящем каталоге уже есть, он считается доставленным, ничего не пишется, а .part снимается только при включённой очистке. Иначе удаление старого .part подавляется (остаток прерванной записи не роняет задание); если он остался, усечение разрешено только при st_nlink == 1, ссылка на чужой файл не трогается и исключение поднимается как есть. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/gateway/hotfolder.py | 30 ++++++++-- api/tests/unit/test_local_hotfolder.py | 83 +++++++++++++++++++++----- 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index 2971fad..145d910 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -206,17 +206,23 @@ class LocalHotFolder: def put(self, name: str, data: bytes) -> None: """Атомарная запись без замены: `..part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/` и удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным - (повтор задания), `.part` остаётся при ошибке линковки, если очистка выключена. Корень не создаётся - (нет корня — `FileNotFoundError`).""" + (повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не + создаётся (нет корня — `FileNotFoundError`).""" validate_name(name) self.tmp_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True) part = self.tmp_dir / f".{name}.part" target = self.input_dir / name - # `.part` создаётся заново (№32): оставшийся от прошлого задания мог быть жёсткой ссылкой на уже опубликованный - # файл (удаление после публикации подавляется, R-8), запись «поверх» испортила бы файл, который читает ИС - part.unlink(missing_ok=True) - fd = os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + if target.exists(): + # R-17: `.part` от прошлой публикации может быть жёсткой ссылкой на этот файл (удаление после публикации + # подавляется, R-8, OQ-144) — писать и даже удалять его незачем, файл уже доставлен + self._drop_part(part) + return + # `.part` создаётся заново (№32): остаток от прерванной записи не должен ронять задание, поэтому ошибка его + # удаления подавляется; если он всё же остался, `_open_part` не даст записать «поверх» ссылки на чужой файл + with contextlib.suppress(OSError): + part.unlink(missing_ok=True) + fd = self._open_part(part) try: with os.fdopen(fd, "wb") as handle: handle.write(data) @@ -245,6 +251,18 @@ class LocalHotFolder: with contextlib.suppress(OSError): part.unlink(missing_ok=True) + @staticmethod + def _open_part(part: Path) -> int: + """Открыть `.part` на запись: новый файл (`O_EXCL`). Если старый не удалился (`FileExistsError`) — усечь его + только когда это обычный файл без других ссылок (`st_nlink == 1`); ссылку на опубликованный файл не трогаем, + исключение поднимается как есть (R-17).""" + try: + return os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except FileExistsError: + if os.lstat(part).st_nlink != 1: + raise + return os.open(part, os.O_WRONLY | os.O_TRUNC) + def _replace_part(self, part: Path, target: Path) -> None: try: os.replace(part, target) diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index 93e2d0d..a76bb25 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -63,7 +63,8 @@ class TestPutWithoutReplace: hf.put("job.pdf", b"first") hf.put("job.pdf", b"second") assert (hf.input_dir / "job.pdf").read_bytes() == b"first" - assert (hf.tmp_dir / ".job.pdf.part").read_bytes() == b"second" # .part остаётся: удаление только под флагом + # R-17: назначение есть — повтор ничего не пишет; прежнего `.part` после успешной публикации нет + assert not (hf.tmp_dir / ".job.pdf.part").exists() def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, cleanup=True) @@ -322,8 +323,7 @@ class TestPut: def unlink_with_failure(path: Any) -> None: unlink_calls.append(path) - # перед записью `.part` создаётся заново (№32) — отказ удаления только после публикации файла (R-8) - if str(path).endswith(".part") and (hf.input_dir / "a.pdf").exists(): + if str(path).endswith(".part"): raise PermissionError("удаление запрещено") real_unlink(path) @@ -343,8 +343,6 @@ class TestPut: raise OSError("диск недоступен") def unlink_fails(self: Path, missing_ok: bool = False) -> None: - if missing_ok and not self.exists(): # как настоящий unlink: нет файла — нечего запрещать (№32) - return raise PermissionError("удаление .part запрещено") monkeypatch.setattr("src.gateway.hotfolder.os.link", boom) @@ -356,17 +354,69 @@ class TestPut: class TestPartIsCreatedFresh: """Решение №32: `.part` создаётся заново (удаление старого + `O_EXCL`), fallback — только вокруг `os.link`.""" - def test_stale_part_hard_linked_to_published_file_is_not_truncated(self, tmp_path: Path) -> None: - hf = LocalHotFolder(tmp_path) + @pytest.mark.parametrize("cleanup", [False, True]) + def test_existing_destination_is_not_touched_by_repeat(self, tmp_path: Path, cleanup: bool) -> None: + """R-17: назначение есть, `.part` — жёсткая ссылка на него: байты не меняются, put не падает.""" + hf = LocalHotFolder(tmp_path, cleanup=cleanup) hf.put("job.pdf", b"first") published = hf.input_dir / "job.pdf" stale = hf.tmp_dir / ".job.pdf.part" - os.link(published, stale) # подавленное удаление `.part` после публикации оставило жёсткую ссылку - assert published.stat().st_nlink == 2 - hf.put("job.pdf", b"different data") # повтор задания: назначение есть, файл считается доставленным - assert published.read_bytes() == b"first" # опубликованный файл не изменился - assert stale.read_bytes() == b"different data" # `.part` — новый файл, а не запись в ту же ссылку - assert published.stat().st_nlink == 1 + os.link(published, stale) + hf.put("job.pdf", b"different data") + assert published.read_bytes() == b"first" + assert stale.exists() is (not cleanup) # очистка снимает `.part`, иначе он остаётся как был + if not cleanup: + assert stale.read_bytes() == b"first" and published.stat().st_nlink == 2 + + def test_existing_destination_with_undeletable_part_does_not_fail( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf = LocalHotFolder(tmp_path) # очистка выключена: удаление `.part` вообще не пытается + hf.put("job.pdf", b"first") + os.link(hf.input_dir / "job.pdf", hf.tmp_dir / ".job.pdf.part") + + def deny(self: Path, missing_ok: bool = False) -> None: + raise PermissionError("удаление запрещено") + + monkeypatch.setattr(Path, "unlink", deny) + hf.put("job.pdf", b"second") + assert (hf.input_dir / "job.pdf").read_bytes() == b"first" + + def test_undeletable_stale_part_with_single_link_is_truncated( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Назначения нет, старый `.part` не удаляется, но это обычный файл (nlink == 1): put успешен.""" + hf = LocalHotFolder(tmp_path) + hf.tmp_dir.mkdir() + stale = hf.tmp_dir / ".job.pdf.part" + stale.write_bytes(b"old and longer content") + + def deny(self: Path, missing_ok: bool = False) -> None: + raise PermissionError("удаление запрещено") + + monkeypatch.setattr(Path, "unlink", deny) + hf.put("job.pdf", b"new") + assert (hf.input_dir / "job.pdf").read_bytes() == b"new" + + def test_undeletable_stale_part_with_other_links_is_not_written( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Назначения нет, старый `.part` не удаляется и имеет nlink == 2 (синтетически): запись не идёт, ошибка не + маскируется, второй файл не изменён.""" + hf = LocalHotFolder(tmp_path) + hf.tmp_dir.mkdir() + other = tmp_path / "other.bin" + other.write_bytes(b"precious") + os.link(other, hf.tmp_dir / ".job.pdf.part") + + def deny(self: Path, missing_ok: bool = False) -> None: + raise PermissionError("удаление запрещено") + + monkeypatch.setattr(Path, "unlink", deny) + with pytest.raises(FileExistsError): + hf.put("job.pdf", b"new") + assert other.read_bytes() == b"precious" + assert not (hf.input_dir / "job.pdf").exists() def test_stale_part_is_replaced_before_publication(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) @@ -423,12 +473,15 @@ class TestPartIsCreatedFresh: assert (hf.input_dir / "job.pdf").read_bytes() == b"data" assert list(hf.tmp_dir.iterdir()) == [] # `.part` стал назначением - def test_no_fallback_when_destination_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_no_fallback_when_destination_appeared_during_link( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Назначение появилось между проверкой и линковкой, а `os.link` дал EPERM: `os.replace` не затирает.""" hf = LocalHotFolder(tmp_path, cleanup=True) hf.input_dir.mkdir() - (hf.input_dir / "job.pdf").write_bytes(b"first") def no_links(src: Any, dst: Any) -> None: + Path(dst).write_bytes(b"first") # файл успел появиться (параллельный повтор того же задания) raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются") monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)