fix: merge-bomb — потоковый expat-скан всех XML-частей (I-1б), after-read считает только индексируемые диапазоны
R-25. Регулярочный скан `<mergeCell ref>` в xl/worksheets/*.xml обходился файлом < 5 КБ четырьмя способами (лист вне xl/worksheets, ссылка на символ в ref, атрибут a:ref перед ref, «>» в кавычках). Теперь до openpyxl — потоковый разбор xml.parsers.expat (namespace-aware) всех записей zip *.xml и записей, похожих на XML (openpyxl идёт к листу по цели связи и расширение не проверяет): элемент с локальным именем mergeCell в любом пространстве имён (openpyxl берёт дочерние mergeCell по локальному имени), только ref без префикса, площадь по строке ref через CellRange openpyxl, ранний отказ при превышении MERGED_AREA_LIMIT; битый XML части *.xml и неразбираемый ref — UNREADABLE. Стоимость: 12 мс на самом большом реальном xlsx (openpyxl 0,58 с), 0,8 с на 2 млн ячеек (openpyxl 10 с). N1: check_merged_area после чтения считает только диапазоны площадью ≤ grid.MAX_INDEXED_AREA (константа стала публичной) — законный merged на весь столбец в xls больше не отвергается. Спека и README: правила скана, не покрытые риски (объём ячеек, sharedStrings/стили, entity expansion); N2 — слепая зона R-21: достаточно свёрнутого прямого родителя. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
b0a86c9b10
commit
6f579b6403
25
README.md
25
README.md
@ -193,13 +193,30 @@ groups:
|
||||
| Константа | Значение | Что ограничивает |
|
||||
|---|---|---|
|
||||
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
|
||||
| `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` без префикса;
|
||||
ссылки на символы (`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-снимки
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 отбрасывает); ссылки на символы (`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<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)
|
||||
|
||||
@ -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:B2"/>', '<mergeCell ref="A1:Z200000"/>', SHEET_PART),
|
||||
"шестнадцатеричная ссылка на символ": (
|
||||
'<mergeCell ref="A1:B2"/>',
|
||||
'<mergeCell ref="A1:Z200000"/>',
|
||||
SHEET_PART,
|
||||
),
|
||||
"атрибут a:ref перед ref": (
|
||||
f'<mergeCell xmlns:a="urn:a" a:ref="A1" ref="{SMALL_REF}"/>',
|
||||
f'<mergeCell xmlns:a="urn:a" a:ref="A1" ref="{BOMB_REF}"/>',
|
||||
SHEET_PART,
|
||||
),
|
||||
"«>» в значении атрибута": (
|
||||
f'<mergeCell xmlns:a="urn:a" a:foo=">" ref="{SMALL_REF}"/>',
|
||||
f'<mergeCell xmlns:a="urn:a" a:foo=">" ref="{BOMB_REF}"/>',
|
||||
SHEET_PART,
|
||||
),
|
||||
"элемент с префиксом основного пространства": (
|
||||
f'<x:mergeCell xmlns:x="{MAIN_NS}" ref="{SMALL_REF}"/>',
|
||||
f'<x:mergeCell xmlns:x="{MAIN_NS}" ref="{BOMB_REF}"/>',
|
||||
SHEET_PART,
|
||||
),
|
||||
"элемент чужого пространства имён": (
|
||||
f'<f:mergeCell xmlns:f="urn:foreign" ref="{SMALL_REF}"/>',
|
||||
f'<f:mergeCell xmlns:f="urn:foreign" ref="{BOMB_REF}"/>',
|
||||
SHEET_PART,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", list(_BYPASSES))
|
||||
def test_bypass_variant_is_honoured_by_openpyxl(variant: str) -> None:
|
||||
"""Каждый вариант — не выдумка: openpyxl на малом диапазоне действительно читает объединение (значит, на
|
||||
большом разложил бы его по ячейкам), а скан пропускает книгу в пределах лимита и видит площадь диапазона."""
|
||||
small, _, part = _BYPASSES[variant]
|
||||
data = _hand_xlsx(_merges(small), sheet_part=part)
|
||||
|
||||
sheets, _ = read_book(data, "XLSX")
|
||||
|
||||
assert sheets[0].merged == (SMALL_SPAN,)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
|
||||
check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
@pytest.mark.parametrize("variant", list(_BYPASSES))
|
||||
def test_bypass_variant_bomb_is_rejected_before_openpyxl(variant: str) -> None:
|
||||
_, bomb, part = _BYPASSES[variant]
|
||||
data = _hand_xlsx(_merges(bomb), sheet_part=part)
|
||||
assert len(data) < BOMB_MAX_FILE_BYTES
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(UnsupportedFormatError) as caught:
|
||||
read_book(data, "XLSX")
|
||||
|
||||
assert time.monotonic() - started < FAST_SECONDS
|
||||
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "лимит объединённых ячеек" in caught.value.message
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_whole_sheet_merge_costs_one_step() -> None:
|
||||
"""Площадь считается по строке ref, ячейки не перебираются: диапазон на весь лист отвергается сразу."""
|
||||
started = time.monotonic()
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
read_book(_hand_xlsx(_merges('<mergeCell ref="A1:XFD1048576"/>')), "XLSX")
|
||||
|
||||
assert time.monotonic() - started < FAST_SECONDS
|
||||
|
||||
|
||||
def test_unprefixed_ref_only_is_counted() -> None:
|
||||
"""Считается только атрибут `ref` без префикса (его читает openpyxl); `a:ref` с бомбой — чужой атрибут."""
|
||||
xml = (
|
||||
b'<worksheet xmlns="%s" xmlns:a="urn:a"><mergeCells><mergeCell a:ref="A1:Z200000" ref="A1:B2"/>'
|
||||
b"</mergeCells></worksheet>"
|
||||
) % MAIN_NS.encode()
|
||||
|
||||
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT) == SMALL_SPAN.area
|
||||
|
||||
|
||||
def test_scan_stops_reading_as_soon_as_limit_is_exceeded() -> None:
|
||||
"""Ранний отказ: поток после бомбы не дочитывается (и даже битый XML за ней не важен)."""
|
||||
head = b'<worksheet><mergeCells><mergeCell ref="A1:Z200000"/>'
|
||||
stream = io.BytesIO(head + b"x" * 1_000_000 + b"<<<")
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
merged_area_of_xml(stream, limit=MERGED_AREA_LIMIT, chunk_size=len(head))
|
||||
|
||||
assert stream.tell() < 2 * len(head)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_malformed_xml_part_is_unreadable() -> None:
|
||||
data = _hand_xlsx(_merges('<mergeCell ref="A1:B2">')) # элемент не закрыт
|
||||
|
||||
with pytest.raises(UnreadableError) as caught:
|
||||
read_book(data, "XLSX")
|
||||
|
||||
assert caught.value.code is ErrorCode.UNREADABLE
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
@pytest.mark.parametrize("element", ['<mergeCell ref="A1:"/>', '<mergeCell ref="A:B"/>', "<mergeCell/>"])
|
||||
def test_unparseable_merge_ref_is_unreadable(element: str) -> None:
|
||||
"""ref, который openpyxl не разберёт (или его нет), — книга не читается, как и у openpyxl."""
|
||||
with pytest.raises(UnreadableError, match="mergeCell"):
|
||||
read_book(_hand_xlsx(_merges(element)), "XLSX")
|
||||
|
||||
|
||||
def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
|
||||
"""Части без расширения .xml сканируются, только если похожи на XML; бинарные (картинки, printerSettings) и
|
||||
не вполне корректный VML книгу не ломают: openpyxl их не разбирает."""
|
||||
extra = {
|
||||
"xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
|
||||
"xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
|
||||
"xl/drawings/vmlDrawing1.vml": b"<xml><v:shape><br></xml>",
|
||||
}
|
||||
|
||||
sheets, _ = read_book(_hand_xlsx(_merges(f'<mergeCell ref="{SMALL_REF}"/>'), extra=extra), "XLSX")
|
||||
|
||||
assert sheets[0].merged == (SMALL_SPAN,)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_utf16_part_is_scanned() -> None:
|
||||
"""Кодировка части не прячет диапазон: expat сам определяет UTF-16 по BOM."""
|
||||
bomb = _merges(f'<mergeCell ref="{BOMB_REF}"/>')
|
||||
sheet = f'<?xml version="1.0" encoding="UTF-16"?><worksheet xmlns="{MAIN_NS}"><sheetData/>{bomb}</worksheet>'
|
||||
data = _hand_xlsx("", extra={"xl/data/u.bin": sheet.encode("utf-16")})
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
read_book(data, "XLSX")
|
||||
|
||||
|
||||
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
||||
|
||||
|
||||
@ -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 ---
|
||||
|
||||
|
||||
|
||||
@ -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: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` («распакованный размер … больше лимита»), книга не открывается
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user