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 раза) |
|
| `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 на весь столбец не считается — индекс его не раскладывает) |
|
| `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 с).
|
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
||||||
|
|
||||||
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
|
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
|
||||||
сканируются все записи zip `*.xml` и записи, первые 64 байта которых (после BOM и пробелов) начинаются с `<` (openpyxl идёт к
|
сканируется каждая некаталожная запись zip, без отбора по имени или началу содержимого (openpyxl идёт к листу по цели
|
||||||
листу по цели связи в `workbook.xml.rels`, расширение части не проверяет); считается элемент с локальным именем `mergeCell` в любом
|
связи в `workbook.xml.rels`, расширение части не проверяет, а начало XML можно отодвинуть пробелами; бинарную часть
|
||||||
пространстве имён (openpyxl берёт дочерние `mergeCell` по локальному имени) и только атрибут `ref` без префикса;
|
expat отвергает на первых байтах, ошибка разбора части не `*.xml` лишь заканчивает её скан, находка I-1б); считается
|
||||||
ссылки на символы (`2`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
|
элемент с локальным именем `mergeCell` в любом пространстве имён (openpyxl берёт дочерние `mergeCell` по локальному
|
||||||
|
имени) и только атрибут `ref` без префикса; ссылки на символы (`2`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
|
||||||
openpyxl), ячейки не перебираются; отказ — сразу при превышении. Битый XML части `*.xml` и `ref`, который openpyxl не
|
openpyxl), ячейки не перебираются; отказ — сразу при превышении. Битый XML части `*.xml` и `ref`, который openpyxl не
|
||||||
разберёт, — `UNREADABLE`. Стоимость скана: самый большой реальный xlsx (454 КБ распакованно) — 12 мс при чтении
|
разберёт, — `UNREADABLE`. Стоимость скана: самый большой реальный xlsx (454 КБ распакованно) — 12 мс при чтении
|
||||||
openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 МБ распакованно) — 0,8 с при чтении openpyxl 10 с.
|
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
|
- XML-бомбы entity expansion: `defusedxml` не подключён; от экспоненты «billion laughs» защищает только сам expat ≥ 2.4
|
||||||
(и в скане, и в openpyxl, который разбирает части тем же expat).
|
(и в скане, и в openpyxl, который разбирает части тем же expat).
|
||||||
|
|
||||||
- **известный обход предсканирования (R-26):** предскан — эшелон защиты, а не граница безопасности. Намеренно собранный
|
- **предскан — эшелон защиты, а не граница безопасности (R-26):** он повторяет разбор openpyxl (тот же expat), но если
|
||||||
файл, у которого лист лежит в части без расширения `.xml`, начинается с ≥ 64 байт пробелов (или BOM UTF-16 и пробелов) и
|
на части не `*.xml` expat скана остановится раньше, чем разбор openpyxl, остаток части до openpyxl дойдёт
|
||||||
не имеет XML-декларации, пропускает предскан: файл в 1,8 КБ читается `read_book` 27 с и занимает 1,4 ГБ. Первый
|
непросканированным; известных таких расхождений нет;
|
||||||
follow-up: убрать «sniff» и лениво отдавать expat каждую некаталожную запись архива (+ тест);
|
- **память и CPU самого предскана** не ограничены размером чанка (сканируется каждая часть, с любым именем): «мусорная»
|
||||||
- **память и CPU самого предскана** не ограничены размером чанка: «мусорная» часть `xl/junk.xml` с одним атрибутом в
|
часть `xl/junk.xml` с одним атрибутом в 400 МБ даёт 1,3 ГБ за 1,2 с, 400 МБ тегов `<a/>` — 8,5 с CPU (в пределах
|
||||||
400 МБ даёт 1,3 ГБ за 1,2 с, 400 МБ тегов `<a/>` — 8,5 с CPU (в пределах лимита распакованного zip, 500 МиБ);
|
лимита распакованного zip, 500 МиБ);
|
||||||
- **xls с очень многим числом крупных merged-диапазонов** (каждый площадью > 10 000, например 100 000 штук): индекс
|
- **крупные merged-диапазоны в пределах потолка** (`LARGE_MERGED_COUNT_LIMIT` = 1000 на лист): индекс `Sheet` по-прежнему
|
||||||
`Sheet` просматривает их линейно (≈ 4,4 мс на обращение), останавливает только `PARSE_TIMEOUT_SECONDS` (`TIMEOUT`).
|
просматривает их линейно (≈ 31 мкс на обращение); интервальный индекс — не сделан.
|
||||||
Follow-up: потолок на число таких диапазонов или интервальный индекс.
|
|
||||||
|
|
||||||
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же —
|
Жёсткий лимит памяти контейнера, `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 не работает, а лимит размера файла не ограничивает распакованный
|
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
|
||||||
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
||||||
@ -10,9 +11,10 @@
|
|||||||
2. xlsx: потоковый скан `xml.parsers.expat` всех XML-частей архива (R-25); суммарная площадь объединённых диапазонов
|
2. xlsx: потоковый скан `xml.parsers.expat` всех XML-частей архива (R-25); суммарная площадь объединённых диапазонов
|
||||||
книги — не больше `MERGED_AREA_LIMIT`, отказ — сразу при превышении, не дочитывая часть. Правила скана повторяют то,
|
книги — не больше `MERGED_AREA_LIMIT`, отказ — сразу при превышении, не дочитывая часть. Правила скана повторяют то,
|
||||||
что разложит openpyxl (он разбирает листы тем же expat через `xml.etree`), с запасом в сторону отказа:
|
что разложит openpyxl (он разбирает листы тем же expat через `xml.etree`), с запасом в сторону отказа:
|
||||||
- части: каждая запись zip с именем `*.xml` и каждая запись, чьё начало похоже на XML (`<` после BOM/пробелов,
|
- части: КАЖДАЯ некаталожная запись zip, без отбора по имени или началу содержимого: openpyxl идёт к листу по цели
|
||||||
UTF-16): openpyxl идёт к листу по цели связи в `workbook.xml.rels` и ни каталог, ни расширение части не проверяет;
|
связи в `workbook.xml.rels` и ни каталог, ни расширение части не проверяет, а начало XML-документа можно
|
||||||
порядок — по имени (детерминированно);
|
отодвинуть пробелами сколь угодно далеко (прежний отбор по первым 64 байтам обходился, I-1б); бинарную часть
|
||||||
|
expat отвергает на первых байтах; порядок — по имени (детерминированно);
|
||||||
- элемент: локальное имя `mergeCell` в ЛЮБОМ пространстве имён (и без него). openpyxl находит контейнер
|
- элемент: локальное имя `mergeCell` в ЛЮБОМ пространстве имён (и без него). openpyxl находит контейнер
|
||||||
`mergeCells` только в основном пространстве SpreadsheetML, но дочерние `mergeCell` берёт по локальному имени
|
`mergeCells` только в основном пространстве SpreadsheetML, но дочерние `mergeCell` берёт по локальному имени
|
||||||
(`Serialisable.from_tree`) — ограничение основным пространством было бы обходом; лишний счёт элемента вне листа
|
(`Serialisable.from_tree`) — ограничение основным пространством было бы обходом; лишний счёт элемента вне листа
|
||||||
@ -21,14 +23,17 @@
|
|||||||
внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
|
внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
|
||||||
- площадь — по строке `ref` (`CellRange` openpyxl, те же правила разбора), ячейки не перебираются: диапазон
|
- площадь — по строке `ref` (`CellRange` openpyxl, те же правила разбора), ячейки не перебираются: диапазон
|
||||||
`A1:XFD1048576` стоит один шаг; ref, который openpyxl не разберёт (или его нет), — `UnreadableError`;
|
`A1:XFD1048576` стоит один шаг; ref, который openpyxl не разберёт (или его нет), — `UnreadableError`;
|
||||||
- битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части, лишь
|
- битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части с
|
||||||
похожей на XML (VML, неизвестные данные), — конец скана этой части без отказа: openpyxl её либо не разбирает,
|
другим именем (картинки, printerSettings, VML, неизвестные данные) — конец скана этой части без отказа: openpyxl
|
||||||
либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора всего листа).
|
её либо не разбирает, либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора
|
||||||
|
всего листа), а найденная до ошибки площадь остаётся в сумме.
|
||||||
Память скана ограничена: части читаются кусками `_SCAN_CHUNK_BYTES`, expat держит только незаконченный токен;
|
Память скана ограничена: части читаются кусками `_SCAN_CHUNK_BYTES`, expat держит только незаконченный токен;
|
||||||
общий объём — не больше лимита распакованного zip (проверен до скана).
|
общий объём — не больше лимита распакованного zip (проверен до скана).
|
||||||
3. После чтения любого формата — сумма площадей `Sheet.merged`, которые индекс `Sheet` разложит по ячейкам (площадь
|
3. После чтения любого формата — сумма площадей `Sheet.merged`, которые индекс `Sheet` разложит по ячейкам (площадь
|
||||||
не больше `grid.MAX_INDEXED_AREA`): xls хранит диапазон в 8 байтах, тысячи средних диапазонов дали бы десятки
|
не больше `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 здесь нет: решение
|
Защиты от XML-бомб (entity expansion, `defusedxml`) и от объёма самих ячеек в пределах лимита zip здесь нет: решение
|
||||||
платформы (README, «Известные ограничения»). Скан раскрывает внутренние сущности так же, как openpyxl; от экспоненты
|
платформы (README, «Известные ограничения»). Скан раскрывает внутренние сущности так же, как openpyxl; от экспоненты
|
||||||
@ -46,6 +51,7 @@ from src.parser.errors import UnreadableError, UnsupportedFormatError
|
|||||||
from src.parser.grid import MAX_INDEXED_AREA, Sheet
|
from src.parser.grid import MAX_INDEXED_AREA, Sheet
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"LARGE_MERGED_COUNT_LIMIT",
|
||||||
"MERGED_AREA_LIMIT",
|
"MERGED_AREA_LIMIT",
|
||||||
"ZIP_UNCOMPRESSED_LIMIT_FACTOR",
|
"ZIP_UNCOMPRESSED_LIMIT_FACTOR",
|
||||||
"check_merged_area",
|
"check_merged_area",
|
||||||
@ -59,19 +65,25 @@ ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
|
|||||||
# Суммарная площадь объединённых ячеек книги (ячеек). Реальный максимум — 10 090 (xls, 3 444 диапазона), у xlsx —
|
# Суммарная площадь объединённых ячеек книги (ячеек). Реальный максимум — 10 090 (xls, 3 444 диапазона), у xlsx —
|
||||||
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
||||||
MERGED_AREA_LIMIT = 200_000
|
MERGED_AREA_LIMIT = 200_000
|
||||||
|
# Число диапазонов листа площадью больше `MAX_INDEXED_AREA` (индекс их не раскладывает, поиск — перебором). В реальных
|
||||||
|
# книгах — единицы; 1000 — ≈ 44 мкс на обращение к `Sheet.merged_span` (100 000 — ≈ 4,4 мс)
|
||||||
|
LARGE_MERGED_COUNT_LIMIT = 1000
|
||||||
_SCAN_CHUNK_BYTES = 64 * 1024
|
_SCAN_CHUNK_BYTES = 64 * 1024
|
||||||
_MERGE_LOCAL_NAME = "mergeCell"
|
_MERGE_LOCAL_NAME = "mergeCell"
|
||||||
_REF_ATTRIBUTE = "ref" # без префикса: с namespace_separator expat отдаёт атрибут с префиксом как «uri<sep>имя»
|
_REF_ATTRIBUTE = "ref" # без префикса: с namespace_separator expat отдаёт атрибут с префиксом как «uri<sep>имя»
|
||||||
# Разделитель «пространство имён — локальное имя»: в XML-имени не встречается, локальное имя — после последнего
|
# Разделитель «пространство имён — локальное имя»: в XML-имени не встречается, локальное имя — после последнего
|
||||||
_NS_SEPARATOR = " "
|
_NS_SEPARATOR = " "
|
||||||
_XML_SUFFIX = ".xml"
|
_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
|
_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:
|
def _merged_limit_error(limit: int) -> UnsupportedFormatError:
|
||||||
return UnsupportedFormatError(
|
return UnsupportedFormatError(
|
||||||
f"превышен лимит объединённых ячеек: площадь объединённых диапазонов книги больше {limit} ячеек "
|
f"превышен лимит объединённых ячеек: площадь объединённых диапазонов книги больше {limit} ячеек "
|
||||||
@ -134,33 +146,36 @@ def merged_area_of_xml(
|
|||||||
return area
|
return area
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_xml(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bool:
|
|
||||||
if info.filename.lower().endswith(_XML_SUFFIX):
|
|
||||||
return True
|
|
||||||
with archive.open(info) as stream:
|
|
||||||
head = stream.read(_SNIFF_BYTES)
|
|
||||||
return head.lstrip(_SNIFF_SKIPPED).startswith(_XML_START)
|
|
||||||
|
|
||||||
|
|
||||||
def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
|
def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
|
||||||
"""Суммарная площадь объединённых ячеек всех XML-частей xlsx — не больше `limit` (до вызова openpyxl)."""
|
"""Суммарная площадь объединённых ячеек всех некаталожных частей xlsx — не больше `limit` (до вызова openpyxl).
|
||||||
|
|
||||||
|
Части `*.xml` сканируются строго (битый XML — `UnreadableError`), остальные — мягко (ошибка разбора лишь
|
||||||
|
заканчивает скан части)."""
|
||||||
area = 0
|
area = 0
|
||||||
for info in sorted(archive.infolist(), key=lambda entry: entry.filename):
|
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
|
continue
|
||||||
strict = info.filename.lower().endswith(_XML_SUFFIX)
|
strict = info.filename.lower().endswith(_XML_SUFFIX)
|
||||||
with archive.open(info) as stream:
|
with archive.open(info) as stream:
|
||||||
area = merged_area_of_xml(stream, limit=limit, start=area, strict=strict)
|
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` разложит по ячейкам
|
"""Суммарная площадь диапазонов `Sheet.merged` прочитанной книги, которые индекс `Sheet` разложит по ячейкам
|
||||||
(не больше `MAX_INDEXED_AREA`), — не больше `limit` (до построения индекса merged)."""
|
(не больше `MAX_INDEXED_AREA`), — не больше `limit`; число остальных (крупных) диапазонов листа — не больше
|
||||||
|
`large_limit` (до построения индекса merged)."""
|
||||||
area = 0
|
area = 0
|
||||||
for sheet in sheets:
|
for sheet in sheets:
|
||||||
|
large = 0
|
||||||
for span in sheet.merged:
|
for span in sheet.merged:
|
||||||
if span.area > MAX_INDEXED_AREA:
|
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
|
area += span.area
|
||||||
if area > limit:
|
if area > limit:
|
||||||
raise _merged_limit_error(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.osv import OsvParser
|
||||||
from src.parser.readers import _READERS, read_book
|
from src.parser.readers import _READERS, read_book
|
||||||
from src.parser.readers._limits import (
|
from src.parser.readers._limits import (
|
||||||
|
LARGE_MERGED_COUNT_LIMIT,
|
||||||
MERGED_AREA_LIMIT,
|
MERGED_AREA_LIMIT,
|
||||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
||||||
check_merged_area,
|
check_merged_area,
|
||||||
@ -189,9 +190,20 @@ SMALL_REF = "A1:B2"
|
|||||||
SMALL_SPAN = MergedRange(1, 1, 2, 2)
|
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`
|
"""Минимальная книга 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/")
|
target = sheet_part.removeprefix("xl/")
|
||||||
parts = {
|
parts = {
|
||||||
"[Content_Types].xml": (
|
"[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>'
|
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>',
|
"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()
|
out = io.BytesIO()
|
||||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
for name, content in parts.items():
|
for name, content in parts.items():
|
||||||
archive.writestr(name, content)
|
archive.writestr(name, content)
|
||||||
|
archive.writestr(sheet_part, sheet.encode(sheet_encoding))
|
||||||
for name, raw in (extra or {}).items():
|
for name, raw in (extra or {}).items():
|
||||||
archive.writestr(name, raw)
|
archive.writestr(name, raw)
|
||||||
return out.getvalue()
|
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:
|
def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
|
||||||
"""Части без расширения .xml сканируются, только если похожи на XML; бинарные (картинки, printerSettings) и
|
"""Части без расширения .xml сканируются все, но мягко: бинарные (картинки, printerSettings), текст и не вполне
|
||||||
не вполне корректный VML книгу не ломают: openpyxl их не разбирает."""
|
корректный VML книгу не ломают — ошибка разбора лишь заканчивает скан части (openpyxl их не разбирает)."""
|
||||||
extra = {
|
extra = {
|
||||||
"xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
|
"xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
|
||||||
"xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
|
"xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
|
||||||
"xl/drawings/vmlDrawing1.vml": b"<xml><v:shape><br></xml>",
|
"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")
|
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")
|
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`) ---
|
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
||||||
|
|
||||||
|
|
||||||
@ -426,6 +487,35 @@ def test_xls_with_whole_column_merge_is_read(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
assert sheets == (sheet,)
|
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 ---
|
# --- Распакованный размер 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»+КПП) | числовая колонка ИНН с потерянными нулями без заголовка «ИНН» не определится автоматически |
|
| 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 остаются до решений платформы |
|
| 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 или лимита памяти платформы; файлы приходят от аутентифицированных пользователей внутренней ИС |
|
| 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` с текстом про лимит: сумма
|
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
|
||||||
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
|
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
|
||||||
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
|
суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
|
||||||
потоковым разбором `xml.parsers.expat` всех XML-частей архива (R-25: записи `*.xml` и записи, начало которых похоже на
|
потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
|
||||||
XML, — в любом каталоге; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
|
без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
|
||||||
префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
|
префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
|
||||||
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
|
для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
|
||||||
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается). Книга не открывается (в том числе битый XML части
|
`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
|
||||||
|
`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части
|
||||||
`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
|
`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
|
||||||
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
|
превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
|
||||||
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
|
таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user