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:
Andrey Epifancev 2026-10-05 17:06:53 +04:00
parent b0a86c9b10
commit 6f579b6403
6 changed files with 398 additions and 101 deletions

View File

@ -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` без префикса;
ссылки на символы (`&#50;`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `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-снимки

View File

@ -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:

View File

@ -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

View File

@ -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 отбрасывает); ссылки на символы (`&#50;`) и
внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
- площадь — по строке `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)

View File

@ -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:B&#50;"/>', '<mergeCell ref="A1:Z&#50;00000"/>', SHEET_PART),
"шестнадцатеричная ссылка на символ": (
'<mergeCell ref="&#x41;1:B2"/>',
'<mergeCell ref="&#x41;1: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 ---

View File

@ -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:Z&#50;00000`, `&#x41;1:…`), перед `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` («распакованный размер … больше лимита»), книга не открывается