diff --git a/README.md b/README.md index d98d488..88f04f8 100644 --- a/README.md +++ b/README.md @@ -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 — потоковым сканом `` до 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` без префикса; +ссылки на символы (`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-снимки diff --git a/api/src/parser/grid.py b/api/src/parser/grid.py index ada82a7..91af5f9 100644 --- a/api/src/parser/grid.py +++ b/api/src/parser/grid.py @@ -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: diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index ede5298..289d74a 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -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 diff --git a/api/src/parser/readers/_limits.py b/api/src/parser/readers/_limits.py index f34a9c5..3978714 100644 --- a/api/src/parser/readers/_limits.py +++ b/api/src/parser/readers/_limits.py @@ -1,4 +1,4 @@ -"""Лимиты фазы чтения (ruling R-23): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память. +"""Лимиты фазы чтения (ruling R-23, R-25): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память. Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный объём: xlsx в 5 КБ с одним `` заставляет 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: элементы `` в `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 отбрасывает); ссылки на символы (`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 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имя» +# Разделитель «пространство имён — локальное имя»: в 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"диапазон объединения не читается: ") 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` + диапазонов `` потока 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) diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py index 3232f81..057aa78 100644 --- a/api/tests/unit/parser/test_read_limits.py +++ b/api/tests/unit/parser/test_read_limits.py @@ -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 = ( + '' + '' + '' + '' +) @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: - """Пространство имён с префиксом (``) — тот же элемент; 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'1' + b'1' b'' b'' - ) + ) % (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'' + xml = b'' 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": ( + '' + '' + '' + '' + '' + ), + "_rels/.rels": ( + f'' + f'' + "" + ), + "xl/workbook.xml": ( + f'' + '' + ), + "xl/_rels/workbook.xml.rels": ( + f'' + f'' + f'' + ), + "xl/styles.xml": f'{_STYLES}', + sheet_part: ( + f'' + 'Оборотно-сальдовая ведомость по счету 60' + f"{merge_cells}" + ), + } + 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'{element}' + + +# Вариант → (элемент mergeCell с диапазоном SMALL_REF, тот же с BOMB_REF, часть листа) +_BYPASSES: dict[str, tuple[str, str, str]] = { + "исходная бомба": (f'', f'', SHEET_PART), + "лист вне xl/worksheets": (f'', f'', "xl/data/s.xml"), + "лист без расширения .xml": (f'', f'', "xl/data/s.dat"), + "десятичная ссылка на символ": ('', '', SHEET_PART), + "шестнадцатеричная ссылка на символ": ( + '', + '', + SHEET_PART, + ), + "атрибут a:ref перед ref": ( + f'', + f'', + SHEET_PART, + ), + "«>» в значении атрибута": ( + f'', + f'', + SHEET_PART, + ), + "элемент с префиксом основного пространства": ( + f'', + f'', + SHEET_PART, + ), + "элемент чужого пространства имён": ( + f'', + f'', + 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('')), "XLSX") + + assert time.monotonic() - started < FAST_SECONDS + + +def test_unprefixed_ref_only_is_counted() -> None: + """Считается только атрибут `ref` без префикса (его читает openpyxl); `a:ref` с бомбой — чужой атрибут.""" + xml = ( + b'' + b"" + ) % 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'' + 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('')) # элемент не закрыт + + 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", ['', '', ""]) +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"
", + } + + sheets, _ = read_book(_hand_xlsx(_merges(f''), 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'') + sheet = f'{bomb}' + 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 --- diff --git a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md index 8e81298..3ed6abb 100644 --- a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md +++ b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md @@ -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 — потоковым сканом -`` до 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 в несколько килобайт с `` (5,2 млн ячеек) - **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 больше лимита - **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES` - **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается