fix(readers): предсканирование xlsx без sniff — expat по всем частям архива, потолок крупных merged в xls (I-1б)
Предскан объединённых ячеек xlsx отдаёт expat каждую некаталожную запись zip: отбор «похоже на XML» по первым 64 байтам обходился листом в части без .xml с ведущими пробелами и без XML-декларации (1,9 КБ → > 3 с, у ревьюера 27 с / 1,4 ГБ). Части *.xml — строго, остальные — мягко (ошибка разбора лишь заканчивает скан части). После чтения любого формата число диапазонов листа площадью > MAX_INDEXED_AREA ограничено LARGE_MERGED_COUNT_LIMIT = 1000: Sheet.merged_span перебирает их линейно. README, design.md №39, дельта спеки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
9820a0e7c0
commit
1eb57da5cb
27
README.md
27
README.md
@ -194,14 +194,16 @@ groups:
|
||||
|---|---|---|
|
||||
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
|
||||
| `MERGED_AREA_LIMIT` | 200 000 ячеек | суммарная площадь объединённых ячеек книги (реальный максимум 10 090): xlsx — до openpyxl, потоковым разбором expat всех XML-частей архива (см. ниже); любой формат — после чтения, по диапазонам, которые индекс `Sheet` раскладывает по ячейкам (площадь ≤ `grid.MAX_INDEXED_AREA` = 10 000; merged на весь столбец не считается — индекс его не раскладывает) |
|
||||
| `LARGE_MERGED_COUNT_LIMIT` | 1000 диапазонов | любой формат, после чтения: число крупных объединённых диапазонов листа (площадь > `grid.MAX_INDEXED_AREA`), которые индекс `Sheet` не раскладывает и перебирает линейно на каждое обращение (1000 — ≈ 31 мкс на обращение, 100 000 — ≈ 3,2 мс); в реальных книгах — единицы |
|
||||
|
||||
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
||||
|
||||
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
|
||||
сканируются все записи zip `*.xml` и записи, первые 64 байта которых (после BOM и пробелов) начинаются с `<` (openpyxl идёт к
|
||||
листу по цели связи в `workbook.xml.rels`, расширение части не проверяет); считается элемент с локальным именем `mergeCell` в любом
|
||||
пространстве имён (openpyxl берёт дочерние `mergeCell` по локальному имени) и только атрибут `ref` без префикса;
|
||||
ссылки на символы (`2`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
|
||||
сканируется каждая некаталожная запись zip, без отбора по имени или началу содержимого (openpyxl идёт к листу по цели
|
||||
связи в `workbook.xml.rels`, расширение части не проверяет, а начало XML можно отодвинуть пробелами; бинарную часть
|
||||
expat отвергает на первых байтах, ошибка разбора части не `*.xml` лишь заканчивает её скан, находка I-1б); считается
|
||||
элемент с локальным именем `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 с.
|
||||
@ -215,15 +217,14 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|
||||
- XML-бомбы entity expansion: `defusedxml` не подключён; от экспоненты «billion laughs» защищает только сам expat ≥ 2.4
|
||||
(и в скане, и в openpyxl, который разбирает части тем же expat).
|
||||
|
||||
- **известный обход предсканирования (R-26):** предскан — эшелон защиты, а не граница безопасности. Намеренно собранный
|
||||
файл, у которого лист лежит в части без расширения `.xml`, начинается с ≥ 64 байт пробелов (или BOM UTF-16 и пробелов) и
|
||||
не имеет XML-декларации, пропускает предскан: файл в 1,8 КБ читается `read_book` 27 с и занимает 1,4 ГБ. Первый
|
||||
follow-up: убрать «sniff» и лениво отдавать expat каждую некаталожную запись архива (+ тест);
|
||||
- **память и CPU самого предскана** не ограничены размером чанка: «мусорная» часть `xl/junk.xml` с одним атрибутом в
|
||||
400 МБ даёт 1,3 ГБ за 1,2 с, 400 МБ тегов `<a/>` — 8,5 с CPU (в пределах лимита распакованного zip, 500 МиБ);
|
||||
- **xls с очень многим числом крупных merged-диапазонов** (каждый площадью > 10 000, например 100 000 штук): индекс
|
||||
`Sheet` просматривает их линейно (≈ 4,4 мс на обращение), останавливает только `PARSE_TIMEOUT_SECONDS` (`TIMEOUT`).
|
||||
Follow-up: потолок на число таких диапазонов или интервальный индекс.
|
||||
- **предскан — эшелон защиты, а не граница безопасности (R-26):** он повторяет разбор openpyxl (тот же expat), но если
|
||||
на части не `*.xml` expat скана остановится раньше, чем разбор openpyxl, остаток части до openpyxl дойдёт
|
||||
непросканированным; известных таких расхождений нет;
|
||||
- **память и CPU самого предскана** не ограничены размером чанка (сканируется каждая часть, с любым именем): «мусорная»
|
||||
часть `xl/junk.xml` с одним атрибутом в 400 МБ даёт 1,3 ГБ за 1,2 с, 400 МБ тегов `<a/>` — 8,5 с CPU (в пределах
|
||||
лимита распакованного zip, 500 МиБ);
|
||||
- **крупные merged-диапазоны в пределах потолка** (`LARGE_MERGED_COUNT_LIMIT` = 1000 на лист): индекс `Sheet` по-прежнему
|
||||
просматривает их линейно (≈ 31 мкс на обращение); интервальный индекс — не сделан.
|
||||
|
||||
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же —
|
||||
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Лимиты фазы чтения (ruling R-23, R-25): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
|
||||
"""Лимиты фазы чтения (ruling R-23, R-25, находка I-1б): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения
|
||||
выделит память.
|
||||
|
||||
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
|
||||
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
||||
@ -10,9 +11,10 @@
|
||||
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` и ни каталог, ни расширение части не проверяет;
|
||||
порядок — по имени (детерминированно);
|
||||
- части: КАЖДАЯ некаталожная запись zip, без отбора по имени или началу содержимого: openpyxl идёт к листу по цели
|
||||
связи в `workbook.xml.rels` и ни каталог, ни расширение части не проверяет, а начало XML-документа можно
|
||||
отодвинуть пробелами сколь угодно далеко (прежний отбор по первым 64 байтам обходился, I-1б); бинарную часть
|
||||
expat отвергает на первых байтах; порядок — по имени (детерминированно);
|
||||
- элемент: локальное имя `mergeCell` в ЛЮБОМ пространстве имён (и без него). openpyxl находит контейнер
|
||||
`mergeCells` только в основном пространстве SpreadsheetML, но дочерние `mergeCell` берёт по локальному имени
|
||||
(`Serialisable.from_tree`) — ограничение основным пространством было бы обходом; лишний счёт элемента вне листа
|
||||
@ -21,14 +23,17 @@
|
||||
внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
|
||||
- площадь — по строке `ref` (`CellRange` openpyxl, те же правила разбора), ячейки не перебираются: диапазон
|
||||
`A1:XFD1048576` стоит один шаг; ref, который openpyxl не разберёт (или его нет), — `UnreadableError`;
|
||||
- битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части, лишь
|
||||
похожей на XML (VML, неизвестные данные), — конец скана этой части без отказа: openpyxl её либо не разбирает,
|
||||
либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора всего листа).
|
||||
- битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части с
|
||||
другим именем (картинки, printerSettings, VML, неизвестные данные) — конец скана этой части без отказа: openpyxl
|
||||
её либо не разбирает, либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора
|
||||
всего листа), а найденная до ошибки площадь остаётся в сумме.
|
||||
Память скана ограничена: части читаются кусками `_SCAN_CHUNK_BYTES`, expat держит только незаконченный токен;
|
||||
общий объём — не больше лимита распакованного zip (проверен до скана).
|
||||
3. После чтения любого формата — сумма площадей `Sheet.merged`, которые индекс `Sheet` разложит по ячейкам (площадь
|
||||
не больше `grid.MAX_INDEXED_AREA`): xls хранит диапазон в 8 байтах, тысячи средних диапазонов дали бы десятки
|
||||
миллионов записей индекса. Диапазоны больше порога (merged на весь столбец) индекс не раскладывает — их не считаем.
|
||||
миллионов записей индекса. Диапазоны больше порога (merged на весь столбец) индекс не раскладывает — их площадь не
|
||||
считаем, но считаем их число: `Sheet.merged_span` перебирает их линейно на каждое обращение, и 100 000 таких
|
||||
диапазонов в xls — ≈ 4,4 мс на обращение; на лист — не больше `LARGE_MERGED_COUNT_LIMIT`.
|
||||
|
||||
Защиты от XML-бомб (entity expansion, `defusedxml`) и от объёма самих ячеек в пределах лимита zip здесь нет: решение
|
||||
платформы (README, «Известные ограничения»). Скан раскрывает внутренние сущности так же, как openpyxl; от экспоненты
|
||||
@ -46,6 +51,7 @@ from src.parser.errors import UnreadableError, UnsupportedFormatError
|
||||
from src.parser.grid import MAX_INDEXED_AREA, Sheet
|
||||
|
||||
__all__ = [
|
||||
"LARGE_MERGED_COUNT_LIMIT",
|
||||
"MERGED_AREA_LIMIT",
|
||||
"ZIP_UNCOMPRESSED_LIMIT_FACTOR",
|
||||
"check_merged_area",
|
||||
@ -59,19 +65,25 @@ ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
|
||||
# Суммарная площадь объединённых ячеек книги (ячеек). Реальный максимум — 10 090 (xls, 3 444 диапазона), у xlsx —
|
||||
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
||||
MERGED_AREA_LIMIT = 200_000
|
||||
# Число диапазонов листа площадью больше `MAX_INDEXED_AREA` (индекс их не раскладывает, поиск — перебором). В реальных
|
||||
# книгах — единицы; 1000 — ≈ 44 мкс на обращение к `Sheet.merged_span` (100 000 — ≈ 4,4 мс)
|
||||
LARGE_MERGED_COUNT_LIMIT = 1000
|
||||
_SCAN_CHUNK_BYTES = 64 * 1024
|
||||
_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 _large_merged_limit_error(sheet: str, limit: int) -> UnsupportedFormatError:
|
||||
return UnsupportedFormatError(
|
||||
f"превышен лимит крупных объединённых диапазонов: на листе {sheet!r} больше {limit} диапазонов площадью больше "
|
||||
f"{MAX_INDEXED_AREA} ячеек (LARGE_MERGED_COUNT_LIMIT): книга не открывается"
|
||||
)
|
||||
|
||||
|
||||
def _merged_limit_error(limit: int) -> UnsupportedFormatError:
|
||||
return UnsupportedFormatError(
|
||||
f"превышен лимит объединённых ячеек: площадь объединённых диапазонов книги больше {limit} ячеек "
|
||||
@ -134,33 +146,36 @@ def merged_area_of_xml(
|
||||
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:
|
||||
"""Суммарная площадь объединённых ячеек всех XML-частей xlsx — не больше `limit` (до вызова openpyxl)."""
|
||||
"""Суммарная площадь объединённых ячеек всех некаталожных частей xlsx — не больше `limit` (до вызова openpyxl).
|
||||
|
||||
Части `*.xml` сканируются строго (битый XML — `UnreadableError`), остальные — мягко (ошибка разбора лишь
|
||||
заканчивает скан части)."""
|
||||
area = 0
|
||||
for info in sorted(archive.infolist(), key=lambda entry: entry.filename):
|
||||
if info.is_dir() or not _looks_like_xml(archive, info):
|
||||
if info.is_dir():
|
||||
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:
|
||||
def check_merged_area(
|
||||
sheets: Iterable[Sheet], limit: int = MERGED_AREA_LIMIT, large_limit: int = LARGE_MERGED_COUNT_LIMIT
|
||||
) -> None:
|
||||
"""Суммарная площадь диапазонов `Sheet.merged` прочитанной книги, которые индекс `Sheet` разложит по ячейкам
|
||||
(не больше `MAX_INDEXED_AREA`), — не больше `limit` (до построения индекса merged)."""
|
||||
(не больше `MAX_INDEXED_AREA`), — не больше `limit`; число остальных (крупных) диапазонов листа — не больше
|
||||
`large_limit` (до построения индекса merged)."""
|
||||
area = 0
|
||||
for sheet in sheets:
|
||||
large = 0
|
||||
for span in sheet.merged:
|
||||
if span.area > MAX_INDEXED_AREA:
|
||||
continue # индекс его не раскладывает: поиск перебором `Sheet._large_merged`
|
||||
# индекс его не раскладывает: поиск перебором `Sheet._large_merged` на каждое обращение
|
||||
large += 1
|
||||
if large > large_limit:
|
||||
raise _large_merged_limit_error(sheet.name, large_limit)
|
||||
continue
|
||||
area += span.area
|
||||
if area > limit:
|
||||
raise _merged_limit_error(limit)
|
||||
|
||||
@ -19,6 +19,7 @@ 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 (
|
||||
LARGE_MERGED_COUNT_LIMIT,
|
||||
MERGED_AREA_LIMIT,
|
||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
||||
check_merged_area,
|
||||
@ -189,9 +190,20 @@ 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:
|
||||
XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
|
||||
|
||||
def _hand_xlsx(
|
||||
merge_cells: str,
|
||||
*,
|
||||
sheet_part: str = SHEET_PART,
|
||||
extra: dict[str, bytes] | None = None,
|
||||
sheet_head: str = XML_DECLARATION,
|
||||
sheet_encoding: str = "utf-8",
|
||||
) -> bytes:
|
||||
"""Минимальная книга xlsx из одного листа: `merge_cells` вставляется после `sheetData`, лист лежит в `sheet_part`
|
||||
(цель связи в `xl/_rels/workbook.xml.rels` — openpyxl идёт по ней, имя части не проверяет)."""
|
||||
(цель связи в `xl/_rels/workbook.xml.rels` — openpyxl идёт по ней, имя части не проверяет), начинается с
|
||||
`sheet_head` (по умолчанию XML-декларация) и записан в кодировке `sheet_encoding`."""
|
||||
target = sheet_part.removeprefix("xl/")
|
||||
parts = {
|
||||
"[Content_Types].xml": (
|
||||
@ -217,16 +229,17 @@ def _hand_xlsx(merge_cells: str, *, sheet_part: str = SHEET_PART, extra: dict[st
|
||||
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>"
|
||||
),
|
||||
}
|
||||
sheet = (
|
||||
f'{sheet_head}<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)
|
||||
archive.writestr(sheet_part, sheet.encode(sheet_encoding))
|
||||
for name, raw in (extra or {}).items():
|
||||
archive.writestr(name, raw)
|
||||
return out.getvalue()
|
||||
@ -350,12 +363,14 @@ def test_unparseable_merge_ref_is_unreadable(element: str) -> None:
|
||||
|
||||
|
||||
def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
|
||||
"""Части без расширения .xml сканируются, только если похожи на XML; бинарные (картинки, printerSettings) и
|
||||
не вполне корректный VML книгу не ломают: openpyxl их не разбирает."""
|
||||
"""Части без расширения .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>",
|
||||
"xl/notes.txt": b" " * 100 + b"plain text, not XML",
|
||||
"xl/media/empty.bin": b"",
|
||||
}
|
||||
|
||||
sheets, _ = read_book(_hand_xlsx(_merges(f'<mergeCell ref="{SMALL_REF}"/>'), extra=extra), "XLSX")
|
||||
@ -374,6 +389,52 @@ def test_utf16_part_is_scanned() -> None:
|
||||
read_book(data, "XLSX")
|
||||
|
||||
|
||||
# Обход «sniff» (повторное ревью раунда 2, I-1б): лист в части без `.xml`, ≥ 64 байт ведущих пробелов, без
|
||||
# XML-декларации — первые 64 байта не начинаются с «<», и проверка «похоже на XML» часть пропускала (27 с / 1,4 ГБ)
|
||||
CRAFTED_PART = "xl/worksheets/sheet1.dat"
|
||||
CRAFTED_PADDING = " " * 64
|
||||
SLOW_READ_SECONDS = 2.0
|
||||
# Вариант → (начало части листа, кодировка)
|
||||
_SNIFF_BYPASSES: dict[str, tuple[str, str]] = {
|
||||
"64 пробела без декларации": (CRAFTED_PADDING, "utf-8"),
|
||||
"BOM UTF-16 и 64 пробела без декларации": (CRAFTED_PADDING, "utf-16"),
|
||||
"4096 пробелов без декларации": (" " * 4096, "utf-8"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", list(_SNIFF_BYPASSES))
|
||||
def test_padded_non_xml_sheet_part_is_honoured_by_openpyxl(variant: str) -> None:
|
||||
"""Вариант не выдумка: openpyxl читает такой лист и раскладывает его объединения."""
|
||||
head, encoding = _SNIFF_BYPASSES[variant]
|
||||
data = _hand_xlsx(
|
||||
_merges(f'<mergeCell ref="{SMALL_REF}"/>'), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
|
||||
)
|
||||
|
||||
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(_SNIFF_BYPASSES))
|
||||
def test_padded_non_xml_sheet_part_bomb_is_rejected_before_openpyxl(variant: str) -> None:
|
||||
head, encoding = _SNIFF_BYPASSES[variant]
|
||||
data = _hand_xlsx(
|
||||
_merges(f'<mergeCell ref="{BOMB_REF}"/>'), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
|
||||
)
|
||||
assert len(data) < BOMB_MAX_FILE_BYTES
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(UnsupportedFormatError) as caught:
|
||||
read_book(data, "XLSX")
|
||||
|
||||
assert time.monotonic() - started < SLOW_READ_SECONDS
|
||||
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "лимит объединённых ячеек" in caught.value.message
|
||||
|
||||
|
||||
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
||||
|
||||
|
||||
@ -426,6 +487,35 @@ def test_xls_with_whole_column_merge_is_read(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert sheets == (sheet,)
|
||||
|
||||
|
||||
def _large_spans(count: int) -> tuple[MergedRange, ...]:
|
||||
"""`count` диапазонов площадью `MAX_INDEXED_AREA + 1` (столбец за столбцом): индекс не раскладывает ни один."""
|
||||
return tuple(MergedRange(1, col, MAX_INDEXED_AREA + 1, col) for col in range(1, count + 1))
|
||||
|
||||
|
||||
def test_large_spans_count_is_limited_per_sheet() -> None:
|
||||
"""Крупные диапазоны (площадь больше `MAX_INDEXED_AREA`) `Sheet.merged_span` перебирает линейно на каждое
|
||||
обращение: в xls их 100 000 — ≈ 4,4 мс на обращение (повторное ревью раунда 2, минор #2). Потолок — на лист."""
|
||||
at_limit = _large_spans(LARGE_MERGED_COUNT_LIMIT)
|
||||
check_merged_area([_sheet_with(*at_limit), _sheet_with(*at_limit)])
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит крупных объединённых диапазонов") as caught:
|
||||
check_merged_area([_sheet_with(*_large_spans(LARGE_MERGED_COUNT_LIMIT + 1))])
|
||||
assert str(LARGE_MERGED_COUNT_LIMIT) in caught.value.message
|
||||
|
||||
|
||||
def test_xls_with_too_many_large_spans_is_unsupported(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
many = _sheet_with(*_large_spans(100_000))
|
||||
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(UnsupportedFormatError) as caught:
|
||||
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
|
||||
|
||||
assert time.monotonic() - started < FAST_SECONDS
|
||||
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "лимит крупных объединённых диапазонов" in caught.value.message
|
||||
|
||||
|
||||
# --- Распакованный размер zip ---
|
||||
|
||||
|
||||
|
||||
@ -174,3 +174,4 @@ attempt), OQ-81 (частично закрыт: в tabular-parser `ARM_KA_FIXTUR
|
||||
| 36 | 2026-10-05 | R-24: значениям-основанное определение колонки ИНН не дополняет 9/11-значные числа нулём (дополнение — только при извлечении значения из колонки, выбранной по заголовку или по строгому правилу 80 % из 10/12 цифр) | финальное ревью M-3: числовая колонка КПП (9 цифр) молча принималась за ИНН (counterparty_inn = «0»+КПП) | числовая колонка ИНН с потерянными нулями без заголовка «ИНН» не определится автоматически |
|
||||
| 37 | 2026-10-05 | R-25: вторая и последняя волна правок по I-1(б): предсканирование merge-диапазонов заменяется потоковым разбором `xml.parsers.expat` по ВСЕМ `.xml`-частям архива (namespace-aware, charref, кавычки), раннее прерывание при превышении площади, битый XML → UNREADABLE; после-чтение проверка merged считает только диапазоны, которые grid разворачивает (≤ порога индексации); остаточные риски (объём ячеек, sharedStrings/styles, entity expansion) — документируются, лимиты памяти контейнера/defusedxml — решение платформы | регулярочный скан обходился четырьмя способами из файла < 5 КБ (24 с, 1,4 ГБ) | бомба по числу ячеек (5 МБ, 2 млн ячеек → 13 с, 1,5 ГБ) и entity expansion остаются до решений платформы |
|
||||
| 38 | 2026-10-05 | R-26: третьей волны правок нет; предсканирование merge-диапазонов — эшелон защиты, а НЕ граница безопасности: закрывает обычную бомбу и все найденные стандартные обходы, но намеренно собранный файл (лист в части без `.xml` + ≥ 64 байт ведущих пробелов, без XML-декларации) доходит до openpyxl (27 с / 1,4 ГБ); настоящая граница — лимит памяти контейнера и/или подпроцесс с RLIMIT (решение платформы, OQ-108). Первый follow-up: убрать «sniff», лениво скармливать expat каждую не-каталожную часть + тест; ограничить число больших merged-диапазонов (xls) | ревью финальной волны, раунд 2 | специально собранный файл занимает воркер ~30 с / 1,4 ГБ до follow-up или лимита памяти платформы; файлы приходят от аутентифицированных пользователей внутренней ИС |
|
||||
| 39 | 2026-10-05 | I-1(б) закрыт: sniff убран, expat по всем частям; потолок крупных merged xls. Предскан xlsx отдаёт `xml.parsers.expat` КАЖДУЮ некаталожную запись архива без отбора по имени или первым байтам (части `*.xml` — строго, битый XML → UNREADABLE; остальные — мягко, ошибка разбора заканчивает скан части без отказа файла). После чтения любого формата число крупных merged-диапазонов листа (площадь > `grid.MAX_INDEXED_AREA` = 10 000, индекс их не раскладывает) — не больше `LARGE_MERGED_COUNT_LIMIT` = 1000, иначе UNSUPPORTED_FORMAT с текстом лимита. «Крупный» взят по существующему порогу индекса, а не по запасному «≥ 1000 ячеек / потолок 10 000», потолок — по предложению ревью (≤ 1000) | follow-up R-26 (ревью финальной волны, раунд 2): crafted-файл 1,9 КБ (лист в `xl/worksheets/sheet1.dat`, 64 пробела, без декларации) читался > 3 с (ревьюер: 27 с / 1,4 ГБ), после — отказ < 1 мс; 100 000 крупных диапазонов xls — ≈ 3,2 мс на обращение к `merged_span`, при 1000 — ≈ 31 мкс | расхождение expat скана и разбора openpyxl на части не `*.xml` (скан остановился раньше) вернёт обход — известных нет; законная книга с > 1000 диапазонами > 10 000 ячеек на листе отвергнется UNSUPPORTED_FORMAT (в реальных книгах — единицы); предскан по-прежнему не граница безопасности (OQ-108) |
|
||||
|
||||
@ -219,11 +219,12 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
|
||||
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
|
||||
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
|
||||
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
|
||||
потоковым разбором `xml.parsers.expat` всех XML-частей архива (R-25: записи `*.xml` и записи, начало которых похоже на
|
||||
XML, — в любом каталоге; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
|
||||
потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
|
||||
без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
|
||||
префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
|
||||
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
|
||||
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается). Книга не открывается (в том числе битый XML части
|
||||
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
|
||||
`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части
|
||||
`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
|
||||
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
|
||||
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user