Срез 0 [fix T7.3, T7.5]: inbox_name укорачивает длинные имена; сбой удаления .part не маскирует ошибку

T7.3: имя из 124+ кириллических символов давало `.part` длиннее 255 байт -> ENAMETOOLONG на ext4 и SMB/NTFS
-> ложный RECOGNITION_FAILED. `inbox_name` обрезает основу по code points так, чтобы `.<job_id>_<имя>.part`
занимал не больше 240 байт UTF-8, сохраняя расширение (до 16 символов после последней точки); короткие имена не
меняются, ValueError — по-прежнему только для пустого имени, `.` и `..`.
T7.5: `LocalHotFolder.put` подавляет OSError при удалении `.part` (`contextlib.suppress`), наружу выходит исходное
исключение; в `smb.py` это уже было.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 05:04:00 +04:00
parent d288d19622
commit ef22e3b075
2 changed files with 107 additions and 3 deletions

View File

@ -4,6 +4,7 @@
`<output>/<job_id>/<job_id>.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)
# Предел длины временного имени `.<job_id>_<имя>.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:
"""Основа имени, при которой `.<job_id>_<основа>.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:
"""Атомарная запись: `.<name>.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:

View File

@ -101,6 +101,53 @@ class TestInboxName:
assert (hf.input_dir / name).read_bytes() == b"x"
class TestInboxNameLength:
"""T7.3: длинное имя укорачивается так, чтобы `.<job_id>_<имя>.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: