fix: close independent verification findings on document parsing

Zip construction and member reads now share one narrow boundary so corrupt BZIP2, LZMA and version fields cannot leak native exceptions. Non-success statuses are read before classification, text-bearing blocks require lines, the size cap and reasoning contract are pinned by tests that fail under mutation, and the per-page timing claim is corrected to the observed range.
This commit is contained in:
2026-10-22 01:12:27 -04:00
parent d80feafb93
commit bb621cad4d
9 changed files with 314 additions and 68 deletions
+96 -7
View File
@@ -11,6 +11,7 @@ table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH
import io
import json
import random
import struct
import zipfile
@@ -287,7 +288,7 @@ class TestPageStructure:
pytest.param("missing", id="missing"),
pytest.param("0", id="string"),
pytest.param(0.0, id="float"),
pytest.param(True, id="bool"),
pytest.param(True, id="bool-true-off-position"),
pytest.param(-1, id="negative"),
pytest.param(3, id="out-of-position"),
],
@@ -301,6 +302,20 @@ class TestPageStructure:
page["page_idx"] = page_idx
_invalid([page])
@pytest.mark.parametrize(
"pages",
[
pytest.param([_page([], page_idx=False)], id="false-equals-0-on-first-page"),
pytest.param(
[_page([], page_idx=0), _page([], page_idx=True)], id="true-equals-1-on-second-page"
),
],
)
def test_bool_page_idx_is_rejected_even_when_it_equals_the_position(self, pages):
"""`False == 0`、`True == 1`: 没有显式的 bool 守卫时它们恰好等于页序而被放行。"""
with pytest.raises(ResultInvalidError, match="page_idx"):
_parse(pages)
def test_second_page_must_not_claim_the_first_index(self):
_invalid([_page([], page_idx=0), _page([], page_idx=0)])
@@ -467,12 +482,15 @@ class TestExternalDataExceptionBoundary:
deep = b'{"pdf_info": ' + b"[" * 100_000 + b"]" * 100_000 + b"}"
_invalid(middle=deep)
@pytest.mark.parametrize("member", ["middle", "markdown"])
def test_declared_size_over_cap_is_rejected(self, member, monkeypatch):
monkeypatch.setattr(
_monkey_document, "MAX_MIDDLE_BYTES" if member == "middle" else "MAX_MARKDOWN_BYTES", 8
)
_invalid([_page([_text_block("A")])], md="# a markdown longer than eight bytes")
@pytest.mark.parametrize(
("cap_name", "member"),
[("MAX_MIDDLE_BYTES", "doc_middle.json"), ("MAX_MARKDOWN_BYTES", "doc.md")],
)
def test_declared_size_over_cap_names_the_offending_member(self, cap_name, member, monkeypatch):
"""断言消息里的成员名: 两个上限互换时,只断言异常类型的旧用例仍会通过。"""
monkeypatch.setattr(_monkey_document, cap_name, 8)
with pytest.raises(ResultInvalidError, match=member):
_parse([_page([_text_block("A")])], md="# a markdown longer than eight bytes")
def test_existing_result_invalid_messages_pass_through_unchanged(self):
with pytest.raises(ResultInvalidError, match="至少应有 1 页"):
@@ -499,3 +517,74 @@ class TestLayoutPathUntouched:
assert accepted[0].page_index == 7
fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")]))
assert fallback[0].page_index == 0
class TestZipLayerNeverLeaksNativeExceptions:
"""独立验证发现: BZIP2/LZMA 成员数据损坏会漏出 `OSError`/`LZMAError`,中央目录版本字段损坏会在
`ZipFile` 构造阶段漏出 `NotImplementedError`——它们都不在外层边界里。漏出的后果与设计 §5 一致:
`OcrClient._call` 只接 `PolyGatewayError`,既不写终态遥测行,也不归还半开探针。"""
@staticmethod
def _zip(method: int) -> bytes:
payload = json.dumps({"pdf_info": [_page([_text_block("SYNTH")])]}).encode() * 20
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=method) as zf:
zf.writestr("doc_middle.json", payload)
zf.writestr("doc.md", "# synth " * 50)
return buf.getvalue()
@pytest.mark.parametrize(
"method",
[
pytest.param(zipfile.ZIP_DEFLATED, id="deflated"),
pytest.param(zipfile.ZIP_BZIP2, id="bzip2"),
pytest.param(zipfile.ZIP_LZMA, id="lzma"),
],
)
def test_single_bit_flips_never_escape_as_native_exceptions(self, method):
"""确定性字节翻转(固定种子): 每个结果只能是成功或 `ResultInvalidError`。"""
base = self._zip(method)
rng = random.Random(34)
for _ in range(600):
data = bytearray(base)
position = rng.randrange(len(data))
data[position] ^= 1 << rng.randrange(8)
try:
parse_document_package(bytes(data))
except ResultInvalidError:
continue
except Exception as exc: # noqa: BLE001 — 测试要抓的正是任何漏出的原生异常
pytest.fail(
f"字节 {position} 翻转后漏出 {type(exc).__module__}.{type(exc).__name__}: {exc}"
)
@pytest.mark.parametrize("method", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
def test_corrupt_non_deflate_member_data(self, method):
"""压缩流内部损坏(而非 CRC 失败): 标准库抛 OSError / LZMAError。"""
data = bytearray(self._zip(method))
local = data.index(b"PK\x03\x04")
name_len, extra_len = struct.unpack_from("<HH", data, local + 26)
body = local + 30 + name_len + extra_len
for offset in range(body, body + 12):
data[offset] ^= 0xFF
with pytest.raises(ResultInvalidError):
parse_document_package(bytes(data))
def test_zipfile_construction_stage_is_inside_the_boundary(self):
"""中央目录的 extract_version 损坏: `ZipFile(...)` 构造阶段抛 NotImplementedError。"""
data = bytearray(self._zip(zipfile.ZIP_STORED))
central = data.index(b"PK\x01\x02")
data[central + 6] = 99 # extract_version = 9.9 → "zip file version 9.9"
with pytest.raises(ResultInvalidError):
parse_document_package(bytes(data))
class TestTextBearingBlocksRequireLines:
"""text/title/interline_equation 缺 `lines` 不是「空文本」而是畸形: 与 table 缺内容就报错同口径,
否则下游分不清「服务识别出空块」和「服务给了坏结构」。"""
@pytest.mark.parametrize("block_type", ["text", "title", "interline_equation"])
def test_missing_lines_is_rejected_with_its_reason(self, block_type):
block = {"type": block_type, "bbox": list(_BBOX)}
with pytest.raises(ResultInvalidError, match="缺少 lines"):
_parse([_page([block])])