fix: merge-bomb — потоковый expat-скан всех XML-частей (I-1б), after-read считает только индексируемые диапазоны
R-25. Регулярочный скан `<mergeCell ref>` в xl/worksheets/*.xml обходился файлом < 5 КБ четырьмя способами (лист вне xl/worksheets, ссылка на символ в ref, атрибут a:ref перед ref, «>» в кавычках). Теперь до openpyxl — потоковый разбор xml.parsers.expat (namespace-aware) всех записей zip *.xml и записей, похожих на XML (openpyxl идёт к листу по цели связи и расширение не проверяет): элемент с локальным именем mergeCell в любом пространстве имён (openpyxl берёт дочерние mergeCell по локальному имени), только ref без префикса, площадь по строке ref через CellRange openpyxl, ранний отказ при превышении MERGED_AREA_LIMIT; битый XML части *.xml и неразбираемый ref — UNREADABLE. Стоимость: 12 мс на самом большом реальном xlsx (openpyxl 0,58 с), 0,8 с на 2 млн ячеек (openpyxl 10 с). N1: check_merged_area после чтения считает только диапазоны площадью ≤ grid.MAX_INDEXED_AREA (константа стала публичной) — законный merged на весь столбец в xls больше не отвергается. Спека и README: правила скана, не покрытые риски (объём ячеек, sharedStrings/стили, entity expansion); N2 — слепая зона R-21: достаточно свёрнутого прямого родителя. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
b0a86c9b10
commit
6f579b6403
25
README.md
25
README.md
@ -193,13 +193,30 @@ groups:
|
|||||||
| Константа | Значение | Что ограничивает |
|
| Константа | Значение | Что ограничивает |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
|
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
|
||||||
| `MERGED_AREA_LIMIT` | 200 000 ячеек | суммарная площадь объединённых ячеек книги: xlsx — потоковым сканом `<mergeCell ref>` до openpyxl, любой формат — после чтения, до индекса merged (реальный максимум 10 090) |
|
| `MERGED_AREA_LIMIT` | 200 000 ячеек | суммарная площадь объединённых ячеек книги (реальный максимум 10 090): xlsx — до openpyxl, потоковым разбором expat всех XML-частей архива (см. ниже); любой формат — после чтения, по диапазонам, которые индекс `Sheet` раскладывает по ячейкам (площадь ≤ `grid.MAX_INDEXED_AREA` = 10 000; merged на весь столбец не считается — индекс его не раскладывает) |
|
||||||
|
|
||||||
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
||||||
|
|
||||||
**Известные ограничения.** Чтение не защищено от XML-бомб (entity expansion в частях xlsx): `defusedxml` не подключён —
|
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
|
||||||
новая зависимость требует решения платформы (OQ в `../docs/context/07-open-questions.md`). Других лимитов памяти фазы
|
сканируются все записи zip `*.xml` и записи, начало которых похоже на XML (openpyxl идёт к листу по цели связи в
|
||||||
чтения, кроме перечисленных, нет: книга в пределах лимитов читается библиотекой целиком.
|
`workbook.xml.rels`, каталог и расширение части не проверяет); считается элемент с локальным именем `mergeCell` в любом
|
||||||
|
пространстве имён (openpyxl берёт дочерние `mergeCell` по локальному имени) и только атрибут `ref` без префикса;
|
||||||
|
ссылки на символы (`2`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
|
||||||
|
openpyxl), ячейки не перебираются; отказ — сразу при превышении. Битый XML части `*.xml` и `ref`, который openpyxl не
|
||||||
|
разберёт, — `UNREADABLE`. Стоимость скана: самый большой реальный xlsx (454 КБ распакованно) — 12 мс при чтении
|
||||||
|
openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 МБ распакованно) — 0,8 с при чтении openpyxl 10 с.
|
||||||
|
|
||||||
|
**Известные ограничения.** Лимиты выше покрывают распакованный объём и площадь объединённых ячеек. **Не ограничены**
|
||||||
|
(остаточный риск, OQ-108 в `../docs/context/07-open-questions.md`):
|
||||||
|
|
||||||
|
- бомба по объёму самих ячеек в пределах лимита распакованного zip: xlsx в 5 МБ с 2 млн обычных ячеек читается
|
||||||
|
openpyxl 13 с и занимает 1,5 ГБ (измерено ревьюером);
|
||||||
|
- объём `sharedStrings` и стилей в тех же пределах;
|
||||||
|
- XML-бомбы entity expansion: `defusedxml` не подключён; от экспоненты «billion laughs» защищает только сам expat ≥ 2.4
|
||||||
|
(и в скане, и в openpyxl, который разбирает части тем же expat).
|
||||||
|
|
||||||
|
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы. Книга в
|
||||||
|
пределах лимитов читается библиотекой целиком.
|
||||||
|
|
||||||
## Тесты на реальных данных и golden-снимки
|
## Тесты на реальных данных и golden-снимки
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from decimal import Decimal
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
__all__ = ["EMPTY_CELL", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"]
|
__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"]
|
||||||
|
|
||||||
CellKind = Literal["empty", "text", "number", "date", "bool"]
|
CellKind = Literal["empty", "text", "number", "date", "bool"]
|
||||||
CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
|
CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
|
||||||
@ -19,7 +19,7 @@ CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
|
|||||||
_ALPHABET_SIZE = 26
|
_ALPHABET_SIZE = 26
|
||||||
# Диапазоны больше этой площади не раскладываются в индекс «ячейка → диапазон», а проверяются перебором:
|
# Диапазоны больше этой площади не раскладываются в индекс «ячейка → диапазон», а проверяются перебором:
|
||||||
# merged на весь столбец/лист не должен порождать миллионы записей
|
# merged на весь столбец/лист не должен порождать миллионы записей
|
||||||
_MAX_INDEXED_AREA = 10_000
|
MAX_INDEXED_AREA = 10_000
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@ -115,7 +115,7 @@ class Sheet:
|
|||||||
def _merged_index(self) -> dict[tuple[int, int], MergedRange]:
|
def _merged_index(self) -> dict[tuple[int, int], MergedRange]:
|
||||||
index: dict[tuple[int, int], MergedRange] = {}
|
index: dict[tuple[int, int], MergedRange] = {}
|
||||||
for span in self.merged:
|
for span in self.merged:
|
||||||
if span.area > _MAX_INDEXED_AREA:
|
if span.area > MAX_INDEXED_AREA:
|
||||||
continue
|
continue
|
||||||
for row in range(span.top, span.bottom + 1):
|
for row in range(span.top, span.bottom + 1):
|
||||||
for col in range(span.left, span.right + 1):
|
for col in range(span.left, span.right + 1):
|
||||||
@ -124,7 +124,7 @@ class Sheet:
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def _large_merged(self) -> tuple[MergedRange, ...]:
|
def _large_merged(self) -> tuple[MergedRange, ...]:
|
||||||
return tuple(span for span in self.merged if span.area > _MAX_INDEXED_AREA)
|
return tuple(span for span in self.merged if span.area > MAX_INDEXED_AREA)
|
||||||
|
|
||||||
|
|
||||||
def column_letter(index: int) -> str:
|
def column_letter(index: int) -> str:
|
||||||
|
|||||||
@ -4,9 +4,10 @@ PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве ес
|
|||||||
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
|
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
|
||||||
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
|
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
|
||||||
|
|
||||||
Лимиты фазы чтения (`_limits.py`, ruling R-23) проверяются до библиотеки чтения: распакованный размер zip
|
Лимиты фазы чтения (`_limits.py`, rulings R-23, R-25) проверяются до библиотеки чтения: распакованный размер zip
|
||||||
(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
|
(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
|
||||||
и площадь объединённых ячеек xlsx; после чтения — площадь объединённых ячеек любого формата.
|
и площадь объединённых ячеек xlsx (потоковый скан всех XML-частей); после чтения — площадь объединённых ячеек любого
|
||||||
|
формата (диапазоны, которые индекс `Sheet` раскладывает по ячейкам).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Лимиты фазы чтения (ruling R-23): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
|
"""Лимиты фазы чтения (ruling R-23, R-25): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
|
||||||
|
|
||||||
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
|
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
|
||||||
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
||||||
@ -7,22 +7,43 @@
|
|||||||
1. zip (xlsx/xlsb): сумма объявленных несжатых размеров записей (`ZipInfo.file_size`) — не больше
|
1. zip (xlsx/xlsb): сумма объявленных несжатых размеров записей (`ZipInfo.file_size`) — не больше
|
||||||
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES`. `zipfile` не отдаёт из записи больше объявленного размера, так
|
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES`. `zipfile` не отдаёт из записи больше объявленного размера, так
|
||||||
что объявленная сумма ограничивает и фактическую распаковку (openpyxl и pyxlsb читают через `zipfile`).
|
что объявленная сумма ограничивает и фактическую распаковку (openpyxl и pyxlsb читают через `zipfile`).
|
||||||
2. xlsx: элементы `<mergeCell ref="…">` в `xl/worksheets/*.xml` сканируются потоково, кусками по байтам (без
|
2. xlsx: потоковый скан `xml.parsers.expat` всех XML-частей архива (R-25); суммарная площадь объединённых диапазонов
|
||||||
XML-парсера и без загрузки части целиком); суммарная площадь диапазонов книги — не больше `MERGED_AREA_LIMIT`.
|
книги — не больше `MERGED_AREA_LIMIT`, отказ — сразу при превышении, не дочитывая часть. Правила скана повторяют то,
|
||||||
3. После чтения любого формата — та же сумма площадей по `Sheet.merged`: индекс `Sheet` раскладывает диапазоны по
|
что разложит openpyxl (он разбирает листы тем же expat через `xml.etree`), с запасом в сторону отказа:
|
||||||
ячейкам, а xls хранит диапазон в 8 байтах (тысячи средних диапазонов дали бы десятки миллионов записей индекса).
|
- части: каждая запись zip с именем `*.xml` и каждая запись, чьё начало похоже на XML (`<` после BOM/пробелов,
|
||||||
|
UTF-16): openpyxl идёт к листу по цели связи в `workbook.xml.rels` и ни каталог, ни расширение части не проверяет;
|
||||||
|
порядок — по имени (детерминированно);
|
||||||
|
- элемент: локальное имя `mergeCell` в ЛЮБОМ пространстве имён (и без него). openpyxl находит контейнер
|
||||||
|
`mergeCells` только в основном пространстве SpreadsheetML, но дочерние `mergeCell` берёт по локальному имени
|
||||||
|
(`Serialisable.from_tree`) — ограничение основным пространством было бы обходом; лишний счёт элемента вне листа
|
||||||
|
безопасен (лишь строже);
|
||||||
|
- атрибут: только `ref` без префикса (атрибуты с префиксом openpyxl отбрасывает); ссылки на символы (`2`) и
|
||||||
|
внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
|
||||||
|
- площадь — по строке `ref` (`CellRange` openpyxl, те же правила разбора), ячейки не перебираются: диапазон
|
||||||
|
`A1:XFD1048576` стоит один шаг; ref, который openpyxl не разберёт (или его нет), — `UnreadableError`;
|
||||||
|
- битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части, лишь
|
||||||
|
похожей на XML (VML, неизвестные данные), — конец скана этой части без отказа: openpyxl её либо не разбирает,
|
||||||
|
либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора всего листа).
|
||||||
|
Память скана ограничена: части читаются кусками `_SCAN_CHUNK_BYTES`, expat держит только незаконченный токен;
|
||||||
|
общий объём — не больше лимита распакованного zip (проверен до скана).
|
||||||
|
3. После чтения любого формата — сумма площадей `Sheet.merged`, которые индекс `Sheet` разложит по ячейкам (площадь
|
||||||
|
не больше `grid.MAX_INDEXED_AREA`): xls хранит диапазон в 8 байтах, тысячи средних диапазонов дали бы десятки
|
||||||
|
миллионов записей индекса. Диапазоны больше порога (merged на весь столбец) индекс не раскладывает — их не считаем.
|
||||||
|
|
||||||
Защиты от XML-бомб (entity expansion, `defusedxml`) здесь нет: новая зависимость — решение платформы (README,
|
Защиты от XML-бомб (entity expansion, `defusedxml`) и от объёма самих ячеек в пределах лимита zip здесь нет: решение
|
||||||
«Известные ограничения»).
|
платформы (README, «Известные ограничения»). Скан раскрывает внутренние сущности так же, как openpyxl; от экспоненты
|
||||||
|
«billion laughs» защищает сам expat ≥ 2.4 (ошибка разбора → `UnreadableError` для частей `*.xml`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import IO
|
from typing import IO
|
||||||
|
from xml.parsers import expat
|
||||||
|
|
||||||
from src.parser.errors import UnsupportedFormatError
|
from openpyxl.worksheet.cell_range import CellRange
|
||||||
from src.parser.grid import Sheet
|
|
||||||
|
from src.parser.errors import UnreadableError, UnsupportedFormatError
|
||||||
|
from src.parser.grid import MAX_INDEXED_AREA, Sheet
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MERGED_AREA_LIMIT",
|
"MERGED_AREA_LIMIT",
|
||||||
@ -39,13 +60,16 @@ ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
|
|||||||
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
||||||
MERGED_AREA_LIMIT = 200_000
|
MERGED_AREA_LIMIT = 200_000
|
||||||
_SCAN_CHUNK_BYTES = 64 * 1024
|
_SCAN_CHUNK_BYTES = 64 * 1024
|
||||||
# Элемент mergeCell длиннее этого — отказ: раздутый атрибут не должен прятать диапазон от сканера
|
_MERGE_LOCAL_NAME = "mergeCell"
|
||||||
_MERGE_TAG_MAX_BYTES = 64 * 1024
|
_REF_ATTRIBUTE = "ref" # без префикса: с namespace_separator expat отдаёт атрибут с префиксом как «uri<sep>имя»
|
||||||
_WORKSHEET_PART = re.compile(r"^xl/worksheets/[^/]+\.xml$")
|
# Разделитель «пространство имён — локальное имя»: в XML-имени не встречается, локальное имя — после последнего
|
||||||
_MERGE_TAG_START = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b")
|
_NS_SEPARATOR = " "
|
||||||
_MERGE_TAG = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b([^<>]*)>")
|
_XML_SUFFIX = ".xml"
|
||||||
_REF = re.compile(rb"""\bref\s*=\s*["']\s*\$?([A-Za-z]{1,3})\$?(\d{1,7})(?::\$?([A-Za-z]{1,3})\$?(\d{1,7}))?\s*["']""")
|
# Начало части «похоже на XML»: после BOM (UTF-8, UTF-16), нулевых байтов UTF-16 и пробелов — «<»
|
||||||
_ALPHABET_SIZE = 26
|
_SNIFF_BYTES = 64
|
||||||
|
_SNIFF_SKIPPED = b"\xef\xbb\xbf\xff\xfe\x00 \t\r\n"
|
||||||
|
_XML_START = b"<"
|
||||||
|
_REF_IN_MESSAGE_CHARS = 40
|
||||||
|
|
||||||
|
|
||||||
def _merged_limit_error(limit: int) -> UnsupportedFormatError:
|
def _merged_limit_error(limit: int) -> UnsupportedFormatError:
|
||||||
@ -65,70 +89,78 @@ def check_zip_uncompressed_size(archive: zipfile.ZipFile, limit: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _column_index(letters: bytes) -> int:
|
def _ref_area(ref: str | None) -> int:
|
||||||
index = 0
|
"""Площадь диапазона `ref` элемента mergeCell по правилам openpyxl (`CellRange`); не разбирается — книга не
|
||||||
for letter in letters.upper():
|
читается (openpyxl упал бы на том же ref)."""
|
||||||
index = index * _ALPHABET_SIZE + letter - ord("A") + 1
|
try:
|
||||||
return index
|
if ref is None:
|
||||||
|
raise ValueError("нет атрибута ref")
|
||||||
|
span = CellRange(ref)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
shown = (ref or "")[:_REF_IN_MESSAGE_CHARS]
|
||||||
|
raise UnreadableError(f"диапазон объединения не читается: <mergeCell ref={shown!r}>") from exc
|
||||||
|
size: dict[str, int] = span.size
|
||||||
|
return size["rows"] * size["columns"]
|
||||||
|
|
||||||
|
|
||||||
def _ref_area(attributes: bytes) -> int:
|
def merged_area_of_xml(
|
||||||
"""Площадь диапазона из атрибутов элемента mergeCell; нераспознанный `ref` — 0 (его отвергнет библиотека)."""
|
stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0, strict: bool = True
|
||||||
found = _REF.search(attributes)
|
) -> int:
|
||||||
if found is None:
|
"""Площадь `start` + диапазонов `mergeCell` потока XML; больше `limit` — `UnsupportedFormatError` сразу, не
|
||||||
return 0
|
дочитывая поток.
|
||||||
first_col, first_row, last_col, last_row = found.groups()
|
|
||||||
last_col = last_col or first_col
|
|
||||||
last_row = last_row or first_row
|
|
||||||
rows = abs(int(last_row) - int(first_row)) + 1
|
|
||||||
cols = abs(_column_index(last_col) - _column_index(first_col)) + 1
|
|
||||||
return rows * cols
|
|
||||||
|
|
||||||
|
Поток читается кусками и подаётся expat с разбором пространств имён (правила — в docstring модуля). Битый XML:
|
||||||
def merged_area_of_xml(stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0) -> int:
|
`strict` — `UnreadableError`, иначе скан части заканчивается, найденная до ошибки площадь остаётся в сумме.
|
||||||
"""Площадь `start` + диапазонов `<mergeCell ref>` потока XML листа; больше `limit` — `UnsupportedFormatError`
|
|
||||||
сразу, не дочитывая поток.
|
|
||||||
|
|
||||||
Поток читается кусками; незакрытый тег на границе куска переносится в следующий. В XML знак «<» вне разметки
|
|
||||||
экранируется, поэтому последний «<» без «>» после него — начало незаконченного тега.
|
|
||||||
"""
|
"""
|
||||||
area = start
|
area = start
|
||||||
tail = b""
|
|
||||||
while chunk := stream.read(chunk_size):
|
def on_start(name: str, attributes: dict[str, str]) -> None:
|
||||||
buffer = tail + chunk
|
nonlocal area
|
||||||
cut = buffer.rfind(b"<")
|
if name.rpartition(_NS_SEPARATOR)[2] != _MERGE_LOCAL_NAME:
|
||||||
if cut != -1 and buffer.find(b">", cut) == -1:
|
return
|
||||||
body, tail = buffer[:cut], buffer[cut:]
|
area += _ref_area(attributes.get(_REF_ATTRIBUTE))
|
||||||
else:
|
|
||||||
body, tail = buffer, b""
|
|
||||||
for found in _MERGE_TAG.finditer(body):
|
|
||||||
area += _ref_area(found.group(1))
|
|
||||||
if area > limit:
|
if area > limit:
|
||||||
raise _merged_limit_error(limit)
|
raise _merged_limit_error(limit)
|
||||||
if len(tail) > _MERGE_TAG_MAX_BYTES:
|
|
||||||
if _MERGE_TAG_START.match(tail):
|
parser = expat.ParserCreate(namespace_separator=_NS_SEPARATOR)
|
||||||
raise UnsupportedFormatError(
|
parser.StartElementHandler = on_start
|
||||||
f"превышен лимит объединённых ячеек: элемент mergeCell длиннее {_MERGE_TAG_MAX_BYTES} байт: "
|
try:
|
||||||
"книга не открывается"
|
while chunk := stream.read(chunk_size):
|
||||||
)
|
parser.Parse(chunk, False)
|
||||||
tail = b"" # длинный тег другого элемента: его продолжение без «<» не содержит mergeCell
|
parser.Parse(b"", True)
|
||||||
|
except expat.ExpatError as exc:
|
||||||
|
if strict:
|
||||||
|
raise UnreadableError(f"часть книги — не корректный XML: {exc}") from exc
|
||||||
return area
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_xml(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bool:
|
||||||
|
if info.filename.lower().endswith(_XML_SUFFIX):
|
||||||
|
return True
|
||||||
|
with archive.open(info) as stream:
|
||||||
|
head = stream.read(_SNIFF_BYTES)
|
||||||
|
return head.lstrip(_SNIFF_SKIPPED).startswith(_XML_START)
|
||||||
|
|
||||||
|
|
||||||
def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
|
def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
|
||||||
"""Суммарная площадь объединённых ячеек всех листов xlsx — не больше `limit` (до вызова openpyxl)."""
|
"""Суммарная площадь объединённых ячеек всех XML-частей xlsx — не больше `limit` (до вызова openpyxl)."""
|
||||||
area = 0
|
area = 0
|
||||||
for name in archive.namelist():
|
for info in sorted(archive.infolist(), key=lambda entry: entry.filename):
|
||||||
if _WORKSHEET_PART.match(name):
|
if info.is_dir() or not _looks_like_xml(archive, info):
|
||||||
with archive.open(name) as stream:
|
continue
|
||||||
area = merged_area_of_xml(stream, limit=limit, start=area)
|
strict = info.filename.lower().endswith(_XML_SUFFIX)
|
||||||
|
with archive.open(info) as stream:
|
||||||
|
area = merged_area_of_xml(stream, limit=limit, start=area, strict=strict)
|
||||||
|
|
||||||
|
|
||||||
def check_merged_area(sheets: Iterable[Sheet], limit: int = MERGED_AREA_LIMIT) -> None:
|
def check_merged_area(sheets: Iterable[Sheet], limit: int = MERGED_AREA_LIMIT) -> None:
|
||||||
"""Суммарная площадь `Sheet.merged` прочитанной книги — не больше `limit` (до построения индекса merged)."""
|
"""Суммарная площадь диапазонов `Sheet.merged` прочитанной книги, которые индекс `Sheet` разложит по ячейкам
|
||||||
|
(не больше `MAX_INDEXED_AREA`), — не больше `limit` (до построения индекса merged)."""
|
||||||
area = 0
|
area = 0
|
||||||
for sheet in sheets:
|
for sheet in sheets:
|
||||||
for span in sheet.merged:
|
for span in sheet.merged:
|
||||||
|
if span.area > MAX_INDEXED_AREA:
|
||||||
|
continue # индекс его не раскладывает: поиск перебором `Sheet._large_merged`
|
||||||
area += span.area
|
area += span.area
|
||||||
if area > limit:
|
if area > limit:
|
||||||
raise _merged_limit_error(limit)
|
raise _merged_limit_error(limit)
|
||||||
|
|||||||
@ -7,21 +7,22 @@
|
|||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.contract import ErrorCode, ResultStatus
|
from src.contract import ErrorCode, ResultStatus
|
||||||
from src.parser.errors import UnsupportedFormatError
|
from src.parser.errors import UnreadableError, UnsupportedFormatError
|
||||||
from src.parser.grid import MergedRange, Sheet
|
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
|
||||||
from src.parser.osv import OsvParser
|
from src.parser.osv import OsvParser
|
||||||
from src.parser.readers import _READERS, read_book
|
from src.parser.readers import _READERS, read_book
|
||||||
from src.parser.readers._limits import (
|
from src.parser.readers._limits import (
|
||||||
MERGED_AREA_LIMIT,
|
MERGED_AREA_LIMIT,
|
||||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
||||||
check_merged_area,
|
check_merged_area,
|
||||||
|
check_xlsx_merged_area,
|
||||||
merged_area_of_xml,
|
merged_area_of_xml,
|
||||||
)
|
)
|
||||||
from src.parser.rules import Rules, load_rules
|
from src.parser.rules import Rules, load_rules
|
||||||
@ -31,6 +32,15 @@ SHEET_PART = "xl/worksheets/sheet1.xml"
|
|||||||
ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
|
ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
|
||||||
BOMB_MAX_FILE_BYTES = 10_000 # бомба — несколько килобайт файла
|
BOMB_MAX_FILE_BYTES = 10_000 # бомба — несколько килобайт файла
|
||||||
SMALL_MAX_BYTES = 100_000 # лимит размера файла в тесте: распакованный лимит = 10 × 100 000 байт
|
SMALL_MAX_BYTES = 100_000 # лимит размера файла в тесте: распакованный лимит = 10 × 100 000 байт
|
||||||
|
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
_PACKAGE_RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||||
|
_OFFICE_RELS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||||
|
_STYLES = (
|
||||||
|
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||||
|
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border/></borders>'
|
||||||
|
'<cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="1"><xf/></cellXfs>'
|
||||||
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@ -76,13 +86,21 @@ def _xlsx_with_zeros(size: int) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> None:
|
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||||
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки)."""
|
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки).
|
||||||
|
|
||||||
|
Вызов не только падает, но и запоминается: `read_book` превращает любое исключение читателя в `UnreadableError`,
|
||||||
|
и тест на UNREADABLE без проверки после теста прошёл бы и при вызове openpyxl.
|
||||||
|
"""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
def forbidden(*_args: Any, **_kwargs: Any) -> None:
|
def forbidden(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
calls.append("load_workbook")
|
||||||
raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
|
raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
|
||||||
|
|
||||||
monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
|
monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
|
||||||
|
yield
|
||||||
|
assert calls == [], "openpyxl.load_workbook вызван: лимит должен сработать до чтения книги"
|
||||||
|
|
||||||
|
|
||||||
# --- Площадь объединённых ячеек (xlsx) ---
|
# --- Площадь объединённых ячеек (xlsx) ---
|
||||||
@ -113,13 +131,6 @@ def test_many_small_merged_ranges_are_summed() -> None:
|
|||||||
read_book(_xlsx_with_merges(*refs), "XLSX")
|
read_book(_xlsx_with_merges(*refs), "XLSX")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
|
||||||
def test_prefixed_merge_element_is_counted() -> None:
|
|
||||||
"""Пространство имён с префиксом (`<x:mergeCell …>`) — тот же элемент; openpyxl его прочитал бы."""
|
|
||||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
|
||||||
read_book(_xlsx_with_merges("A1:Z200000", tag="x:mergeCell"), "XLSX")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
|
def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
|
||||||
outcome = OsvParser(rules, max_bytes=50 * 1024 * 1024, timeout_seconds=120).parse(
|
outcome = OsvParser(rules, max_bytes=50 * 1024 * 1024, timeout_seconds=120).parse(
|
||||||
@ -140,10 +151,10 @@ def test_normal_merges_are_read() -> None:
|
|||||||
@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
|
@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
|
||||||
def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
|
def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
|
||||||
xml = (
|
xml = (
|
||||||
b'<worksheet><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>'
|
b'<worksheet xmlns="%s" xmlns:x="%s"><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>'
|
||||||
b'<mergeCells count="4"><mergeCell ref="A1:B2"/><mergeCell ref="C3"/>'
|
b'<mergeCells count="4"><mergeCell ref="A1:B2"/><mergeCell ref="C3"/>'
|
||||||
b'<x:mergeCell ref = "$D$1:$D$10" /><mergeCell ref="AA10:AB11"/></mergeCells></worksheet>'
|
b'<x:mergeCell ref = "$D$1:$D$10" /><mergeCell ref="AA10:AB11"/></mergeCells></worksheet>'
|
||||||
)
|
) % (MAIN_NS.encode(), MAIN_NS.encode())
|
||||||
expected = 4 + 1 + 10 + 4
|
expected = 4 + 1 + 10 + 4
|
||||||
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=chunk_size) == expected
|
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=chunk_size) == expected
|
||||||
|
|
||||||
@ -158,7 +169,7 @@ def test_scan_limit_is_inclusive() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_scan_ignores_merge_cells_container_and_other_elements() -> None:
|
def test_scan_ignores_merge_cells_container_and_other_elements() -> None:
|
||||||
xml = b'<mergeCells count="999999"/><mergeCellsX ref="A1:Z99999"/><c r="A1:Z99999"/>'
|
xml = b'<worksheet><mergeCells count="999999"/><mergeCellsX ref="A1:Z99999"/><c r="A1:Z99999"/></worksheet>'
|
||||||
|
|
||||||
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=5) == 0
|
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=5) == 0
|
||||||
|
|
||||||
@ -171,6 +182,198 @@ def test_overlong_merge_element_is_rejected() -> None:
|
|||||||
merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=4096)
|
merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=4096)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Обходы скана (повторное ревью I-1б): книги собираются вручную, `zipfile`, без openpyxl ---
|
||||||
|
|
||||||
|
BOMB_REF = "A1:Z200000" # 5,2 млн ячеек
|
||||||
|
SMALL_REF = "A1:B2"
|
||||||
|
SMALL_SPAN = MergedRange(1, 1, 2, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_xlsx(merge_cells: str, *, sheet_part: str = SHEET_PART, extra: dict[str, bytes] | None = None) -> bytes:
|
||||||
|
"""Минимальная книга xlsx из одного листа: `merge_cells` вставляется после `sheetData`, лист лежит в `sheet_part`
|
||||||
|
(цель связи в `xl/_rels/workbook.xml.rels` — openpyxl идёт по ней, имя части не проверяет)."""
|
||||||
|
target = sheet_part.removeprefix("xl/")
|
||||||
|
parts = {
|
||||||
|
"[Content_Types].xml": (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
'<Override PartName="/xl/workbook.xml" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>'
|
||||||
|
),
|
||||||
|
"_rels/.rels": (
|
||||||
|
f'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="{_PACKAGE_RELS_NS}">'
|
||||||
|
f'<Relationship Id="rId1" Type="{_OFFICE_RELS_NS}/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
"</Relationships>"
|
||||||
|
),
|
||||||
|
"xl/workbook.xml": (
|
||||||
|
f'<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="{MAIN_NS}" xmlns:r="{_OFFICE_RELS_NS}">'
|
||||||
|
'<sheets><sheet name="ОСВ" sheetId="1" r:id="rId1"/></sheets></workbook>'
|
||||||
|
),
|
||||||
|
"xl/_rels/workbook.xml.rels": (
|
||||||
|
f'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="{_PACKAGE_RELS_NS}">'
|
||||||
|
f'<Relationship Id="rId1" Type="{_OFFICE_RELS_NS}/worksheet" Target="{target}"/>'
|
||||||
|
f'<Relationship Id="rId2" Type="{_OFFICE_RELS_NS}/styles" Target="styles.xml"/></Relationships>'
|
||||||
|
),
|
||||||
|
"xl/styles.xml": f'<?xml version="1.0" encoding="UTF-8"?><styleSheet xmlns="{MAIN_NS}">{_STYLES}</styleSheet>',
|
||||||
|
sheet_part: (
|
||||||
|
f'<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="{MAIN_NS}"><sheetData><row r="1">'
|
||||||
|
'<c r="A1" t="inlineStr"><is><t>Оборотно-сальдовая ведомость по счету 60</t></is></c></row></sheetData>'
|
||||||
|
f"{merge_cells}</worksheet>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
out = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, content in parts.items():
|
||||||
|
archive.writestr(name, content)
|
||||||
|
for name, raw in (extra or {}).items():
|
||||||
|
archive.writestr(name, raw)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _merges(element: str) -> str:
|
||||||
|
return f'<mergeCells count="1">{element}</mergeCells>'
|
||||||
|
|
||||||
|
|
||||||
|
# Вариант → (элемент mergeCell с диапазоном SMALL_REF, тот же с BOMB_REF, часть листа)
|
||||||
|
_BYPASSES: dict[str, tuple[str, str, str]] = {
|
||||||
|
"исходная бомба": (f'<mergeCell ref="{SMALL_REF}"/>', f'<mergeCell ref="{BOMB_REF}"/>', SHEET_PART),
|
||||||
|
"лист вне xl/worksheets": (f'<mergeCell ref="{SMALL_REF}"/>', f'<mergeCell ref="{BOMB_REF}"/>', "xl/data/s.xml"),
|
||||||
|
"лист без расширения .xml": (f'<mergeCell ref="{SMALL_REF}"/>', f'<mergeCell ref="{BOMB_REF}"/>', "xl/data/s.dat"),
|
||||||
|
"десятичная ссылка на символ": ('<mergeCell ref="A1:B2"/>', '<mergeCell ref="A1:Z200000"/>', SHEET_PART),
|
||||||
|
"шестнадцатеричная ссылка на символ": (
|
||||||
|
'<mergeCell ref="A1:B2"/>',
|
||||||
|
'<mergeCell ref="A1:Z200000"/>',
|
||||||
|
SHEET_PART,
|
||||||
|
),
|
||||||
|
"атрибут a:ref перед ref": (
|
||||||
|
f'<mergeCell xmlns:a="urn:a" a:ref="A1" ref="{SMALL_REF}"/>',
|
||||||
|
f'<mergeCell xmlns:a="urn:a" a:ref="A1" ref="{BOMB_REF}"/>',
|
||||||
|
SHEET_PART,
|
||||||
|
),
|
||||||
|
"«>» в значении атрибута": (
|
||||||
|
f'<mergeCell xmlns:a="urn:a" a:foo=">" ref="{SMALL_REF}"/>',
|
||||||
|
f'<mergeCell xmlns:a="urn:a" a:foo=">" ref="{BOMB_REF}"/>',
|
||||||
|
SHEET_PART,
|
||||||
|
),
|
||||||
|
"элемент с префиксом основного пространства": (
|
||||||
|
f'<x:mergeCell xmlns:x="{MAIN_NS}" ref="{SMALL_REF}"/>',
|
||||||
|
f'<x:mergeCell xmlns:x="{MAIN_NS}" ref="{BOMB_REF}"/>',
|
||||||
|
SHEET_PART,
|
||||||
|
),
|
||||||
|
"элемент чужого пространства имён": (
|
||||||
|
f'<f:mergeCell xmlns:f="urn:foreign" ref="{SMALL_REF}"/>',
|
||||||
|
f'<f:mergeCell xmlns:f="urn:foreign" ref="{BOMB_REF}"/>',
|
||||||
|
SHEET_PART,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("variant", list(_BYPASSES))
|
||||||
|
def test_bypass_variant_is_honoured_by_openpyxl(variant: str) -> None:
|
||||||
|
"""Каждый вариант — не выдумка: openpyxl на малом диапазоне действительно читает объединение (значит, на
|
||||||
|
большом разложил бы его по ячейкам), а скан пропускает книгу в пределах лимита и видит площадь диапазона."""
|
||||||
|
small, _, part = _BYPASSES[variant]
|
||||||
|
data = _hand_xlsx(_merges(small), sheet_part=part)
|
||||||
|
|
||||||
|
sheets, _ = read_book(data, "XLSX")
|
||||||
|
|
||||||
|
assert sheets[0].merged == (SMALL_SPAN,)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
|
||||||
|
check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
|
@pytest.mark.parametrize("variant", list(_BYPASSES))
|
||||||
|
def test_bypass_variant_bomb_is_rejected_before_openpyxl(variant: str) -> None:
|
||||||
|
_, bomb, part = _BYPASSES[variant]
|
||||||
|
data = _hand_xlsx(_merges(bomb), sheet_part=part)
|
||||||
|
assert len(data) < BOMB_MAX_FILE_BYTES
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
with pytest.raises(UnsupportedFormatError) as caught:
|
||||||
|
read_book(data, "XLSX")
|
||||||
|
|
||||||
|
assert time.monotonic() - started < FAST_SECONDS
|
||||||
|
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||||
|
assert "лимит объединённых ячеек" in caught.value.message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
|
def test_whole_sheet_merge_costs_one_step() -> None:
|
||||||
|
"""Площадь считается по строке ref, ячейки не перебираются: диапазон на весь лист отвергается сразу."""
|
||||||
|
started = time.monotonic()
|
||||||
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
|
read_book(_hand_xlsx(_merges('<mergeCell ref="A1:XFD1048576"/>')), "XLSX")
|
||||||
|
|
||||||
|
assert time.monotonic() - started < FAST_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprefixed_ref_only_is_counted() -> None:
|
||||||
|
"""Считается только атрибут `ref` без префикса (его читает openpyxl); `a:ref` с бомбой — чужой атрибут."""
|
||||||
|
xml = (
|
||||||
|
b'<worksheet xmlns="%s" xmlns:a="urn:a"><mergeCells><mergeCell a:ref="A1:Z200000" ref="A1:B2"/>'
|
||||||
|
b"</mergeCells></worksheet>"
|
||||||
|
) % MAIN_NS.encode()
|
||||||
|
|
||||||
|
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT) == SMALL_SPAN.area
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_stops_reading_as_soon_as_limit_is_exceeded() -> None:
|
||||||
|
"""Ранний отказ: поток после бомбы не дочитывается (и даже битый XML за ней не важен)."""
|
||||||
|
head = b'<worksheet><mergeCells><mergeCell ref="A1:Z200000"/>'
|
||||||
|
stream = io.BytesIO(head + b"x" * 1_000_000 + b"<<<")
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
|
merged_area_of_xml(stream, limit=MERGED_AREA_LIMIT, chunk_size=len(head))
|
||||||
|
|
||||||
|
assert stream.tell() < 2 * len(head)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
|
def test_malformed_xml_part_is_unreadable() -> None:
|
||||||
|
data = _hand_xlsx(_merges('<mergeCell ref="A1:B2">')) # элемент не закрыт
|
||||||
|
|
||||||
|
with pytest.raises(UnreadableError) as caught:
|
||||||
|
read_book(data, "XLSX")
|
||||||
|
|
||||||
|
assert caught.value.code is ErrorCode.UNREADABLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
|
@pytest.mark.parametrize("element", ['<mergeCell ref="A1:"/>', '<mergeCell ref="A:B"/>', "<mergeCell/>"])
|
||||||
|
def test_unparseable_merge_ref_is_unreadable(element: str) -> None:
|
||||||
|
"""ref, который openpyxl не разберёт (или его нет), — книга не читается, как и у openpyxl."""
|
||||||
|
with pytest.raises(UnreadableError, match="mergeCell"):
|
||||||
|
read_book(_hand_xlsx(_merges(element)), "XLSX")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
|
||||||
|
"""Части без расширения .xml сканируются, только если похожи на XML; бинарные (картинки, printerSettings) и
|
||||||
|
не вполне корректный VML книгу не ломают: openpyxl их не разбирает."""
|
||||||
|
extra = {
|
||||||
|
"xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
|
||||||
|
"xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
|
||||||
|
"xl/drawings/vmlDrawing1.vml": b"<xml><v:shape><br></xml>",
|
||||||
|
}
|
||||||
|
|
||||||
|
sheets, _ = read_book(_hand_xlsx(_merges(f'<mergeCell ref="{SMALL_REF}"/>'), extra=extra), "XLSX")
|
||||||
|
|
||||||
|
assert sheets[0].merged == (SMALL_SPAN,)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
|
def test_utf16_part_is_scanned() -> None:
|
||||||
|
"""Кодировка части не прячет диапазон: expat сам определяет UTF-16 по BOM."""
|
||||||
|
bomb = _merges(f'<mergeCell ref="{BOMB_REF}"/>')
|
||||||
|
sheet = f'<?xml version="1.0" encoding="UTF-16"?><worksheet xmlns="{MAIN_NS}"><sheetData/>{bomb}</worksheet>'
|
||||||
|
data = _hand_xlsx("", extra={"xl/data/u.bin": sheet.encode("utf-16")})
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
|
read_book(data, "XLSX")
|
||||||
|
|
||||||
|
|
||||||
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
||||||
|
|
||||||
|
|
||||||
@ -178,25 +381,51 @@ def _sheet_with(*spans: MergedRange) -> Sheet:
|
|||||||
return Sheet(name="Лист", cells={}, merged=spans, row_outline={}, n_rows=1, n_cols=1)
|
return Sheet(name="Лист", cells={}, merged=spans, row_outline={}, n_rows=1, n_cols=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _indexable_spans(count: int) -> tuple[MergedRange, ...]:
|
||||||
|
"""`count` диапазонов площадью ровно `MAX_INDEXED_AREA` (столбец за столбцом): каждый индекс раскладывает."""
|
||||||
|
return tuple(MergedRange(1, col, MAX_INDEXED_AREA, col) for col in range(1, count + 1))
|
||||||
|
|
||||||
|
|
||||||
def test_merged_area_of_read_sheets_is_limited() -> None:
|
def test_merged_area_of_read_sheets_is_limited() -> None:
|
||||||
"""xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
|
"""xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
|
||||||
тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
|
тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
|
||||||
"""
|
"""
|
||||||
half = MergedRange(1, 1, MERGED_AREA_LIMIT // 2, 1)
|
half = _indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA // 2)
|
||||||
check_merged_area([_sheet_with(half), _sheet_with(half)], limit=MERGED_AREA_LIMIT)
|
check_merged_area([_sheet_with(*half), _sheet_with(*half)], limit=MERGED_AREA_LIMIT)
|
||||||
|
|
||||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
check_merged_area([_sheet_with(half), _sheet_with(half, MergedRange(1, 2, 1, 2))], limit=MERGED_AREA_LIMIT)
|
check_merged_area([_sheet_with(*half), _sheet_with(*half, MergedRange(1, 99, 1, 99))], limit=MERGED_AREA_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spans_not_indexed_by_sheet_are_not_counted_after_read() -> None:
|
||||||
|
"""Диапазон больше `MAX_INDEXED_AREA` (merged на весь столбец, на весь лист) `Sheet` по ячейкам не раскладывает —
|
||||||
|
риска нет, и законная книга xls с таким диапазоном не отвергается (находка N1)."""
|
||||||
|
whole_column = MergedRange(1, 1, 1_048_576, 1)
|
||||||
|
whole_sheet = MergedRange(1, 1, 1_048_576, 16_384)
|
||||||
|
just_above = MergedRange(1, 1, MAX_INDEXED_AREA + 1, 1)
|
||||||
|
|
||||||
|
check_merged_area([_sheet_with(whole_column, whole_sheet, just_above)], limit=MERGED_AREA_LIMIT)
|
||||||
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
|
check_merged_area([_sheet_with(whole_column, *_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))])
|
||||||
|
|
||||||
|
|
||||||
def test_xls_reader_result_is_checked(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_xls_reader_result_is_checked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
huge = _sheet_with(MergedRange(1, 1, MERGED_AREA_LIMIT + 1, 1))
|
many = _sheet_with(*_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))
|
||||||
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (huge,)))
|
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
|
||||||
|
|
||||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||||
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
|
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_xls_with_whole_column_merge_is_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
sheet = _sheet_with(MergedRange(1, 1, 1_048_576, 1))
|
||||||
|
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (sheet,)))
|
||||||
|
|
||||||
|
sheets, _ = read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
|
||||||
|
|
||||||
|
assert sheets == (sheet,)
|
||||||
|
|
||||||
|
|
||||||
# --- Распакованный размер zip ---
|
# --- Распакованный размер zip ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -179,8 +179,9 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
|
|||||||
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
|
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
|
||||||
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
|
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
|
||||||
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
|
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
|
||||||
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а все его
|
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
|
||||||
предки до корня свёрнуты — не ловится ничем (тождество строки, нетто родителей и «Итого = корень» сохраняются).
|
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
|
||||||
|
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
|
||||||
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
|
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
|
||||||
(закреплено тестом как осознанное поведение R-21).
|
(закреплено тестом как осознанное поведение R-21).
|
||||||
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
|
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
|
||||||
@ -217,12 +218,18 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
|
|||||||
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
|
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
|
||||||
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
|
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
|
||||||
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
|
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
|
||||||
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек; для xlsx — потоковым сканом
|
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
|
||||||
`<mergeCell ref>` до openpyxl, для любого формата — после чтения). Книга не открывается → `UNREADABLE` (лог WARNING);
|
потоковым разбором `xml.parsers.expat` всех XML-частей архива (R-25: записи `*.xml` и записи, начало которых похоже на
|
||||||
|
XML, — в любом каталоге; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
|
||||||
|
префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
|
||||||
|
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
|
||||||
|
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается). Книга не открывается (в том числе битый XML части
|
||||||
|
`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
|
||||||
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
|
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
|
||||||
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
|
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
|
||||||
непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Защиты от XML-бомб
|
непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
|
||||||
(`defusedxml`) нет — новая зависимость требует решения платформы. Любой файл
|
риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в
|
||||||
|
пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
|
||||||
`fixtures/osv/**` SHALL разбираться быстрее 5 с.
|
`fixtures/osv/**` SHALL разбираться быстрее 5 с.
|
||||||
|
|
||||||
#### Scenario: XLSB
|
#### Scenario: XLSB
|
||||||
@ -242,6 +249,17 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
|
|||||||
- **WHEN** передан xlsx в несколько килобайт с `<mergeCell ref="A1:Z200000"/>` (5,2 млн ячеек)
|
- **WHEN** передан xlsx в несколько килобайт с `<mergeCell ref="A1:Z200000"/>` (5,2 млн ячеек)
|
||||||
- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
|
- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
|
||||||
|
|
||||||
|
#### Scenario: Обходы скана объединённых ячеек
|
||||||
|
- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в
|
||||||
|
`workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `ref` стоит атрибут
|
||||||
|
с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`)
|
||||||
|
- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с
|
||||||
|
малым диапазоном читается, и объединение видно в листе
|
||||||
|
|
||||||
|
#### Scenario: Объединение на весь столбец в xls
|
||||||
|
- **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`)
|
||||||
|
- **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает)
|
||||||
|
|
||||||
#### Scenario: Распакованный zip больше лимита
|
#### Scenario: Распакованный zip больше лимита
|
||||||
- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
|
- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
|
||||||
- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается
|
- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user