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 раза) |
| `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 с).
**Известные ограничения.** Чтение не защищено от XML-бомб (entity expansion в частях xlsx): `defusedxml` не подключён —
новая зависимость требует решения платформы (OQ в `../docs/context/07-open-questions.md`). Других лимитов памяти фазы
чтения, кроме перечисленных, нет: книга в пределах лимитов читается библиотекой целиком.
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
сканируются все записи 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-снимки

View File

@ -11,7 +11,7 @@ from decimal import Decimal
from functools import cached_property
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"]
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
# Диапазоны больше этой площади не раскладываются в индекс «ячейка → диапазон», а проверяются перебором:
# merged на весь столбец/лист не должен порождать миллионы записей
_MAX_INDEXED_AREA = 10_000
MAX_INDEXED_AREA = 10_000
@dataclass(frozen=True, slots=True)
@ -115,7 +115,7 @@ class Sheet:
def _merged_index(self) -> dict[tuple[int, int], MergedRange]:
index: dict[tuple[int, int], MergedRange] = {}
for span in self.merged:
if span.area > _MAX_INDEXED_AREA:
if span.area > MAX_INDEXED_AREA:
continue
for row in range(span.top, span.bottom + 1):
for col in range(span.left, span.right + 1):
@ -124,7 +124,7 @@ class Sheet:
@cached_property
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:

View File

@ -4,9 +4,10 @@ PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве ес
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
исключение библиотеки чтения → `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` из настроек по умолчанию)
и площадь объединённых ячеек xlsx; после чтения — площадь объединённых ячеек любого формата.
и площадь объединённых ячеек xlsx (потоковый скан всех XML-частей); после чтения — площадь объединённых ячеек любого
формата (диапазоны, которые индекс `Sheet` раскладывает по ячейкам).
"""
import io

View File

@ -1,4 +1,4 @@
"""Лимиты фазы чтения (ruling R-23): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
"""Лимиты фазы чтения (ruling R-23, R-25): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
@ -7,22 +7,43 @@
1. zip (xlsx/xlsb): сумма объявленных несжатых размеров записей (`ZipInfo.file_size`) — не больше
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES`. `zipfile` не отдаёт из записи больше объявленного размера, так
что объявленная сумма ограничивает и фактическую распаковку (openpyxl и pyxlsb читают через `zipfile`).
2. xlsx: элементы `<mergeCell ref="…">` в `xl/worksheets/*.xml` сканируются потоково, кусками по байтам (без
XML-парсера и без загрузки части целиком); суммарная площадь диапазонов книги — не больше `MERGED_AREA_LIMIT`.
3. После чтения любого формата — та же сумма площадей по `Sheet.merged`: индекс `Sheet` раскладывает диапазоны по
ячейкам, а xls хранит диапазон в 8 байтах (тысячи средних диапазонов дали бы десятки миллионов записей индекса).
2. xlsx: потоковый скан `xml.parsers.expat` всех XML-частей архива (R-25); суммарная площадь объединённых диапазонов
книги — не больше `MERGED_AREA_LIMIT`, отказ — сразу при превышении, не дочитывая часть. Правила скана повторяют то,
что разложит openpyxl (он разбирает листы тем же expat через `xml.etree`), с запасом в сторону отказа:
- части: каждая запись 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
from collections.abc import Iterable
from typing import IO
from xml.parsers import expat
from src.parser.errors import UnsupportedFormatError
from src.parser.grid import Sheet
from openpyxl.worksheet.cell_range import CellRange
from src.parser.errors import UnreadableError, UnsupportedFormatError
from src.parser.grid import MAX_INDEXED_AREA, Sheet
__all__ = [
"MERGED_AREA_LIMIT",
@ -39,13 +60,16 @@ ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
MERGED_AREA_LIMIT = 200_000
_SCAN_CHUNK_BYTES = 64 * 1024
# Элемент mergeCell длиннее этого — отказ: раздутый атрибут не должен прятать диапазон от сканера
_MERGE_TAG_MAX_BYTES = 64 * 1024
_WORKSHEET_PART = re.compile(r"^xl/worksheets/[^/]+\.xml$")
_MERGE_TAG_START = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b")
_MERGE_TAG = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b([^<>]*)>")
_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*["']""")
_ALPHABET_SIZE = 26
_MERGE_LOCAL_NAME = "mergeCell"
_REF_ATTRIBUTE = "ref" # без префикса: с namespace_separator expat отдаёт атрибут с префиксом как «uri<sep>имя»
# Разделитель «пространство имён — локальное имя»: в XML-имени не встречается, локальное имя — после последнего
_NS_SEPARATOR = " "
_XML_SUFFIX = ".xml"
# Начало части «похоже на XML»: после BOM (UTF-8, UTF-16), нулевых байтов UTF-16 и пробелов — «<»
_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:
@ -65,70 +89,78 @@ def check_zip_uncompressed_size(archive: zipfile.ZipFile, limit: int) -> None:
)
def _column_index(letters: bytes) -> int:
index = 0
for letter in letters.upper():
index = index * _ALPHABET_SIZE + letter - ord("A") + 1
return index
def _ref_area(ref: str | None) -> int:
"""Площадь диапазона `ref` элемента mergeCell по правилам openpyxl (`CellRange`); не разбирается — книга не
читается (openpyxl упал бы на том же ref)."""
try:
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:
"""Площадь диапазона из атрибутов элемента mergeCell; нераспознанный `ref` — 0 (его отвергнет библиотека)."""
found = _REF.search(attributes)
if found is None:
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
def merged_area_of_xml(
stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0, strict: bool = True
) -> int:
"""Площадь `start` + диапазонов `mergeCell` потока XML; больше `limit` — `UnsupportedFormatError` сразу, не
дочитывая поток.
def merged_area_of_xml(stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0) -> int:
"""Площадь `start` + диапазонов `<mergeCell ref>` потока XML листа; больше `limit` — `UnsupportedFormatError`
сразу, не дочитывая поток.
Поток читается кусками; незакрытый тег на границе куска переносится в следующий. В XML знак «<» вне разметки
экранируется, поэтому последний «<» без «>» после него — начало незаконченного тега.
Поток читается кусками и подаётся expat с разбором пространств имён (правила — в docstring модуля). Битый XML:
`strict` — `UnreadableError`, иначе скан части заканчивается, найденная до ошибки площадь остаётся в сумме.
"""
area = start
tail = b""
while chunk := stream.read(chunk_size):
buffer = tail + chunk
cut = buffer.rfind(b"<")
if cut != -1 and buffer.find(b">", cut) == -1:
body, tail = buffer[:cut], buffer[cut:]
else:
body, tail = buffer, b""
for found in _MERGE_TAG.finditer(body):
area += _ref_area(found.group(1))
if area > limit:
raise _merged_limit_error(limit)
if len(tail) > _MERGE_TAG_MAX_BYTES:
if _MERGE_TAG_START.match(tail):
raise UnsupportedFormatError(
f"превышен лимит объединённых ячеек: элемент mergeCell длиннее {_MERGE_TAG_MAX_BYTES} байт: "
"книга не открывается"
)
tail = b"" # длинный тег другого элемента: его продолжение без «<» не содержит mergeCell
def on_start(name: str, attributes: dict[str, str]) -> None:
nonlocal area
if name.rpartition(_NS_SEPARATOR)[2] != _MERGE_LOCAL_NAME:
return
area += _ref_area(attributes.get(_REF_ATTRIBUTE))
if area > limit:
raise _merged_limit_error(limit)
parser = expat.ParserCreate(namespace_separator=_NS_SEPARATOR)
parser.StartElementHandler = on_start
try:
while chunk := stream.read(chunk_size):
parser.Parse(chunk, False)
parser.Parse(b"", True)
except expat.ExpatError as exc:
if strict:
raise UnreadableError(f"часть книги — не корректный XML: {exc}") from exc
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:
"""Суммарная площадь объединённых ячеек всех листов xlsx — не больше `limit` (до вызова openpyxl)."""
"""Суммарная площадь объединённых ячеек всех XML-частей xlsx — не больше `limit` (до вызова openpyxl)."""
area = 0
for name in archive.namelist():
if _WORKSHEET_PART.match(name):
with archive.open(name) as stream:
area = merged_area_of_xml(stream, limit=limit, start=area)
for info in sorted(archive.infolist(), key=lambda entry: entry.filename):
if info.is_dir() or not _looks_like_xml(archive, info):
continue
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:
"""Суммарная площадь `Sheet.merged` прочитанной книги — не больше `limit` (до построения индекса merged)."""
"""Суммарная площадь диапазонов `Sheet.merged` прочитанной книги, которые индекс `Sheet` разложит по ячейкам
(не больше `MAX_INDEXED_AREA`), — не больше `limit` (до построения индекса merged)."""
area = 0
for sheet in sheets:
for span in sheet.merged:
if span.area > MAX_INDEXED_AREA:
continue # индекс его не раскладывает: поиск перебором `Sheet._large_merged`
area += span.area
if area > limit:
raise _merged_limit_error(limit)

View File

@ -7,21 +7,22 @@
import io
import time
import zipfile
from collections.abc import Callable
from collections.abc import Callable, Iterator
from typing import Any
import openpyxl
import pytest
from src.contract import ErrorCode, ResultStatus
from src.parser.errors import UnsupportedFormatError
from src.parser.grid import MergedRange, Sheet
from src.parser.errors import UnreadableError, UnsupportedFormatError
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
from src.parser.osv import OsvParser
from src.parser.readers import _READERS, read_book
from src.parser.readers._limits import (
MERGED_AREA_LIMIT,
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
check_merged_area,
check_xlsx_merged_area,
merged_area_of_xml,
)
from src.parser.rules import Rules, load_rules
@ -31,6 +32,15 @@ SHEET_PART = "xl/worksheets/sheet1.xml"
ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
BOMB_MAX_FILE_BYTES = 10_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")
@ -76,13 +86,21 @@ def _xlsx_with_zeros(size: int) -> bytes:
@pytest.fixture
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> None:
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки)."""
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки).
Вызов не только падает, но и запоминается: `read_book` превращает любое исключение читателя в `UnreadableError`,
и тест на UNREADABLE без проверки после теста прошёл бы и при вызове openpyxl.
"""
calls: list[str] = []
def forbidden(*_args: Any, **_kwargs: Any) -> None:
calls.append("load_workbook")
raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
yield
assert calls == [], "openpyxl.load_workbook вызван: лимит должен сработать до чтения книги"
# --- Площадь объединённых ячеек (xlsx) ---
@ -113,13 +131,6 @@ def test_many_small_merged_ranges_are_summed() -> None:
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")
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(
@ -140,10 +151,10 @@ def test_normal_merges_are_read() -> None:
@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
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'<x:mergeCell ref = "$D$1:$D$10" /><mergeCell ref="AA10:AB11"/></mergeCells></worksheet>'
)
) % (MAIN_NS.encode(), MAIN_NS.encode())
expected = 4 + 1 + 10 + 4
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:
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
@ -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)
# --- Обходы скана (повторное ревью 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`) ---
@ -178,25 +381,51 @@ def _sheet_with(*spans: MergedRange) -> Sheet:
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:
"""xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
"""
half = MergedRange(1, 1, MERGED_AREA_LIMIT // 2, 1)
check_merged_area([_sheet_with(half), _sheet_with(half)], limit=MERGED_AREA_LIMIT)
half = _indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA // 2)
check_merged_area([_sheet_with(*half), _sheet_with(*half)], limit=MERGED_AREA_LIMIT)
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:
huge = _sheet_with(MergedRange(1, 1, MERGED_AREA_LIMIT + 1, 1))
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (huge,)))
many = _sheet_with(*_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
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 ---

View File

@ -179,8 +179,9 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а все его
предки до корня свёрнуты — не ловится ничем (тождество строки, нетто родителей и «Итого = корень» сохраняются).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
(закреплено тестом как осознанное поведение R-21).
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
@ -217,12 +218,18 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек; для xlsx — потоковым сканом
`<mergeCell ref>` до openpyxl, для любого формата — после чтения). Книга не открывается → `UNREADABLE` (лог WARNING);
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
потоковым разбором `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, проверка между листами, в циклах по всему листу — поиск заголовка
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Защиты от XML-бомб
(`defusedxml`) нет — новая зависимость требует решения платформы. Любой файл
непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в
пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
`fixtures/osv/**` SHALL разбираться быстрее 5 с.
#### Scenario: XLSB
@ -242,6 +249,17 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
- **WHEN** передан xlsx в несколько килобайт с `<mergeCell ref="A1:Z200000"/>` (5,2 млн ячеек)
- **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 больше лимита
- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается