diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16739462..9cf1d3f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -122,6 +122,11 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- Let `/Prev` and `/XRefStm` be ordinary PDF dictionary keys: they mark an unsupported + incremental update only as trailer keys, so an outline item's `/Prev` no longer makes the + whole document unreadable (#427). +- 允许 `/Prev` 与 `/XRefStm` 作为普通 PDF 字典键:仅当它们出现在 trailer 中才表示不支持的增量更新,大纲项里的 `/Prev` 不再使整份文档被判不可读(#427)。 + - Add current-scope `ops_status` diagnostics and public `diagnose(ctx, agent)` inspection; report integration, host/runtime, presentation and external provider readiness without executing or activating capabilities (#440). - 新增当前作用域 `ops_status` 与公开 `diagnose(ctx, agent)`,区分接入、宿主/运行时、展示及外部提供者状态,不执行或激活能力(#440)。 - Expose opt-in Native capability providers with original names/contracts, explicit preset allow/startup/disable controls, per-instance selection invalidation and provider-owned authorization; reuse scoped discovery publication without requiring the OPS worker for external-only sessions (#440). diff --git a/docs/native-kernel.md b/docs/native-kernel.md index 35bd580a..4200fdff 100644 --- a/docs/native-kernel.md +++ b/docs/native-kernel.md @@ -246,7 +246,10 @@ xref table, an exact final startxref/EOF footer, unique dictionary keys and obje identities, and directly declared stream lengths. Every active xref offset must point exactly at its object header. Objects cannot overlap; gaps must contain only whitespace or comments. This deliberately rejects incremental updates, encryption, object/xref -streams, recovery scans and ambiguous duplicate data, including decoded forbidden names. +streams, recovery scans and ambiguous duplicate data. `/Encrypt`, `/ObjStm` and `/XRef` +are refused wherever they appear, decoded escapes included, while `/Prev` and `/XRefStm` are +refused only as trailer keys — the one place they denote an earlier xref section — so a +dictionary that merely names them, such as an outline item's previous sibling, stays readable. Bounds are checked before indexed allocation: 16 MiB per input, 10,000 xref entries, 100,000 metadata values, depth 64, 4,096 keys per dictionary, 256 bytes per name/token, diff --git "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" index ad24d94b..2d8a0006 100644 --- "a/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" +++ "b/docs/\345\216\237\347\224\237\345\206\205\346\240\270.md" @@ -196,8 +196,10 @@ FILE_CHANGED 始终使所请求页不完整。引用页生成的 source 类型 内部 PDF 容器解析器仅接受受限的 PDF 1.0–1.7 子集:单个传统 xref 表、 准确的末尾 startxref/EOF、唯一字典键及对象标识,以及直接整数形式的流长度。 每个有效 xref 偏移必须准确指向对象头;对象不可重叠,间隙只能包含空白或注释。 -增量更新、加密、对象流/xref 流、恢复扫描及重复歧义数据均被拒绝,名称转义后 -仍会检查不支持的结构名称。 +增量更新、加密、对象流/xref 流、恢复扫描及重复歧义数据均被拒绝。 +`/Encrypt`、`/ObjStm` 与 `/XRef` 无论出现在哪里都会被拒绝,名称转义后也不例外; +`/Prev` 与 `/XRefStm` 仅作为 trailer 键时才表示较早的 xref 区段并被拒绝, +因此仅使用这些名称的普通字典(如大纲项的前一同级引用)仍可读取。 在索引分配前实施边界:输入 16 MiB、xref 条目 10,000、元数据值 100,000、 嵌套深度 64、每字典 4,096 个键、名称/词元 256 字节、字符串及编码/解码流 diff --git a/native/crates/ops-documents/src/pdf_container.rs b/native/crates/ops-documents/src/pdf_container.rs index 3936e2c3..d5a2debb 100644 --- a/native/crates/ops-documents/src/pdf_container.rs +++ b/native/crates/ops-documents/src/pdf_container.rs @@ -193,6 +193,16 @@ pub fn parse(data: &[u8]) -> Result> { } let trailer = lexer.value(0)?; let trailer = trailer.dict()?; + // Only a trailer key means "this file has an earlier xref section"; the same + // names inside an object dictionary are ordinary keys (outline items carry + // `/Prev` for their previous sibling). + for key in [b"Prev".as_slice(), b"XRefStm"] { + if trailer.contains_key(key) { + return Err(unsupported( + "Encrypted, incremental, object-stream and xref-stream PDFs are unsupported", + )); + } + } let size = get(trailer, b"Size")?.integer()?; if size == 0 || size > MAX_OBJECTS diff --git a/native/crates/ops-documents/src/pdf_container_tests.rs b/native/crates/ops-documents/src/pdf_container_tests.rs index 706d3a13..81d5f979 100644 --- a/native/crates/ops-documents/src/pdf_container_tests.rs +++ b/native/crates/ops-documents/src/pdf_container_tests.rs @@ -75,7 +75,7 @@ fn duplicate_keys_and_escaped_unsupported_names_are_rejected() { ] { rejected(&fixture(&[value]), "DOCUMENT_PARSE_ERROR"); } - for name in ["Encrypt", "Pr#65v", "XRefStm", "ObjStm", "XRef"] { + for name in ["Encrypt", "Enc#72ypt", "ObjStm", "XRef"] { rejected( &fixture(&[format!("<< /{name} null >>").as_bytes()]), "UNSUPPORTED_DOCUMENT", @@ -83,6 +83,32 @@ fn duplicate_keys_and_escaped_unsupported_names_are_rejected() { } } #[test] +fn outline_prev_is_an_ordinary_key_while_a_trailer_prev_stays_unsupported() { + // An outline item names its previous sibling with /Prev, which is not an + // incremental-update marker; refusing the token made the whole document + // unreadable (#427). + let pdf = fixture(&[ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>", + b"<< /Type /Pages /Kids [] /Count 0 >>", + b"<< /Type /Outlines /First 4 0 R /Last 5 0 R /Count 2 >>", + b"<< /Title (First) /Parent 3 0 R /Next 5 0 R >>", + b"<< /Title (Second) /Parent 3 0 R /Prev 4 0 R /XRefStm 4 0 R >>", + ]); + let document = parse(&pdf).unwrap(); + let second = document.object((5, 0)).unwrap().value.dict().unwrap(); + assert!(matches!(get(second, b"Prev").unwrap(), Value::Ref(4, 0))); + // The same names still refuse a file that really has an earlier xref section. + for key in [b"/Prev".as_slice(), b"/Pr#65v", b"/XRefStm"] { + let mut trailer = b"/Root 1 0 R ".to_vec(); + trailer.extend_from_slice(key); + trailer.extend_from_slice(b" 9 0 R >>"); + rejected( + &replace(&pdf, b"/Root 1 0 R >>", &trailer), + "UNSUPPORTED_DOCUMENT", + ); + } +} +#[test] fn offset_alias_overlap_and_unindexed_data_are_never_recovered() { let pdf = fixture(&[b"<<>>", b"(value)"]); let document = parse(&pdf).unwrap(); diff --git a/native/crates/ops-documents/src/pdf_syntax.rs b/native/crates/ops-documents/src/pdf_syntax.rs index d540cb18..01438da5 100644 --- a/native/crates/ops-documents/src/pdf_syntax.rs +++ b/native/crates/ops-documents/src/pdf_syntax.rs @@ -187,6 +187,13 @@ impl<'a> Lexer<'a> { } Err(bad("Unclosed PDF literal string")) } + /// A name is a token, not a policy decision. + /// + /// `/Prev` and `/XRefStm` only describe an incremental update while they are + /// trailer keys, and an outline item legitimately carries `/Prev` as its + /// previous sibling. Those two are therefore refused where a trailer is read + /// (`pdf_container::parse`), not here; `/Encrypt`, `/ObjStm` and `/XRef` + /// stay refused at the token because no supported structure can hold them. fn name(&mut self) -> Result> { self.at += 1; let mut name = Vec::new(); @@ -213,10 +220,7 @@ impl<'a> Lexer<'a> { return Err(limit("PDF name exceeds 256 bytes")); } } - if matches!( - name.as_slice(), - b"Encrypt" | b"Prev" | b"XRefStm" | b"ObjStm" | b"XRef" - ) { + if matches!(name.as_slice(), b"Encrypt" | b"ObjStm" | b"XRef") { return Err(unsupported( "Encrypted, incremental, object-stream and xref-stream PDFs are unsupported", ));