mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 15:49:36 +00:00
crates/cos/src/document.rs: #2220 moved the header scan into object_headers; it also returns the position after `obj`, and reconstruct applies this PR's /XRef candidate check (same 1024-byte window) to each header there. Tests: kept both sides.
This commit is contained in:
commit
7bcf25991d
4 files changed
+238
-20
No files matched your search
+95
-11
@@ -1952,7 +1952,7 @@ impl Document {
|
||||
return;
|
||||
}
|
||||
let mut located = BTreeMap::new();
|
||||
for (num, offset, generation) in object_headers(&self.data) {
|
||||
for (num, offset, generation, _) in object_headers(&self.data) {
|
||||
located.insert(num, (offset, generation));
|
||||
}
|
||||
let mut filled = entries.to_map(&self.data);
|
||||
@@ -1989,9 +1989,17 @@ impl Document {
|
||||
let data = self.data.clone();
|
||||
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
|
||||
let mut objstms = Vec::new();
|
||||
for (num, offset, generation) in object_headers(&data) {
|
||||
// Offsets of objects that may be cross-reference streams: their dictionaries are the
|
||||
// trailers of files that have no `trailer` keyword (§7.5.8).
|
||||
let mut xref_streams = Vec::new();
|
||||
for (num, offset, generation, body) in object_headers(&data) {
|
||||
entries.insert(num, XrefEntry::InFile { offset, generation });
|
||||
objstms.push(num);
|
||||
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
|
||||
&& let Ok(at) = usize::try_from(offset)
|
||||
{
|
||||
xref_streams.push(at);
|
||||
}
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
|
||||
@@ -2015,20 +2023,44 @@ impl Document {
|
||||
let object_count = entries.len();
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
// Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
|
||||
let mut trailer = None;
|
||||
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
|
||||
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
|
||||
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
|
||||
// dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still
|
||||
// counts when nothing better is there.
|
||||
// A file whose only trailers are cross-reference streams (and whose `startxref` is gone
|
||||
// or points nowhere) keeps its /Info, /ID and /Encrypt this way.
|
||||
let rank = |at: usize, d: &Dict| {
|
||||
let root = self.try_get(d.reference(b"Root")?.num).ok()?;
|
||||
let Object::Dict(c) = &*root else { return None };
|
||||
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
|
||||
};
|
||||
let mut trailer: Option<((bool, usize), Dict)> = None;
|
||||
let mut from = 0;
|
||||
while let Some(p) = find(&data, b"trailer", from, data.len()) {
|
||||
from = p + 7;
|
||||
let mut lx = Lexer::new(&data, from);
|
||||
if let Ok(Object::Dict(d)) = lx.object()
|
||||
&& d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
|
||||
&& let Some(r) = rank(p, &d)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some(d);
|
||||
trailer = Some((r, d));
|
||||
}
|
||||
}
|
||||
for at in xref_streams {
|
||||
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
|
||||
&& s.dict.name(b"Type") == Some(b"XRef")
|
||||
&& let Some(r) = rank(at, &s.dict)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some((r, s.dict));
|
||||
}
|
||||
}
|
||||
let trailer = match trailer {
|
||||
Some(t) => t,
|
||||
Some((_, t)) => t,
|
||||
None => {
|
||||
let catalog = self
|
||||
.entries
|
||||
@@ -2042,7 +2074,9 @@ impl Document {
|
||||
}
|
||||
};
|
||||
let mut trailer = trailer;
|
||||
for k in [&b"Prev"[..], b"XRefStm"] {
|
||||
// What a cross-reference stream's dictionary carries for itself goes, as the chain
|
||||
// readers drop it: a save writes its own.
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
self.trailer = trailer;
|
||||
@@ -2071,8 +2105,9 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation).
|
||||
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
|
||||
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
|
||||
/// after `obj`).
|
||||
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
|
||||
let mut headers = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
@@ -2087,7 +2122,7 @@ fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16)> {
|
||||
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
|
||||
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||
if let Some(num) = num {
|
||||
headers.push((num, i as u64, generation));
|
||||
headers.push((num, i as u64, generation, lx.pos));
|
||||
}
|
||||
i = lx.pos;
|
||||
continue;
|
||||
@@ -3321,6 +3356,55 @@ endobj
|
||||
assert_compact_matches_materialized(&doc);
|
||||
}
|
||||
|
||||
/// A file whose only trailers are cross-reference stream dictionaries, with its `startxref`
|
||||
/// out of reach (here a second file appended after it, cut off before its own
|
||||
/// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used
|
||||
/// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after
|
||||
/// editing one Info field dropped every other.
|
||||
#[test]
|
||||
fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() {
|
||||
let table = build(
|
||||
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) /Creator (Me) >>"],
|
||||
"/Root 1 0 R /Info 3 0 R",
|
||||
);
|
||||
let packed = crate::write_full(&Document::open(Arc::new(table)).unwrap(), &crate::SaveOptions::default()).unwrap();
|
||||
let text = String::from_utf8_lossy(&packed);
|
||||
assert!(text.contains("/XRef") && !text.contains("trailer"), "a cross-reference stream is the only trailer");
|
||||
let mut bytes = packed.clone();
|
||||
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
|
||||
bytes.extend_from_slice(appended.as_bytes());
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log());
|
||||
let info = doc.trailer().reference(b"Info").expect("the cross-reference stream's /Info");
|
||||
let field = |d: &Document, key: &[u8]| d.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
|
||||
assert_eq!(field(&doc, b"Title"), b"Kept");
|
||||
assert!(doc.trailer().get(b"ID").is_some(), "and its /ID");
|
||||
let mut edited = doc.clone();
|
||||
edited.update_dict(info, |d| d.set(b"Subject".to_vec(), Object::String(crate::PdfString::literal("new")))).unwrap();
|
||||
let saved = crate::write_full(&edited, &crate::SaveOptions::default()).unwrap();
|
||||
let back = Document::open(Arc::new(saved)).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
let info = back.trailer().reference(b"Info").expect("the full save keeps /Info");
|
||||
let field = |key: &[u8]| back.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
|
||||
assert_eq!((field(b"Title"), field(b"Creator"), field(b"Subject")), (b"Kept".to_vec(), b"Me".to_vec(), b"new".to_vec()));
|
||||
}
|
||||
|
||||
/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a
|
||||
/// later cross-reference stream whose /Root points at the Info dictionary does not replace an
|
||||
/// earlier good trailer, or a full save would write the Info dictionary as /Root.
|
||||
#[test]
|
||||
fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() {
|
||||
let mut bytes =
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R");
|
||||
bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n");
|
||||
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
|
||||
bytes.extend_from_slice(appended.as_bytes());
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log());
|
||||
assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary");
|
||||
assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_xref_is_reconstructed() {
|
||||
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
|
||||
|
||||
@@ -2439,6 +2439,71 @@ trailer << /Root 1 0 R >>
|
||||
assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads");
|
||||
}
|
||||
|
||||
/// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its
|
||||
/// cross-reference data, and the first's only trailer is a cross-reference stream. The
|
||||
/// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer`
|
||||
/// keyword and failed, so Document Properties showed every field empty. The rebuild now
|
||||
/// takes the cross-reference stream's dictionary as the trailer.
|
||||
#[test]
|
||||
fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() {
|
||||
let header = "%PDF-1.5\n";
|
||||
let obj1 = "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n";
|
||||
let obj2 = "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n";
|
||||
let obj3 = "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\n";
|
||||
let obj4 = "4 0 obj<</Title(Kept)/Creator(Me)>>endobj\n";
|
||||
let o1 = header.len();
|
||||
let o2 = o1 + obj1.len();
|
||||
let o3 = o2 + obj2.len();
|
||||
let o4 = o3 + obj3.len();
|
||||
let o5 = o4 + obj4.len();
|
||||
let rows: Vec<u8> =
|
||||
[[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]]
|
||||
.concat();
|
||||
let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes();
|
||||
bytes.extend_from_slice(format!("5 0 obj<</Type/XRef/Root 1 0 R/Info 4 0 R/Size 6/W[1 2 1]/Length {}>>stream\n", rows.len()).as_bytes());
|
||||
bytes.extend_from_slice(&rows);
|
||||
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
|
||||
bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n");
|
||||
bytes.extend_from_slice(&[b'x'; 1024]);
|
||||
|
||||
let info = inspect(Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
assert_eq!(info.title.as_deref(), Some("Kept"));
|
||||
assert_eq!(info.creator.as_deref(), Some("Me"));
|
||||
}
|
||||
|
||||
/// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later
|
||||
/// cross-reference stream whose /Root points at the Info dictionary used to replace the
|
||||
/// earlier `trailer`, so Document Properties lost the catalog it should have read.
|
||||
#[test]
|
||||
fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() {
|
||||
let mut bytes = b"%PDF-1.5\n".to_vec();
|
||||
for body in [
|
||||
"<</Type/Catalog/Pages 2 0 R>>",
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>",
|
||||
"<</Title(Kept)/Creator(Me)>>",
|
||||
] {
|
||||
let n = bytes.iter().filter(|b| **b == b'\n').count();
|
||||
bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(b"trailer\n<</Size 5/Root 1 0 R/Info 4 0 R>>\n");
|
||||
bytes.extend_from_slice(b"5 0 obj<</Type/XRef/Root 4 0 R/Size 6/W[1 2 1]/Length 4>>stream\n\x01\x00\x09\x00\nendstream\nendobj\n");
|
||||
// A wrong startxref, and a second file after %%EOF: every reader rebuilds the table.
|
||||
bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n");
|
||||
bytes.extend_from_slice(&[b'x'; 1024]);
|
||||
|
||||
let info = inspect(Arc::new(bytes.clone()), None).unwrap();
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
assert_eq!(info.title.as_deref(), Some("Kept"));
|
||||
// A later stream whose /Root names the catalog's number with another generation does
|
||||
// not count either: loading `1 1 R` finds no such object.
|
||||
let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap();
|
||||
bytes[at..at + 10].copy_from_slice(b"/Root 1 1 ");
|
||||
let info = inspect(Arc::new(bytes), None).unwrap();
|
||||
assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_structure_matches_the_compatibility_inspector() {
|
||||
// Each fixture as lopdf writes it, and with its objects in object streams.
|
||||
|
||||
Vendored
+1
-1
@@ -19,7 +19,7 @@ In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device spac
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependencyLine truncated
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dLine truncated
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` |
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` |
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retriLine truncated
|
||||
|
||||
Vendored
+77
-8
@@ -1627,8 +1627,38 @@ impl Reader<'_> {
|
||||
Some((number, generation))
|
||||
}
|
||||
|
||||
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object.
|
||||
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object, one whose
|
||||
/// `/Root` is the catalog first.
|
||||
fn find_latest_trailer(&self, xref: &Xref) -> Option<(usize, Dictionary)> {
|
||||
// PdfCraft patch: (#2365) an in-file /Root must also match its generation, as
|
||||
// `get_offset` requires when the root is loaded.
|
||||
let usable = |dict: &Dictionary| {
|
||||
dict.get(b"Root")
|
||||
.and_then(Object::as_reference)
|
||||
.is_ok_and(|root| match xref.entries.get(&root.0) {
|
||||
Some(XrefEntry::Normal { generation, .. }) => *generation == root.1,
|
||||
entry => entry.is_some(),
|
||||
})
|
||||
};
|
||||
// PdfCraft patch: (#2365) a candidate whose /Root is the catalog (`/Type /Catalog` or
|
||||
// `/Pages`) beats one whose /Root is another dictionary, such as /Info, wherever it is;
|
||||
// the other kind is still taken when nothing better is there (an FDF's /Root).
|
||||
let is_catalog = |dict: &Dictionary| {
|
||||
let Ok(root) = dict.get(b"Root").and_then(Object::as_reference) else {
|
||||
return false;
|
||||
};
|
||||
let Some(XrefEntry::Normal { offset, generation }) = xref.entries.get(&root.0) else {
|
||||
return false;
|
||||
};
|
||||
if *generation != root.1 {
|
||||
return false;
|
||||
}
|
||||
parser::indirect_object(self.buffer, *offset as usize, None, self, &mut HashSet::new(), None)
|
||||
.ok()
|
||||
.and_then(|(_, object)| object.as_dict().map(|d| d.has_type(b"Catalog") || d.has(b"Pages")).ok())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut latest: Option<(bool, usize, Dictionary)> = None;
|
||||
let mut bound = self.buffer.len();
|
||||
for _ in 0..MAX_TRAILER_CANDIDATES {
|
||||
let Some(pos) = Reader::search_substring(&self.buffer[..bound], b"trailer", 0) else {
|
||||
@@ -1643,15 +1673,54 @@ impl Reader<'_> {
|
||||
let Ok((_, dict)) = parser::dictionary(&after_keyword[dict_start..]) else {
|
||||
continue;
|
||||
};
|
||||
if dict
|
||||
.get(b"Root")
|
||||
.and_then(Object::as_reference)
|
||||
.is_ok_and(|root| xref.entries.contains_key(&root.0))
|
||||
{
|
||||
return Some((pos, dict));
|
||||
if usable(&dict) {
|
||||
let catalog = is_catalog(&dict);
|
||||
if latest.is_none() || catalog {
|
||||
latest = Some((catalog, pos, dict));
|
||||
}
|
||||
if catalog {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
// PdfCraft patch: (#2365) a file whose only trailer is a cross-reference stream has
|
||||
// no `trailer` keyword, so the scan above found nothing and the rebuild failed; its
|
||||
// /Info and /ID were then lost. Such a stream's dictionary is the trailer, and the
|
||||
// latest one in the file wins, as a later `trailer` keyword would.
|
||||
const HEADER_WINDOW: usize = 32;
|
||||
const DICT_WINDOW: usize = 4096;
|
||||
for entry in xref.entries.values() {
|
||||
let XrefEntry::Normal { offset, .. } = entry else {
|
||||
continue;
|
||||
};
|
||||
let offset = *offset as usize;
|
||||
if latest.as_ref().is_some_and(|(catalog, pos, _)| *catalog && *pos >= offset) {
|
||||
continue;
|
||||
}
|
||||
let Some(head) = self.buffer.get(offset..) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dict_start) = head[..cmp::min(head.len(), HEADER_WINDOW)]
|
||||
.windows(2)
|
||||
.position(|window| window == b"<<")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let dict_bytes = &head[dict_start..cmp::min(head.len(), dict_start + DICT_WINDOW)];
|
||||
if !dict_bytes.windows(5).any(|window| window == b"/XRef") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_, dict)) = parser::dictionary(&head[dict_start..]) else {
|
||||
continue;
|
||||
};
|
||||
if dict.has_type(b"XRef") && usable(&dict) {
|
||||
let rank = (is_catalog(&dict), offset);
|
||||
if latest.as_ref().is_none_or(|(catalog, pos, _)| rank > (*catalog, *pos)) {
|
||||
latest = Some((rank.0, offset, dict));
|
||||
}
|
||||
}
|
||||
}
|
||||
latest.map(|(_, pos, dict)| (pos, dict))
|
||||
}
|
||||
|
||||
/// Some generators write `startxref` (or trailer `Prev`) values that are
|
||||
|
||||
Reference in new issue
Block a user