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