Срез 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:
parent
1695573b35
commit
5fb046fced
@ -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}"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user