Срез 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:
parent
d288d19622
commit
ef22e3b075
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user