From ef22e3b07597fdc1d19aa5fd5bdc4b1f06f18146 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 05:04:00 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[fix=20T7.3,=20T?= =?UTF-8?q?7.5]:=20inbox=5Fname=20=D1=83=D0=BA=D0=BE=D1=80=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0;=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20.part=20=D0=BD=D0=B5=20=D0=BC=D0=B0=D1=81=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D1=83=D0=B5=D1=82=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T7.3: имя из 124+ кириллических символов давало `.part` длиннее 255 байт -> ENAMETOOLONG на ext4 и SMB/NTFS -> ложный RECOGNITION_FAILED. `inbox_name` обрезает основу по code points так, чтобы `._<имя>.part` занимал не больше 240 байт UTF-8, сохраняя расширение (до 16 символов после последней точки); короткие имена не меняются, ValueError — по-прежнему только для пустого имени, `.` и `..`. T7.5: `LocalHotFolder.put` подавляет OSError при удалении `.part` (`contextlib.suppress`), наружу выходит исходное исключение; в `smb.py` это уже было. Co-Authored-By: Claude Fable 5.1 --- api/src/gateway/hotfolder.py | 46 ++++++++++++++++-- api/tests/unit/test_local_hotfolder.py | 64 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index 4e7950c..83e4317 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -4,6 +4,7 @@ `//.json`. Имена каталогов (`Input`/`Output`) настраиваются, а не зашиты в код. """ +import contextlib import os from pathlib import Path from typing import Protocol @@ -12,6 +13,11 @@ _PATH_SEPARATORS = ("/", "\\") # Недопустимые в именах файлов Windows/SMB символы (кроме разделителей пути) и управляющие 0x00-0x1F (с NUL) _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x20)) _FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS) +# Предел длины временного имени `._<имя>.part` в байтах UTF-8: ext4 — 255 байт, SMB/NTFS — 255 единиц UTF-16 +# (их не больше, чем байт UTF-8); 15 байт — запас +MAX_PART_NAME_BYTES = 240 +# Суффикс после последней точки считается расширением и сохраняется при укорачивании, если он не длиннее этого +_MAX_EXTENSION_CHARS = 16 def validate_name(name: str) -> None: @@ -45,6 +51,10 @@ def inbox_name(job_id: str, original_name: str) -> str: Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows) убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id` допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы). + + Длинное имя укорачивается (T7.3): основа обрезается по символам с конца, пока `.<результат>.part` не уложится + в `MAX_PART_NAME_BYTES` байт UTF-8; расширение (суффикс после последней точки до `_MAX_EXTENSION_CHARS` символов) + сохраняется. Короткое имя не меняется. """ base = _split_basename(original_name) if base in (".", ".."): @@ -52,7 +62,35 @@ def inbox_name(job_id: str, original_name: str) -> str: base = "".join("_" if char in _ILLEGAL_CHARS else char for char in base).rstrip(". ") if not base: raise ValueError(f"недопустимое имя файла: {original_name!r}") - return f"{job_id}_{base}" + return f"{job_id}_{_fit_name(job_id, base)}" + + +def _utf8_len(text: str) -> int: + # surrogatepass: одиночный суррогат (схема запроса их не пропускает) не роняет подсчёт длины + return len(text.encode("utf-8", "surrogatepass")) + + +def _fit_name(job_id: str, base: str) -> str: + """Основа имени, при которой `._<основа>.part` занимает не больше `MAX_PART_NAME_BYTES` байт UTF-8.""" + budget = MAX_PART_NAME_BYTES - _utf8_len(f".{job_id}_.part") + if _utf8_len(base) <= budget: + return base + dot = base.rfind(".") + extension = base[dot:] if 0 < dot and len(base) - dot - 1 <= _MAX_EXTENSION_CHARS else "" + stem = base[: len(base) - len(extension)] + limit = budget - _utf8_len(extension) + used = 0 + kept = 0 + for char in stem: # по code points: многобайтный символ не разрезается + used += _utf8_len(char) + if used > limit: + break + kept += 1 + stem = stem[:kept] + if not extension: + # без расширения имя заканчивается основой: завершающие точки и пробелы отбрасывает Windows + stem = stem.rstrip(". ") + return (stem or "_") + extension class HotFolder(Protocol): @@ -96,7 +134,7 @@ class LocalHotFolder: """Атомарная запись: `..part` в том же каталоге, затем `os.replace` (существующий файл заменяется). Корень не создаётся (нет корня — `FileNotFoundError`), входящий каталог создаётся без родителей. При сбое - временный файл удаляется. + временный файл удаляется (ошибка удаления не маскирует исходную). """ validate_name(name) self.input_dir.mkdir(exist_ok=True) @@ -105,7 +143,9 @@ class LocalHotFolder: part.write_bytes(data) os.replace(part, self.input_dir / name) except BaseException: - part.unlink(missing_ok=True) + # ошибка удаления временного файла не должна маскировать исходную (T7.5) + with contextlib.suppress(OSError): + part.unlink(missing_ok=True) raise def poll(self, job_id: str) -> bytes | None: diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index a3d69f3..80e1812 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -101,6 +101,53 @@ class TestInboxName: assert (hf.input_dir / name).read_bytes() == b"x" +class TestInboxNameLength: + """T7.3: длинное имя укорачивается так, чтобы `._<имя>.part` занимал не больше 240 байт UTF-8 (ext4 — + 255 байт, SMB/NTFS — 255 единиц UTF-16), расширение (до 16 символов после последней точки) сохраняется.""" + + @staticmethod + def _part_bytes(name: str) -> int: + return len(f".{name}.part".encode()) + + def test_long_cyrillic_name_is_trimmed_and_put(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + job_id = _job_id() + name = inbox_name(job_id, "я" * 200 + ".pdf") + assert self._part_bytes(name) <= 240 + assert name.startswith(f"{job_id}_яя") + assert name.endswith("я.pdf") + hf.put(name, b"x") + assert (hf.input_dir / name).read_bytes() == b"x" + + def test_short_name_is_unchanged(self) -> None: + job_id = _job_id() + original = "Отчёт за 2024 год (копия).PDF" + assert inbox_name(job_id, original) == f"{job_id}_{original}" + + def test_name_at_limit_is_unchanged(self) -> None: + job_id = _job_id() + original = "a" * (240 - len(f".{job_id}_.part") - 4) + ".pdf" + name = inbox_name(job_id, original) + assert name == f"{job_id}_{original}" + assert self._part_bytes(name) == 240 + + def test_long_name_without_extension(self, tmp_path: Path) -> None: + name = inbox_name(_job_id(), "б" * 300) + assert self._part_bytes(name) <= 240 + assert name.endswith("б") + LocalHotFolder(tmp_path).put(name, b"x") + + def test_long_suffix_is_not_treated_as_extension(self) -> None: + name = inbox_name(_job_id(), "ж" * 150 + "." + "x" * 17) + assert self._part_bytes(name) <= 240 + assert not name.endswith("x" * 17) + + def test_four_byte_chars_are_not_split(self) -> None: + name = inbox_name(_job_id(), "\U0001f600" * 100 + ".png") + assert self._part_bytes(name) <= 240 + assert name.endswith("\U0001f600.png") + + class TestPaths: def test_default_layout(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) @@ -213,6 +260,23 @@ class TestPut: hf.put("a.pdf", b"data") assert list(hf.input_dir.iterdir()) == [] + def test_cleanup_failure_does_not_mask_original_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """T7.5: ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" + hf = LocalHotFolder(tmp_path) + + def boom(src: object, dst: object) -> None: + raise OSError("диск недоступен") + + def unlink_fails(self: Path, missing_ok: bool = False) -> None: + raise PermissionError("удаление .part запрещено") + + monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom) + monkeypatch.setattr(Path, "unlink", unlink_fails) + with pytest.raises(OSError, match="диск недоступен"): + hf.put("a.pdf", b"data") + class TestPoll: def test_none_until_answer_exists(self, tmp_path: Path) -> None: