Срез 0 [4.2] T8.1-T8.3: inbox_name заменяет символы Windows/SMB, poll ждёт занятый файл, put терпит отказ mkdir при существующем Input

T8.1: inbox_name заменяет `: < > " | ? *` и управляющие символы на `_`, убирает завершающие точки/пробелы;
ValueError только если имени не осталось. validate_name/put остаются строгими.
T8.2: SmbHotFolder.poll возвращает None на STATUS_SHARING_VIOLATION/STATUS_DELETE_PENDING (ИС ещё пишет ответ),
монопольное открытие сохранено.
T8.3: при ошибке mkdir, кроме EEXIST/ENOENT, put продолжает, если smbclient.path.isdir(Input); иначе исходная ошибка.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 03:05:02 +04:00
parent 1695573b35
commit 5fb046fced
4 changed files with 116 additions and 17 deletions

View File

@ -40,12 +40,17 @@ def _split_basename(name: str) -> str:
def inbox_name(job_id: str, original_name: str) -> str: def inbox_name(job_id: str, original_name: str) -> str:
"""Имя входящего файла `<job_id>_<original_name>`. """Имя входящего файла `<job_id>_<original_name>`.
`original_name` приходит из недоверенного запроса: берётся только последний компонент пути (без разделителей `original_name` приходит из недоверенного запроса («как загрузил пользователь», контракт требует только
и `..`). Если после этого имени не осталось (пусто, `.`, `..`) или в нём есть символы, недопустимые в имени файла непустую строку): берётся последний компонент пути (без разделителей и `..`), символы, недопустимые в имени файла
Windows/SMB, либо управляющие (см. `validate_name`) — `ValueError`. Пробелы и кириллица сохраняются как есть. Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows)
убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id`
допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы).
""" """
base = _split_basename(original_name) base = _split_basename(original_name)
if base in ("", ".", "..") or any(char in _ILLEGAL_CHARS for char in base): if base in (".", ".."):
raise ValueError(f"недопустимое имя файла: {original_name!r}")
base = "".join("_" if char in _ILLEGAL_CHARS else char for char in base).rstrip(". ")
if not base:
raise ValueError(f"недопустимое имя файла: {original_name!r}") raise ValueError(f"недопустимое имя файла: {original_name!r}")
return f"{job_id}_{base}" return f"{job_id}_{base}"

View File

@ -15,9 +15,13 @@ import ntpath
from typing import Any from typing import Any
import smbclient import smbclient
from smbprotocol.header import NtStatus
from src.gateway.hotfolder import validate_job_id, validate_name from src.gateway.hotfolder import validate_job_id, validate_name
# Ответ есть, но пока не читается: ИС держит файл открытым на запись или файл удаляется — опрос повторится позже
_NOT_READY = frozenset({NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING})
def _has_errno(err: OSError, code: int, exc_type: type[OSError]) -> bool: def _has_errno(err: OSError, code: int, exc_type: type[OSError]) -> bool:
"""`SMBOSError` несёт `errno`, но не превращается в подкласс `OSError` по нему — проверяем и то, и другое.""" """`SMBOSError` несёт `errno`, но не превращается в подкласс `OSError` по нему — проверяем и то, и другое."""
@ -87,13 +91,16 @@ class SmbHotFolder:
def poll(self, job_id: str) -> bytes | None: def poll(self, job_id: str) -> bytes | None:
r"""Байты `<output>\<job_id>\<job_id>.json` или `None`, пока его нет (нет файла, каталога задания или r"""Байты `<output>\<job_id>\<job_id>.json` или `None`, пока его нет (нет файла, каталога задания или
`Output`). Остальные ошибки SMB поднимаются.""" `Output`) или он ещё не готов (ИС держит его открытым — `STATUS_SHARING_VIOLATION`, удаляется —
`STATUS_DELETE_PENDING`). Остальные ошибки SMB поднимаются."""
path = self.answer_path(job_id) path = self.answer_path(job_id)
try: try:
# Монопольное открытие (share_access по умолчанию 0): пока ИС пишет файл, оно не проходит, и недописанный
# JSON не читается
with smbclient.open_file(path, mode="rb", **self._credentials) as fd: with smbclient.open_file(path, mode="rb", **self._credentials) as fd:
data: bytes = fd.read() data: bytes = fd.read()
except OSError as err: except OSError as err:
if _has_errno(err, errno.ENOENT, FileNotFoundError): if _has_errno(err, errno.ENOENT, FileNotFoundError) or getattr(err, "ntstatus", None) in _NOT_READY:
return None return None
raise raise
return data return data
@ -107,9 +114,22 @@ class SmbHotFolder:
return True return True
def _ensure_input_dir(self) -> None: def _ensure_input_dir(self) -> None:
"""`mkdir` входящего каталога; «уже существует» (EEXIST) — не ошибка.""" """`mkdir` входящего каталога, как `os.makedirs(exist_ok=True)` без родителей.
«Уже существует» (EEXIST) — не ошибка. Другая ошибка (например, `STATUS_ACCESS_DENIED`: учётке нельзя
создавать подкаталоги в корне, хотя писать в `Input` можно) — тоже не ошибка, если каталог уже есть
(`smbclient.path.isdir`); иначе поднимается исходная ошибка `mkdir`. ENOENT (нет корня) — сразу наружу.
"""
try: try:
smbclient.mkdir(self.input_dir, **self._credentials) smbclient.mkdir(self.input_dir, **self._credentials)
except OSError as err: except OSError as err:
if not _has_errno(err, errno.EEXIST, FileExistsError): if _has_errno(err, errno.EEXIST, FileExistsError):
return
if _has_errno(err, errno.ENOENT, FileNotFoundError) or not self._input_dir_exists():
raise raise
def _input_dir_exists(self) -> bool:
try:
return bool(smbclient.path.isdir(self.input_dir, **self._credentials))
except Exception:
return False

View File

@ -8,7 +8,7 @@ from pathlib import Path
import pytest import pytest
from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name, validate_name
def _job_id() -> str: def _job_id() -> str:
@ -40,22 +40,60 @@ class TestInboxName:
def test_sanitizes_to_basename(self, original: str, expected: str) -> None: def test_sanitizes_to_basename(self, original: str, expected: str) -> None:
assert inbox_name("job", original) == f"job_{expected}" assert inbox_name("job", original) == f"job_{expected}"
@pytest.mark.parametrize("original", ["", "/", "..", ".", "a/..", "dir/", "\\", "bad\x00name.pdf"]) @pytest.mark.parametrize("original", ["", "/", "..", ".", "a/..", "dir/", "\\", "...", ". .", " ", "dir/. "])
def test_rejects_unusable_names(self, original: str) -> None: def test_rejects_unusable_names(self, original: str) -> None:
with pytest.raises(ValueError, match="имя файла"): with pytest.raises(ValueError, match="имя файла"):
inbox_name("job", original) inbox_name("job", original)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"original", ["a:b.pdf", "отчёт?.pdf", "a*b.pdf", 'a"b.pdf', "a|b.pdf", "a<b>.pdf", "a\x01b.pdf"] ("original", "expected"),
[
("a:b?.pdf", "a_b_.pdf"),
("Отчёт: 2024.pdf", "Отчёт_ 2024.pdf"),
('Баланс "итог".pdf', "Баланс _итог_.pdf"),
("форма?.pdf", "форма_.pdf"),
("a|b<c>*.pdf", "a_b_c__.pdf"),
("C:report.pdf", "C_report.pdf"),
("a\x01b.pdf", "a_b.pdf"),
("bad\x00name.pdf", "bad_name.pdf"),
("tab\tname.pdf", "tab_name.pdf"),
],
) )
def test_rejects_names_illegal_on_windows(self, original: str) -> None: def test_replaces_chars_illegal_on_windows(self, original: str, expected: str) -> None:
with pytest.raises(ValueError, match="имя файла"): """Имя от пользователя законно на Linux/S3/macOS: заменяем, а не отвергаем (контракт: только minLength 1)."""
inbox_name("job", original) assert inbox_name("job", original) == f"job_{expected}"
@pytest.mark.parametrize(
("original", "expected"),
[("report.pdf.", "report.pdf"), ("report.pdf . ", "report.pdf"), ("report .pdf", "report .pdf")],
)
def test_strips_trailing_dots_and_spaces(self, original: str, expected: str) -> None:
assert inbox_name("job", original) == f"job_{expected}"
def test_keeps_spaces_and_cyrillic(self) -> None: def test_keeps_spaces_and_cyrillic(self) -> None:
job_id = _job_id() job_id = _job_id()
assert inbox_name(job_id, "Отчёт за 2024 год (копия).PDF") == f"{job_id}_Отчёт за 2024 год (копия).PDF" assert inbox_name(job_id, "Отчёт за 2024 год (копия).PDF") == f"{job_id}_Отчёт за 2024 год (копия).PDF"
@pytest.mark.parametrize(
"original",
[
"a:b?.pdf",
'Баланс "итог".pdf',
"Отчёт за 2024 год (копия).PDF",
".hidden.pdf",
"..pdf",
"x\x00\x1f<>|*?.pdf. ",
"C:\\dir\\..\\скан.png",
" leading.pdf",
],
)
def test_result_always_passes_validate_name_and_put(self, tmp_path: Path, original: str) -> None:
hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), original)
validate_name(name)
hf.put(name, b"x")
assert (hf.input_dir / name).read_bytes() == b"x"
def test_result_is_accepted_by_put(self, tmp_path: Path) -> None: def test_result_is_accepted_by_put(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path) hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), "../../scan.pdf") name = inbox_name(_job_id(), "../../scan.pdf")

View File

@ -7,6 +7,7 @@
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import IO, Any from typing import IO, Any
import pytest import pytest
@ -29,6 +30,11 @@ class FakeSmbClient:
self.base = base self.base = base
self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = [] self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = []
self.fail: dict[str, BaseException] = {} # имя функции -> исключение, которое она бросит self.fail: dict[str, BaseException] = {} # имя функции -> исключение, которое она бросит
self.path = SimpleNamespace(isdir=self._isdir) # подмодуль smbclient.path
def _isdir(self, path: str, **kwargs: Any) -> bool:
self._record("isdir", (path,), kwargs)
return self._local(path).is_dir()
def _record(self, func: str, args: tuple[str, ...], kwargs: dict[str, Any]) -> None: def _record(self, func: str, args: tuple[str, ...], kwargs: dict[str, Any]) -> None:
self.calls.append((func, args, kwargs)) self.calls.append((func, args, kwargs))
@ -185,11 +191,31 @@ class TestPut:
assert fake.funcs() == ["mkdir"] assert fake.funcs() == ["mkdir"]
assert not fake.base.exists() assert not fake.base.exists()
def test_mkdir_other_error_propagates(self, fake: FakeSmbClient, share_root: Path) -> None: def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
(share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError): _hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "open_file", "replace"]
assert fake.calls[1] == ("isdir", (INPUT,), {})
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1") _hotfolder().put("a.pdf", b"1")
assert fake.funcs() == ["mkdir"] assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None: def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder() hf = _hotfolder()
@ -290,6 +316,16 @@ class TestPoll:
fake.fail["open_file"] = FileNotFoundError("нет файла") fake.fail["open_file"] = FileNotFoundError("нет файла")
assert _hotfolder().poll(_job_id()) is None assert _hotfolder().poll(_job_id()) is None
@pytest.mark.parametrize("status", [NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING])
def test_answer_being_written_or_deleted_is_none(
self, fake: FakeSmbClient, share_root: Path, status: NtStatus
) -> None:
"""ИС ещё держит файл (монопольное открытие не проходит) — ответа пока нет, опросим позже."""
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
fake.fail["open_file"] = SMBOSError(status, OUTPUT)
assert _hotfolder().poll(job_id) is None
def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path) -> None: def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path) -> None:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT) fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT)
with pytest.raises(SMBOSError): with pytest.raises(SMBOSError):