diff --git a/README.md b/README.md index e2530c5..1272ef3 100644 --- a/README.md +++ b/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 МБ тегов `` — 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 МБ тегов `` — 8,5 с CPU (в пределах + лимита распакованного zip, 500 МиБ); +- **крупные merged-диапазоны в пределах потолка** (`LARGE_MERGED_COUNT_LIMIT` = 1000 на лист): индекс `Sheet` по-прежнему + просматривает их линейно (≈ 31 мкс на обращение); интервальный индекс — не сделан. Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же — настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком. diff --git a/api/src/parser/readers/_limits.py b/api/src/parser/readers/_limits.py index 3978714..20e3c1a 100644 --- a/api/src/parser/readers/_limits.py +++ b/api/src/parser/readers/_limits.py @@ -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 КБ с одним `` заставляет 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имя» # Разделитель «пространство имён — локальное имя»: в 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) diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py index 057aa78..412b783 100644 --- a/api/tests/unit/parser/test_read_limits.py +++ b/api/tests/unit/parser/test_read_limits.py @@ -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 = '' + + +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'' ), "xl/styles.xml": f'{_STYLES}', - sheet_part: ( - f'' - 'Оборотно-сальдовая ведомость по счету 60' - f"{merge_cells}" - ), } + sheet = ( + f'{sheet_head}' + 'Оборотно-сальдовая ведомость по счету 60' + f"{merge_cells}" + ) out = io.BytesIO() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive: for name, content in parts.items(): archive.writestr(name, content) + 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"
", + "xl/notes.txt": b" " * 100 + b"plain text, not XML", + "xl/media/empty.bin": b"", } sheets, _ = read_book(_hand_xlsx(_merges(f''), 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''), 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''), 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 --- diff --git a/openspec/changes/slice-2a-osv-parser/design.md b/openspec/changes/slice-2a-osv-parser/design.md index b90645c..ea004a5 100644 --- a/openspec/changes/slice-2a-osv-parser/design.md +++ b/openspec/changes/slice-2a-osv-parser/design.md @@ -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) | diff --git a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md index 3ed6abb..fee2e57 100644 --- a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md +++ b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md @@ -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`;