fix(domain): импорт — устойчивое чтение повреждённых xlsx, лимиты строк и размера (T9.1, T9.2, T9.4)
- T9.1: ошибки разбора xlsx (XML, ссылки ячеек, книга без листов, zlib) и при ленивой итерации листа → UNSUPPORTED_FILE «файл xlsx не читается». - T9.2: строки читаются лениво; MAX_IMPORT_ROWS = 100 000 сырых строк (с пустыми) и MAX_UNCOMPRESSED_BYTES = 200 MiB распакованного zip → UNSUPPORTED_FILE. - T9.4: номер строки csv — строка файла, с которой начинается запись. - Тесты: перепакованные xlsx с повреждёнными частями, r="2000000", zip-бомба, предел строк csv, многострочное поле csv. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
7f14e70aef
commit
1364f43c61
@ -4,6 +4,11 @@
|
|||||||
снимку справочника (`DirectorySnapshot`, строит сервис) решает судьбу каждой строки и возвращает операции (только
|
снимку справочника (`DirectorySnapshot`, строит сервис) решает судьбу каждой строки и возвращает операции (только
|
||||||
данные — применяет сервис) и отчёт с кодами §13.2. Идентификаторы новых объектов генерируются здесь (`uuid4`), чтобы
|
данные — применяет сервис) и отчёт с кодами §13.2. Идентификаторы новых объектов генерируются здесь (`uuid4`), чтобы
|
||||||
операции плана ссылались друг на друга до записи в БД.
|
операции плана ссылались друг на друга до записи в БД.
|
||||||
|
|
||||||
|
Технические пределы чтения (защита от повреждённых и «раздутых» файлов, не правила предметной области):
|
||||||
|
`MAX_IMPORT_ROWS` — число строк листа/записей csv, включая пустые (реальная выгрузка ЕЦП — 8400 строк);
|
||||||
|
`MAX_UNCOMPRESSED_BYTES` — суммарный распакованный размер частей xlsx (zip), проверяется до разбора. Превышение —
|
||||||
|
`UNSUPPORTED_FILE`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@ -12,7 +17,8 @@ import io
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Iterable, Sequence
|
import zlib
|
||||||
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -73,6 +79,22 @@ FLAG_GSZ = "входитвгсз"
|
|||||||
MISSING_COLUMNS = "MISSING_COLUMNS"
|
MISSING_COLUMNS = "MISSING_COLUMNS"
|
||||||
UNSUPPORTED_FILE = "UNSUPPORTED_FILE"
|
UNSUPPORTED_FILE = "UNSUPPORTED_FILE"
|
||||||
|
|
||||||
|
MAX_IMPORT_ROWS = 100_000
|
||||||
|
MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
|
||||||
|
# Сбои разбора повреждённого xlsx (zip, XML, ссылки ячеек, книга без листов); `SyntaxError` покрывает
|
||||||
|
# `xml.etree.ElementTree.ParseError`. openpyxl в read-only читает лист лениво — ошибки возникают и при итерации строк
|
||||||
|
_XLSX_ERRORS: tuple[type[BaseException], ...] = (
|
||||||
|
zipfile.BadZipFile,
|
||||||
|
InvalidFileException,
|
||||||
|
KeyError,
|
||||||
|
ValueError,
|
||||||
|
IndexError,
|
||||||
|
OSError,
|
||||||
|
EOFError,
|
||||||
|
SyntaxError,
|
||||||
|
zlib.error,
|
||||||
|
)
|
||||||
|
|
||||||
_WHITESPACE_RE = re.compile(r"\s+")
|
_WHITESPACE_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
@ -134,35 +156,50 @@ def read_rows(filename: str, content: bytes) -> list[ImportRow]:
|
|||||||
return _rows_from_table(raw_rows)
|
return _rows_from_table(raw_rows)
|
||||||
|
|
||||||
|
|
||||||
def _xlsx_rows(content: bytes) -> list[tuple[int, list[str]]]:
|
def _xlsx_rows(content: bytes) -> Iterator[tuple[int, list[str]]]:
|
||||||
|
"""Строки первого листа лениво (пустые строки openpyxl отдаёт пустыми кортежами, номер = позиция в листе)."""
|
||||||
|
_check_uncompressed_size(content)
|
||||||
try:
|
try:
|
||||||
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||||
except (zipfile.BadZipFile, InvalidFileException, KeyError, ValueError, OSError, EOFError) as error:
|
except _XLSX_ERRORS as error:
|
||||||
raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error
|
raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error
|
||||||
try:
|
try:
|
||||||
sheet = workbook.worksheets[0]
|
sheet = workbook.worksheets[0]
|
||||||
# Размер листа из файла бывает неверным (экспорт сторонних систем) — читаем все строки фактически
|
# Размер листа из файла бывает неверным (экспорт сторонних систем) — читаем все строки фактически
|
||||||
sheet.reset_dimensions()
|
sheet.reset_dimensions()
|
||||||
return [
|
for number, values in enumerate(sheet.iter_rows(values_only=True), start=1):
|
||||||
(number, [normalize_cell(value) for value in values])
|
yield number, [normalize_cell(value) for value in values]
|
||||||
for number, values in enumerate(sheet.iter_rows(values_only=True), start=1)
|
except _XLSX_ERRORS as error:
|
||||||
]
|
raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error
|
||||||
finally:
|
finally:
|
||||||
workbook.close()
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
def _csv_rows(content: bytes) -> list[tuple[int, list[str]]]:
|
def _check_uncompressed_size(content: bytes) -> None:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||||
|
total = sum(info.file_size for info in archive.infolist())
|
||||||
|
except _XLSX_ERRORS as error:
|
||||||
|
raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error
|
||||||
|
if total > MAX_UNCOMPRESSED_BYTES:
|
||||||
|
raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx слишком велик после распаковки")
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_rows(content: bytes) -> Iterator[tuple[int, list[str]]]:
|
||||||
|
"""Записи csv лениво; номер — физическая строка файла, с которой начинается запись (поля в кавычках бывают
|
||||||
|
многострочными)."""
|
||||||
try:
|
try:
|
||||||
text = content.decode("utf-8-sig")
|
text = content.decode("utf-8-sig")
|
||||||
except UnicodeDecodeError as error:
|
except UnicodeDecodeError as error:
|
||||||
raise FileFormatError(UNSUPPORTED_FILE, "кодировка csv не распознана: ожидается UTF-8") from error
|
raise FileFormatError(UNSUPPORTED_FILE, "кодировка csv не распознана: ожидается UTF-8") from error
|
||||||
header_line = next((line for line in text.splitlines() if line.strip()), "")
|
header_line = next((line for line in text.splitlines() if line.strip()), "")
|
||||||
delimiter = ";" if header_line.count(";") > header_line.count(",") else ","
|
delimiter = ";" if header_line.count(";") > header_line.count(",") else ","
|
||||||
|
reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)
|
||||||
|
start = 1
|
||||||
try:
|
try:
|
||||||
return [
|
for values in reader:
|
||||||
(number, [normalize_cell(value) for value in values])
|
yield start, [normalize_cell(value) for value in values]
|
||||||
for number, values in enumerate(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter), start=1)
|
start = reader.line_num + 1
|
||||||
]
|
|
||||||
except csv.Error as error:
|
except csv.Error as error:
|
||||||
raise FileFormatError(UNSUPPORTED_FILE, "файл csv не читается") from error
|
raise FileFormatError(UNSUPPORTED_FILE, "файл csv не читается") from error
|
||||||
|
|
||||||
@ -170,7 +207,10 @@ def _csv_rows(content: bytes) -> list[tuple[int, list[str]]]:
|
|||||||
def _rows_from_table(raw_rows: Iterable[tuple[int, list[str]]]) -> list[ImportRow]:
|
def _rows_from_table(raw_rows: Iterable[tuple[int, list[str]]]) -> list[ImportRow]:
|
||||||
columns: dict[str, int] | None = None
|
columns: dict[str, int] | None = None
|
||||||
result: list[ImportRow] = []
|
result: list[ImportRow] = []
|
||||||
for number, cells in raw_rows:
|
for count, (number, cells) in enumerate(raw_rows, start=1):
|
||||||
|
# Считаются и пустые строки: лист с одной ячейкой в строке 2 000 000 отвергается, не дочитываясь
|
||||||
|
if count > MAX_IMPORT_ROWS:
|
||||||
|
raise FileFormatError(UNSUPPORTED_FILE, f"слишком много строк: больше {MAX_IMPORT_ROWS}")
|
||||||
if not any(cells):
|
if not any(cells):
|
||||||
continue
|
continue
|
||||||
if columns is None:
|
if columns is None:
|
||||||
|
|||||||
@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.domain import directory_import
|
||||||
from src.domain.directory_import import (
|
from src.domain.directory_import import (
|
||||||
AddMember,
|
AddMember,
|
||||||
CreateEntity,
|
CreateEntity,
|
||||||
@ -216,6 +220,114 @@ def test_read_xlsx_header_in_second_row_numeric_inn_and_trailing_empty_rows() ->
|
|||||||
assert rows[0].gsk_flag == "входит в ГСК"
|
assert rows[0].gsk_flag == "входит в ГСК"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_csv_row_is_file_line_where_record_starts() -> None:
|
||||||
|
"""Поле в кавычках с переводом строки не сдвигает номера: запись после него — со своей строки файла."""
|
||||||
|
content = _csv_bytes(
|
||||||
|
[HEADERS, _data_row("3528052718")[:4] + ["АО\nмногострочное"] + _data_row()[5:], _data_row("3528104476")]
|
||||||
|
)
|
||||||
|
rows = read_rows("export.csv", content)
|
||||||
|
assert [(r.row, r.inn) for r in rows] == [(2, "3528052718"), (4, "3528104476")]
|
||||||
|
assert rows[0].entity_name == "АО\nмногострочное"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- повреждённые и «раздутые» xlsx (T9.1, T9.2)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_xlsx() -> bytes:
|
||||||
|
workbook = openpyxl.Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
assert sheet is not None
|
||||||
|
sheet.append(HEADERS)
|
||||||
|
sheet.append(_data_row())
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_xlsx(content: bytes, member: str, change: Callable[[bytes], bytes]) -> bytes:
|
||||||
|
"""Тот же zip (валидный архив), в котором часть `member` заменена результатом `change`."""
|
||||||
|
source = zipfile.ZipFile(io.BytesIO(content))
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target:
|
||||||
|
for info in source.infolist():
|
||||||
|
data = source.read(info.filename)
|
||||||
|
target.writestr(info.filename, change(data) if info.filename == member else data)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_cell_ref(data: bytes) -> bytes:
|
||||||
|
return data.replace(b'r="A2"', b'r="AAAAA2"')
|
||||||
|
|
||||||
|
|
||||||
|
def _no_sheets(data: bytes) -> bytes:
|
||||||
|
return re.sub(rb"<sheets>.*</sheets>", b"<sheets/>", data, flags=re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("member", "change"),
|
||||||
|
[
|
||||||
|
pytest.param("xl/worksheets/sheet1.xml", lambda data: data[: len(data) // 2], id="лист-обрезан"),
|
||||||
|
pytest.param("xl/worksheets/sheet1.xml", lambda data: b"\x00garbage<<", id="лист-мусор"),
|
||||||
|
pytest.param("xl/workbook.xml", lambda data: b"garbage", id="книга-мусор"),
|
||||||
|
pytest.param("xl/styles.xml", lambda data: b"<styleSheet", id="стили-мусор"),
|
||||||
|
pytest.param("xl/worksheets/sheet1.xml", _bad_cell_ref, id="ссылка-ячейки"),
|
||||||
|
pytest.param("xl/workbook.xml", _no_sheets, id="книга-без-листов"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_read_corrupt_xlsx_parts_is_unsupported(member: str, change: Callable[[bytes], bytes]) -> None:
|
||||||
|
content = _rewrite_xlsx(_valid_xlsx(), member, change)
|
||||||
|
with pytest.raises(FileFormatError) as error:
|
||||||
|
read_rows("export.xlsx", content)
|
||||||
|
assert error.value.code == "UNSUPPORTED_FILE"
|
||||||
|
assert error.value.message == "файл xlsx не читается"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewritten_valid_xlsx_is_still_read() -> None:
|
||||||
|
"""Контроль помощника: перепаковка без изменений даёт читаемый файл."""
|
||||||
|
content = _rewrite_xlsx(_valid_xlsx(), "xl/worksheets/sheet1.xml", lambda data: data)
|
||||||
|
assert [r.inn for r in read_rows("export.xlsx", content)] == ["3528052718"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_xlsx_with_far_row_index_is_rejected_by_row_limit() -> None:
|
||||||
|
"""Одна строка данных с r="2000000": пустые строки до неё считаются, чтение останавливается на пределе."""
|
||||||
|
|
||||||
|
def far_row(data: bytes) -> bytes:
|
||||||
|
data = re.sub(rb'r="([A-Z]+)2"', rb'r="\g<1>2000000"', data)
|
||||||
|
return data.replace(b'<row r="2"', b'<row r="2000000"')
|
||||||
|
|
||||||
|
content = _rewrite_xlsx(_valid_xlsx(), "xl/worksheets/sheet1.xml", far_row)
|
||||||
|
assert b'r="2000000"' in zipfile.ZipFile(io.BytesIO(content)).read("xl/worksheets/sheet1.xml")
|
||||||
|
with pytest.raises(FileFormatError) as error:
|
||||||
|
read_rows("export.xlsx", content)
|
||||||
|
assert error.value.code == "UNSUPPORTED_FILE"
|
||||||
|
assert "слишком много строк" in error.value.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_limit_counts_empty_csv_lines(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(directory_import, "MAX_IMPORT_ROWS", 3)
|
||||||
|
within = _csv_bytes([HEADERS, ["", "", "", "", "", "", "", ""], _data_row()])
|
||||||
|
assert len(read_rows("export.csv", within)) == 1
|
||||||
|
beyond = _csv_bytes([HEADERS, ["", "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", ""], _data_row()])
|
||||||
|
with pytest.raises(FileFormatError) as error:
|
||||||
|
read_rows("export.csv", beyond)
|
||||||
|
assert error.value.code == "UNSUPPORTED_FILE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_uncompressed_size_bomb_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Сумма распакованных размеров частей zip выше предела → отказ до разбора (предел уменьшен для скорости)."""
|
||||||
|
limit = 1024 * 1024
|
||||||
|
monkeypatch.setattr(directory_import, "MAX_UNCOMPRESSED_BYTES", limit)
|
||||||
|
buffer = io.BytesIO(_valid_xlsx())
|
||||||
|
with zipfile.ZipFile(buffer, "a", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("xl/padding.bin", b"\x00" * (limit + 1))
|
||||||
|
content = buffer.getvalue()
|
||||||
|
assert len(content) < limit // 10
|
||||||
|
with pytest.raises(FileFormatError) as error:
|
||||||
|
read_rows("export.xlsx", content)
|
||||||
|
assert error.value.code == "UNSUPPORTED_FILE"
|
||||||
|
assert error.value.message == "файл xlsx слишком велик после распаковки"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- план импорта: помощники
|
# ---------------------------------------------------------------- план импорта: помощники
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user