mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
Never crash: the fuzz job's eleven hangs finish (parser retries, inline images, Type 3 fan-out)
Every hang the nightly fuzz job found (11 findings, 8 source files) now finishes in 0.1-2 s; each cause has a synthetic regression test that times out without its fix. - cos (7 findings, our own parser): a stream whose /Length pointed at itself, with no endstream, made every parse attempt fail and each attempt reload the same object: ~2^32 parses. Objects being loaded are a seen-set, /Length values are memoised per load, and the shifted-header retry only runs when the header is shifted. - hayro-syntax (3): deciding where an inline image's data ends re-parsed the rest of the stream, nesting through every inline image; 50 images ran for over ten minutes. The look-ahead no longer nests and is bounded (256). - hayro-interpret (1): a Type 3 glyph showing eight glyphs of its own font, sixteen deep. Nested paints share a per-page budget (50,000). The pdf.js corpus sweep matches its baseline (963 ok, same 20 known). xtask check writes its report under CARGO_TARGET_DIR too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f97684f0d8
commit
2f364665b8
+60
-18
@@ -13,21 +13,29 @@ use crate::object::{Dict, ObjRef, Object};
|
||||
use crate::parser::{Lexer, is_whitespace, parse_indirect};
|
||||
|
||||
thread_local! {
|
||||
static LOAD_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
|
||||
static LOADING: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
|
||||
/// retries a parse several ways, and each retry would resolve the same lengths again
|
||||
/// (exponential in the chain of streams whose lengths point at further streams).
|
||||
static LENGTHS: std::cell::RefCell<HashMap<u32, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Nesting counter for object loads on this thread (see `Document::try_get`).
|
||||
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
|
||||
/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is
|
||||
/// bounded as well.
|
||||
struct LoadGuard;
|
||||
|
||||
impl LoadGuard {
|
||||
const MAX: u32 = 32;
|
||||
const MAX: usize = 32;
|
||||
|
||||
fn enter() -> Option<Self> {
|
||||
LOAD_DEPTH.with(|d| {
|
||||
if d.get() >= Self::MAX {
|
||||
fn enter(num: u32) -> Option<Self> {
|
||||
LOADING.with(|l| {
|
||||
let mut l = l.borrow_mut();
|
||||
if l.len() >= Self::MAX || l.contains(&num) {
|
||||
return None;
|
||||
}
|
||||
d.set(d.get() + 1);
|
||||
l.push(num);
|
||||
Some(LoadGuard)
|
||||
})
|
||||
}
|
||||
@@ -35,7 +43,14 @@ impl LoadGuard {
|
||||
|
||||
impl Drop for LoadGuard {
|
||||
fn drop(&mut self) {
|
||||
LOAD_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
|
||||
let outermost = LOADING.with(|l| {
|
||||
let mut l = l.borrow_mut();
|
||||
l.pop();
|
||||
l.is_empty()
|
||||
});
|
||||
if outermost {
|
||||
LENGTHS.with(|m| m.borrow_mut().clear());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +486,7 @@ impl Document {
|
||||
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
|
||||
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
|
||||
let _guard =
|
||||
LoadGuard::enter().ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
let obj = self.load(num, 0)?;
|
||||
let obj = Arc::new(obj);
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
|
||||
@@ -485,16 +500,24 @@ impl Document {
|
||||
match self.entries.get(&num) {
|
||||
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
|
||||
Some(XrefEntry::InFile { offset, .. }) => {
|
||||
let resolve = |r: ObjRef| self.try_get(r.num).ok().and_then(|o| o.as_int());
|
||||
let resolve = |r: ObjRef| {
|
||||
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
|
||||
return known;
|
||||
}
|
||||
let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
|
||||
LENGTHS.with(|m| m.borrow_mut().insert(r.num, length));
|
||||
length
|
||||
};
|
||||
let off = *offset as usize;
|
||||
match parse_indirect(&self.data, off, &resolve) {
|
||||
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
|
||||
// Offsets relative to a shifted header, or simply wrong: try both fixes.
|
||||
_ => match parse_indirect(&self.data, off + self.header_offset, &resolve) {
|
||||
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
|
||||
_ => self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num)),
|
||||
},
|
||||
}
|
||||
let at = |off: usize| match parse_indirect(&self.data, off, &resolve) {
|
||||
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
|
||||
_ => None,
|
||||
};
|
||||
// Offsets relative to a shifted header, or simply wrong: try both fixes.
|
||||
at(off)
|
||||
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
|
||||
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
|
||||
.ok_or(CosError::MissingObject(num))
|
||||
}
|
||||
Some(XrefEntry::InStream { stream, index }) => {
|
||||
let stm = self.objstm(*stream)?;
|
||||
@@ -1082,4 +1105,23 @@ mod tests {
|
||||
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
|
||||
// From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream`
|
||||
// after it. Every parse attempt failed, and each attempt resolved the length by loading
|
||||
// object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses.
|
||||
// Objects being loaded are now a seen-set, lengths are memoised, and the second parse
|
||||
// only runs when the header is shifted.
|
||||
let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
|
||||
bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(doc) = Document::open(Arc::new(bytes)) {
|
||||
let _ = doc.try_get(6);
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,6 +868,59 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap");
|
||||
}
|
||||
|
||||
/// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own
|
||||
/// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen
|
||||
/// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs,
|
||||
/// tiling patterns inside one another) share a per-page budget.
|
||||
#[test]
|
||||
fn type3_glyphs_that_fan_out_into_themselves_finish() {
|
||||
let pdf = "%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 53 >> stream
|
||||
BT /F1 20 Tf 10 10 Td (A) Tj ET 1 0 0 rg 0 0 4 4 re f
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1]
|
||||
/Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj
|
||||
6 0 obj << /Length 48 >> stream
|
||||
1 0 d0 BT /F1 1 Tf (AAAAAAAA) Tj ET 0 0 1 1 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
|
||||
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
|
||||
});
|
||||
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a self-multiplying Type 3 glyph must not stall the renderer");
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
|
||||
}
|
||||
|
||||
/// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed
|
||||
/// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the
|
||||
/// data, the parser re-read the rest of the stream as content, and that re-read did the same
|
||||
/// for every inline image it met, nesting through the whole stream: 50 images (3 KB) ran for
|
||||
/// over ten minutes. Vendored hayro-syntax patch: the re-read doesn't nest, and the search is
|
||||
/// bounded.
|
||||
#[test]
|
||||
fn inline_images_with_ei_in_their_data_parse_in_linear_time() {
|
||||
let image = b"q 1 0 0 1 0 0 cm\nBI\n/IM true\n/W 8\n/H 2\n/BPC 1\nID \x01 EI \x02\nEI Q\n";
|
||||
let content: Vec<u8> = image.iter().copied().cycle().take(image.len() * 200).collect();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut iter = hayro::hayro_syntax::content::TypedIter::new(&content);
|
||||
let mut ops = 0usize;
|
||||
while iter.next().is_some() {
|
||||
ops += 1;
|
||||
}
|
||||
let _ = tx.send(ops);
|
||||
});
|
||||
let ops = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("inline images must not stall the parser");
|
||||
assert!(ops >= 200 * 4, "every image and its q/cm/Q are read: {ops}");
|
||||
}
|
||||
|
||||
/// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data
|
||||
/// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`).
|
||||
#[test]
|
||||
|
||||
Vendored
+2
-2
@@ -7,10 +7,10 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
|
||||
|
||||
| Crate | Version | Licence | Patches | Test |
|
||||
|---|---|---|---|---|
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding` |
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish` |
|
||||
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render` |
|
||||
| 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); (2) predictor row widths use saturating arithmetic | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating` |
|
||||
| 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) | (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` |
|
||||
| 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) | (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` |
|
||||
|
||||
Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`.
|
||||
|
||||
+33
@@ -23,6 +23,32 @@ pub(crate) const MAX_NESTED_INTERPRETATION_DEPTH: u32 = 50;
|
||||
/// inherits, which recursed until the stack overflowed (found by fuzzing).
|
||||
pub(crate) const MAX_PAINT_NESTING: u32 = 16;
|
||||
|
||||
/// PrintCraft patch: how many nested paints (a form inside a form, a Type 3 glyph or tiling
|
||||
/// pattern painted from inside another one) one page may do. Nesting depth alone doesn't bound
|
||||
/// the work: a glyph that shows ten glyphs of its own font, nested sixteen deep, is 10^16
|
||||
/// paints (a fuzzed file hung). Ordinary pages stay far below this.
|
||||
pub(crate) const MAX_NESTED_PAINTS: u32 = 50_000;
|
||||
|
||||
std::thread_local! {
|
||||
static NESTED_PAINTS: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// PrintCraft patch: count a paint at `depth` (top-level ones are free); `false` once this
|
||||
/// page's budget is spent, and the paint is skipped.
|
||||
pub(crate) fn take_nested_paint(depth: u32) -> bool {
|
||||
if depth < 2 {
|
||||
return true;
|
||||
}
|
||||
NESTED_PAINTS.with(|n| {
|
||||
let used = n.get();
|
||||
if used >= MAX_NESTED_PAINTS {
|
||||
return false;
|
||||
}
|
||||
n.set(used + 1);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// A cache used by the interpreter.
|
||||
///
|
||||
/// Ideally, such a cache should be constructed once per PDF and then reused across
|
||||
@@ -75,6 +101,8 @@ impl<'a> Context<'a> {
|
||||
settings: InterpreterSettings,
|
||||
) -> Self {
|
||||
let state = State::new(initial_transform);
|
||||
// PrintCraft patch: a page (or other top-level content) starts with a fresh budget.
|
||||
NESTED_PAINTS.with(|n| n.set(0));
|
||||
|
||||
Self::new_with(initial_transform, bbox, cache, xref, settings, state, 0)
|
||||
}
|
||||
@@ -301,6 +329,11 @@ impl<'a> Context<'a> {
|
||||
|
||||
return false;
|
||||
}
|
||||
if !take_nested_paint(self.nesting_depth + 1) {
|
||||
warn!("nested paint budget exceeded");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self.nesting_depth += 1;
|
||||
|
||||
|
||||
+3
-1
@@ -125,7 +125,9 @@ impl<'a> Type3<'a> {
|
||||
) -> Option<()> {
|
||||
// PrintCraft patch: a glyph procedure may show text in this same font (through
|
||||
// inherited resources), recursing until the stack overflowed.
|
||||
if glyph.nesting_depth > crate::context::MAX_PAINT_NESTING {
|
||||
if glyph.nesting_depth > crate::context::MAX_PAINT_NESTING
|
||||
|| !crate::context::take_nested_paint(glyph.nesting_depth)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut state = glyph.state.clone();
|
||||
|
||||
+2
-2
@@ -231,8 +231,8 @@ impl<'a> TilingPattern<'a> {
|
||||
// PrintCraft patch: a tiling pattern can paint itself (directly, or through resources it
|
||||
// inherits when its own are missing), which recursed until the stack overflowed. Bound
|
||||
// the nesting like XObjects do, tighter because every level rasterizes a tile.
|
||||
if self.nesting_depth > crate::context::MAX_PAINT_NESTING {
|
||||
warn!("tiling pattern nesting depth exceeded");
|
||||
if self.nesting_depth > crate::context::MAX_PAINT_NESTING || !crate::context::take_nested_paint(self.nesting_depth) {
|
||||
warn!("tiling pattern nesting depth or paint budget exceeded");
|
||||
return None;
|
||||
}
|
||||
let state = State::new(initial_transform);
|
||||
|
||||
+20
-2
@@ -91,12 +91,22 @@ impl<'a> Readable<'a> for Operator<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PrintCraft patch: how many `EI` candidates an inline image examines, and how far past each
|
||||
/// one the end-of-data heuristic looks, before taking the current candidate. Unbounded, the
|
||||
/// heuristic re-parsed the rest of the stream for every candidate, and each re-parse ran the
|
||||
/// heuristic again on the inline images it met: a fuzzed file hung for minutes.
|
||||
const MAX_EI_CANDIDATES: u32 = 256;
|
||||
const MAX_EI_LOOKAHEAD_STEPS: u32 = 256;
|
||||
|
||||
/// An iterator over operators in the PDF content streams, providing raw access to the instructions.
|
||||
#[derive(Clone)]
|
||||
pub struct UntypedIter<'a> {
|
||||
reader: Reader<'a>,
|
||||
stack: Stack<'a>,
|
||||
operator: Option<Operator<'a>>,
|
||||
/// PrintCraft patch: this iterator only checks whether data reads as content (the
|
||||
/// end-of-data heuristic below); inline images it meets end at their first `EI`.
|
||||
lookahead: bool,
|
||||
}
|
||||
|
||||
impl<'a> UntypedIter<'a> {
|
||||
@@ -106,6 +116,7 @@ impl<'a> UntypedIter<'a> {
|
||||
reader: Reader::new(data),
|
||||
stack: Stack::new(),
|
||||
operator: None,
|
||||
lookahead: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +126,7 @@ impl<'a> UntypedIter<'a> {
|
||||
reader: Reader::new(&[]),
|
||||
stack: Stack::new(),
|
||||
operator: None,
|
||||
lookahead: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +180,7 @@ impl<'a> UntypedIter<'a> {
|
||||
|
||||
let stream_data = self.reader.tail()?;
|
||||
let start_offset = self.reader.offset();
|
||||
let mut candidates = 0;
|
||||
|
||||
'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") {
|
||||
self.reader.read_bytes(pos)?;
|
||||
@@ -197,8 +210,13 @@ impl<'a> UntypedIter<'a> {
|
||||
// new we don't bother trying to read it.
|
||||
let tail = &self.reader.tail()?[2..];
|
||||
let mut find_reader = Reader::new(tail);
|
||||
// PrintCraft patch: bounded (see `MAX_EI_CANDIDATES`).
|
||||
candidates += 1;
|
||||
let mut steps = 0;
|
||||
let heuristic = !self.lookahead && candidates <= MAX_EI_CANDIDATES;
|
||||
|
||||
while !find_reader.at_end() {
|
||||
while heuristic && !find_reader.at_end() && steps < MAX_EI_LOOKAHEAD_STEPS {
|
||||
steps += 1;
|
||||
let remaining = find_reader.tail()?;
|
||||
let next_ei = find_needle(remaining, b"EI");
|
||||
let next_bi = find_needle(remaining, b"BI");
|
||||
@@ -231,7 +249,7 @@ impl<'a> UntypedIter<'a> {
|
||||
// stream and there should be at least one text-related
|
||||
// operator that can be parsed correctly.
|
||||
|
||||
let mut iter = TypedIter::new(tail);
|
||||
let mut iter = TypedIter::from_untyped(UntypedIter { lookahead: true, ..UntypedIter::new(tail) });
|
||||
let mut found = false;
|
||||
let mut counter = 0;
|
||||
|
||||
|
||||
+9
-5
@@ -12,11 +12,15 @@ pub fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
/// The release `printcraft-cli` that `cargo build --release -p printcraft-cli` produced: under
|
||||
/// `CARGO_TARGET_DIR` when it is set (parallel agents use their own), else `target/`.
|
||||
/// Cargo's target directory: `CARGO_TARGET_DIR` when it is set (parallel agents use their own),
|
||||
/// else `target/`.
|
||||
pub fn target_dir() -> PathBuf {
|
||||
std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d))
|
||||
}
|
||||
|
||||
/// The release `printcraft-cli` that `cargo build --release -p printcraft-cli` produced.
|
||||
pub fn release_cli() -> PathBuf {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d));
|
||||
target.join("release").join(format!("printcraft-cli{}", std::env::consts::EXE_SUFFIX))
|
||||
target_dir().join("release").join(format!("printcraft-cli{}", std::env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn cargo() -> Command {
|
||||
@@ -163,7 +167,7 @@ pub fn check(args: &[String]) -> anyhow::Result<()> {
|
||||
bail!("corpus missing: run `cargo xtask corpus` first");
|
||||
}
|
||||
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
|
||||
let report = root().join("target/check-pdfjs.json");
|
||||
let report = target_dir().join("check-pdfjs.json");
|
||||
let mut c = Command::new(release_cli());
|
||||
c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report);
|
||||
run(c, "printcraft-cli check corpus/pdfjs")?;
|
||||
|
||||
Reference in New Issue
Block a user