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:
Andrey Epifancev 2026-10-05 18:11:19 +04:00
parent 9820a0e7c0
commit 1eb57da5cb
5 changed files with 158 additions and 50 deletions

View File

@ -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` без префикса;
ссылки на символы (`&#50;`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
сканируется каждая некаталожная запись zip, без отбора по имени или началу содержимого (openpyxl идёт к листу по цели
связи в `workbook.xml.rels`, расширение части не проверяет, а начало XML можно отодвинуть пробелами; бинарную часть
expat отвергает на первых байтах, ошибка разбора части не `*.xml` лишь заканчивает её скан, находка I-1б); считается
элемент с локальным именем `mergeCell` в любом пространстве имён (openpyxl берёт дочерние `mergeCell` по локальному
имени) и только атрибут `ref` без префикса; ссылки на символы (`&#50;`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `ref` (`CellRange`
openpyxl), ячейки не перебираются; отказ — сразу при превышении. Битый XML части `*.xml` и `ref`, который openpyxl не
разберёт, — `UNREADABLE`. Стоимость скана: самый большой реальный xlsx (454 КБ распакованно) — 12 мс при чтении
openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 МБ распакованно) — 0,8 с при чтении openpyxl 10 с.
@ -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` — решения платформы (они же —
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.

View File

@ -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)

View File

@ -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 ---

View File

@ -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) |

View File

@ -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`;