Срез 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:
"""Имя входящего файла `<job_id>_<original_name>`.
`original_name` приходит из недоверенного запроса: берётся только последний компонент пути (без разделителей
и `..`). Если после этого имени не осталось (пусто, `.`, `..`) или в нём есть символы, недопустимые в имени файла
Windows/SMB, либо управляющие (см. `validate_name`) — `ValueError`. Пробелы и кириллица сохраняются как есть.
`original_name` приходит из недоверенного запроса («как загрузил пользователь», контракт требует только
непустую строку): берётся последний компонент пути (без разделителей и `..`), символы, недопустимые в имени файла
Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows)
убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id`
допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы).
"""
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}")
return f"{job_id}_{base}"

View File

@ -15,9 +15,13 @@ import ntpath
from typing import Any
import smbclient
from smbprotocol.header import NtStatus
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:
"""`SMBOSError` несёт `errno`, но не превращается в подкласс `OSError` по нему — проверяем и то, и другое."""
@ -87,13 +91,16 @@ class SmbHotFolder:
def poll(self, job_id: str) -> bytes | 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)
try:
# Монопольное открытие (share_access по умолчанию 0): пока ИС пишет файл, оно не проходит, и недописанный
# JSON не читается
with smbclient.open_file(path, mode="rb", **self._credentials) as fd:
data: bytes = fd.read()
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
raise
return data
@ -107,9 +114,22 @@ class SmbHotFolder:
return True
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:
smbclient.mkdir(self.input_dir, **self._credentials)
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
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
from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name
from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name, validate_name
def _job_id() -> str:
@ -40,22 +40,60 @@ class TestInboxName:
def test_sanitizes_to_basename(self, original: str, expected: str) -> None:
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:
with pytest.raises(ValueError, match="имя файла"):
inbox_name("job", original)
@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:
with pytest.raises(ValueError, match="имя файла"):
inbox_name("job", original)
def test_replaces_chars_illegal_on_windows(self, original: str, expected: str) -> None:
"""Имя от пользователя законно на Linux/S3/macOS: заменяем, а не отвергаем (контракт: только minLength 1)."""
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:
job_id = _job_id()
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:
hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), "../../scan.pdf")

View File

@ -7,6 +7,7 @@
import os
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import IO, Any
import pytest
@ -29,6 +30,11 @@ class FakeSmbClient:
self.base = base
self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = []
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:
self.calls.append((func, args, kwargs))
@ -185,11 +191,31 @@ class TestPut:
assert fake.funcs() == ["mkdir"]
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)
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")
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:
hf = _hotfolder()
@ -290,6 +316,16 @@ class TestPoll:
fake.fail["open_file"] = FileNotFoundError("нет файла")
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:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT)
with pytest.raises(SMBOSError):