## What this changes
`read_xref_chain_materialized` in `crates/cos/src/document/reader.rs` keeps a running total of the rows it merges across the cross-reference chain. Past `MAX_XREF_OBJECTS` (8,388,607) it returns `CosError::Syntax` ("cross-reference chain lists more than 8388607 entries"). The open path then rebuilds the index from object headers and logs the repair, as it already does when a single section decodes past that cap. Before this, the per-section cap and the 10,000-section chain limit multiplied, so the merged map had no overall bound.
Each section adds `sect.len() + odd.len()`: its entries plus the rows it set aside, including those from a hybrid `/XRefStm`. Set-aside rows are kept for the whole chain, and a section can set aside nearly everything it lists. The count is of rows merged, not the map's size, because a chain that relists the same objects costs as much to merge. A section's rows never outnumber what the compact reader counts for it, and that reader stops at 2,000,000 rows. So no chain the compact reader accepts is refused here.
The limit itself lives in `read_xref_chain_materialized_with_limit` (`pub(super)`). The existing entry point, `read_xref_chain_materialized`, calls it with `MAX_XREF_OBJECTS`, so tests can reach the limit in a few rows. Both functions have doc comments.
Fixes #5082
## How you verified it
Three regression tests in `crates/cos/src/document/tests.rs`. The two small-limit tests were suggested by @Harrison-Booth in review.
1. `a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed` runs end to end at the production cap. It builds a synthetic file:
- a catalog, a page tree with one page, and a chain of three cross-reference streams;
- the newest stream locates objects 0 to 6;
- two older Flate-compressed streams each list 4,200,000 rows (`/W [1 0 0]`, disjoint `/Index` ranges). Each is half the per-section cap and too large for the compact reader. Their rows are in use at offset 0, so they are set aside, which keeps the test at a few bytes a row.
It asserts that `Document::open` logs the cap with "rebuilding from object headers" and then "rebuilt cross-reference index ...". The rebuilt document must keep `/Root 1 0 R` and the `/Page`. `read_xref_chain_materialized` must return `CosError::Syntax` at the oldest stream's offset.
2. `the_chain_row_limit_counts_disjoint_and_relisted_rows` uses three classic tables of 2, 1 and 1 rows. A limit of 4 passes, and a limit of 3 refuses the chain at the oldest table. This holds when the sections list different objects, and when one relists an object a newer section has: that is a map of three entries but four rows merged.
3. `the_chain_row_limit_counts_hybrid_rows_set_aside` uses a hybrid file whose table puts object 3 at an offset while its `/XRefStm` puts it in an object stream. It merges six rows and sets object 3 aside. A limit of 7 passes, and a limit of 6 refuses it.
Seen failing, each by reverting part of the fix in the working tree:
| Revert | Test 1 | Test 2 | Test 3 |
|---|---|---|---|
| no chain check at all | fails (empty repair log) | fails | fails |
| count without `sect.len()` | passes | fails | fails |
| count without `odd.len()` (state before 780764cb) | fails | passes | fails |
| fix restored | passes | passes | passes |
All three run in about a second together.
Commands, all with `-j 2` and a separate `CARGO_TARGET_DIR`:
- `cargo fmt -p pdfcraft-cos --check`: clean
- `cargo clippy -p pdfcraft-cos -j 2 --all-targets --locked -- -D warnings`: clean
- `cargo test -p pdfcraft-cos -j 2 --locked`: 95 unit tests and 39 integration tests pass (2 ignored)
- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the crates I changed
- [x] tests pass on the crates I changed
- [x] n/a — `cargo xtask layers` / `cargo xtask assets`: no crate boundary or asset touched
## Never-crash
- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests (the offset conversion uses `unwrap_or(usize::MAX)`)
- [x] Checked arithmetic: `saturating_add` per section, `checked_add` for the running total; the change bounds an allocation sized by input
- [x] No `unsafe`
- [x] The unbounded allocation this fixes has a small synthetic regression test
## Provenance
- [x] n/a — no assets of any kind
- [x] n/a — no font files
- [x] No GPL/AGPL code copied in
- [x] Behaviour comes from ISO 32000 (§7.5.8 cross-reference streams, the Annex C object limit) and the crate's existing escalation path
- [x] n/a — no new assets, so no `ATTRIBUTION.toml` entry
## Security
- [x] n/a — no file paths or tool dispatch touched (`--root` confinement unaffected)
- [x] n/a — MCP and control channel untouched
- [x] No new outbound network access
## Parity and reach
- [x] n/a — hardening of the cross-reference reader, not a parity feature; no `parity/` entry changes
- [x] n/a — no new behaviour to expose; every `automation` tool that opens a document gets the cap
- [x] n/a — no documented behaviour changes
## Anything a reviewer should look at first
- The check runs after each section is read. So one section, already under the per-section cap, is decoded before the chain is refused. Peak memory stays bounded by the cap plus one section.
- The row limit is a parameter of a `pub(super)` function so tests can set it small. Production always passes `MAX_XREF_OBJECTS`, and the error text names whichever limit applied.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Bug Fixes**
* Documents with cross-reference chains that exceed the supported total size now report a syntax error at the section where the limit is exceeded.
* The limit applies across the entire chain, including repeated object numbers and set-aside entries, so individually valid sections cannot exceed the total cap.
* When opening an affected document, the index can be rebuilt from object headers, allowing available catalog and page content to be recovered.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
pdfcraft-cos
The PDF object layer (ISO 32000-2 §7): the COS object graph, parsed lazily and tolerantly, edited through a copy-on-write overlay, and written back incrementally or as a full rewrite.
- Layer: L1, standalone. The layering table lets it use only
pdfcraft-filters,pdfcraft-cryptandpdfcraft-geom(xtask/src/layers.rs::STANDALONE_DEPS); it currently uses the first two. That keeps the parser and the crypto independently auditable and reusable. #![forbid(unsafe_code)], and the crate deniesunwrap/expect/panic!/todo!. Builds forwasm32-unknown-unknown.
The object graph is the document model. Nothing is lifted into a tidier representation and
written back out, because that is how data gets silently dropped. Typed views live above, in
pdfcraft-model.
API
let doc = Document::open(bytes)?; // Arc<Vec<u8>>
let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files
let root = doc.root(); // Option<ObjRef> — the catalog
let obj = doc.get(ObjRef::new(12, 0)); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow a bounded reference chain
let d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too
let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults
doc.set(r, Object::Dict(d)); // edit: lands in the overlay
let r = doc.add(obj); // append a new object
doc.free(r);
let bytes = write_incremental(&doc, &SaveOptions::default())?; // append a revision
let bytes = write_full(&doc, &SaveOptions::default())?; // garbage-collected rewrite
Both writers return the bytes; the caller decides where they go, which is what lets the engine produce a working file in memory after every edit and keep saving atomic.
Useful alongside: doc.repair_log() (what was reconstructed), doc.revisions() and
doc.revision_ends() (the incremental history), doc.is_edited(num) and doc.modified_objects(),
doc.permissions() and doc.security(), doc.set_encryption() / doc.remove_encryption(), and
doc.require_full_save() for edits that cannot be expressed incrementally.
Document is cheap to clone: the original bytes, the xref index and the parse caches are
shared, so a clone is a snapshot. Undo/redo and background jobs hold clones while the UI edits
another. Edits never touch the original bytes.
Reading
- Every cross-reference form: classic tables with their quirky subsections, cross-reference
streams, hybrid-reference files (
/XRefStm), and object streams. - Repair. A file whose xref is wrong or missing is reconstructed by scanning for
objheaders. Repairs are recorded, never silent, so the UI can say the file was damaged. A live in-file reference may match either its xref generation or a same-number object header at that indexed offset; a missing, free, or neither-matching slot still resolves to null. Header probes inspect at most 128 bytes at the indexed and header-relative offsets, without parsing object bodies. On open, disagreements in retained xref revisions are recorded once per object, up to 256 objects plus an omitted-details summary. This diagnostic scan does not expand compact xref ranges; the winning xref entry remains authoritative for free slots. Compressed objects require generation zero. Explicitsetoverlays accept only the supplied generation;set_existingandupdate_dictretain both accepted generations of a damaged live slot, including through snapshots, and require full rewrite.accepted_generations(num)lists the generations a slot accepts, so code comparing two revisions can tell whether a reference still reaches the same slot. Headers beyond this bounded probe are not additionally recovered. Materialized primary and hybrid rows are diagnosed before folding, so a newer row does not hide a retained disagreement. - Tolerant, never fatal. The lexer works on a byte slice and a position; it allocates nothing
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
CosError, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. - Lazy. Objects are parsed on first use and cached. (Whole-file buffering is still the case;
ByteSourcestreaming iscore.lazy-loading, not yet done.) - Encryption is handled on load and save through
pdfcraft-crypt: every standard-security revision R2–R6 (RC4, AES-128/256), crypt filters,/EFFembedded-file encryption, and/EncryptMetadata.security.rsknows what the spec exempts — the/Encryptdictionary, xref streams, the trailer/ID, and objects inside an object stream (the stream is encrypted whole).
Writing
write_incremental ordinarily appends only the edited objects, a cross-reference section in
the same style as the file's last one, and a trailer with /Prev, preserving the original
bytes and signatures over earlier revisions. It delegates to write_full when reconstruction,
encryption changes or full_save_required() demand a rewrite. Repair-aware edits to a live
xref/header generation disagreement use this fallback rather than append a contradictory row.
write_full writes only what the trailer can reach, renumbered densely — the Save As and
garbage-collecting path. By default (SaveOptions::object_streams) it packs non-stream objects
into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a
classic table any reader accepts. Both forms map accepted xref/header aliases to one output
object, retaining shared identity such as page-tree parent links; unrelated generations stay null.
A full save keeps the file's /ID as it is, unless the document asked for a new identifier with
Document::require_full_save_with_new_id (redaction and sanitizing do). Then both strings are
regenerated, and SaveOptions::id_entropy can supply entropy for reproducible output (tests pin
output this way). Without it the identifier mixes in fresh entropy from the host — the browser's
crypto.getRandomValues on the web, the operating system's random hash seed on the desktop — so a
fresh copy of the program cannot reproduce it. An encrypted file keeps the first string, because its
key is derived from it.
Dictionaries keep their key order and streams keep their encoded bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized.
Errors
CosError: NotPdf, Syntax { offset, detail }, MissingObject, NotADictionary, Filter,
NeedsPassword, WrongPassword, Security, Poisoned.
Not done here
- Streaming very large files within a memory budget (
ByteSource,core.lazy-loading); files are read whole today. - Linearized output, Fast Web View (
core.linearization, M11). - Structural validation against the Arlington model (
core.arlington-validation), which will live in the reservedarlingtoncrate. - Image codecs.
pdfcraft-filterscarries the non-image filters and CCITT fax and JBIG2 decoding (Document::decoded_image, a JBIG2 image's/JBIG2GlobalswithinMAX_JBIG2_GLOBALS, 32 MiB, their filters undone once a document for the images that share them, up to 64 MiB kept, while their segments are decoded for every image, andImageBudgetfor an operation over many images, which share one allowance of decoding, filters, decoders and pixels alike, and are refused past it:IMAGE_WORK, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at 300 dpi, plusIMAGE_WORK_PER_BYTE, 2^13 steps for each byte of the file); DCT and JPX are still decoded through the bootstrap renderer (core.image-filters, M2.6).