diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs index ba12a8646b..6a71336f51 100644 --- a/crates/redact/src/globals.rs +++ b/crates/redact/src/globals.rs @@ -39,7 +39,8 @@ pub(crate) fn followed(doc: &Document, o: &Object) -> Arc { /// ([`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget)). A scan reads a few /// objects a page, and its pages stored again a unit for each 4 KiB of their samples and of the /// dictionary they name, so a book of thousands of pages sharing a dictionary of a MiB or two stays -/// within it. +/// within it. The work of decoding, every filter stage's and the JBIG2 decoder's, is paid besides +/// from the run's image allowance (`ImageBudget`), which a unit of decoded data says nothing of. pub(crate) const RUN_BUDGET: usize = 1 << 22; /// What a parameters value names as `/JBIG2Globals`, and the key it is remembered by: a value the @@ -384,11 +385,20 @@ impl Run { /// What dictionary `num` holds, to know a copy of it by: a file put together from pages split apart /// has one per page, each byte for byte the others. -pub(crate) fn content(run: &mut Run, doc: &Document, num: u32) -> Result, crate::RedactError> { +/// +/// Decoded within `images`, the run's image allowance, which pays for what every filter stage reads +/// and makes: what the last one makes says nothing of the work (a predictor over what three Flates +/// inflate, which hex then skips as white space, ends at nothing). Past it the run is refused +/// ([`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance)). +pub(crate) fn content(run: &mut Run, doc: &Document, num: u32, images: &mut ImageBudget) -> Result, crate::RedactError> { let Ok(object) = doc.try_get(num) else { return Ok(None) }; let Object::Stream(s) = &*object else { return Ok(None) }; let readable = run.readable(doc, s)?; - let data = doc.with_direct_parms(&readable).decoded_within(MAX_JBIG2_GLOBALS); + let refused = images.refused(); + let data = images.decode_filters(doc, &readable, MAX_JBIG2_GLOBALS); + if data.is_err() && images.refused() > refused { + return Err(crate::RedactError::ImagesPastAllowance); + } let mut hash = DefaultHasher::new(); match &data { Ok(data) => { @@ -480,6 +490,7 @@ fn matches_left( left: &HashSet, contents: &HashSet, read: &mut HashMap>, + images: &mut ImageBudget, ) -> Result { if left.contains(&n) { return Ok(true); @@ -487,7 +498,7 @@ fn matches_left( let held = match read.get(&n) { Some(held) => *held, None => { - let held = content(run, doc, n)?; + let held = content(run, doc, n, images)?; read.insert(n, held); held } @@ -505,14 +516,14 @@ fn matches_left( /// An image another page still draws hasn't left: it keeps its pixels, so its dictionary keeps nothing /// more. /// -/// What it reads and decodes is counted against [`RUN_BUDGET`] for the whole run, and each image -/// is decoded within `images`, the run's image allowance. +/// What it reads and decodes is counted against [`RUN_BUDGET`] for the whole run, and each +/// dictionary and image is decoded within `images`, the run's image allowance. /// /// Errors: [`RedactError::SharedJbig2Globals`](crate::RedactError::SharedJbig2Globals) when a dictionary would /// still be kept, as what names it can't be stored without it (counted) or something else refers to /// it (0 when that is all); [`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget) when /// finding that would spend more than the budget; [`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance) -/// when decoding the images would spend more than `images` has left. Such an image may still be drawn, on a page no mark +/// when decoding the dictionaries and images would spend more than `images` has left. Such an image may still be drawn, on a page no mark /// reached, so it isn't removed, and `doc` is part changed by then: a caller works on a copy it can drop. pub(crate) fn unshare(doc: &mut Document, before: &HashSet, images: &mut ImageBudget) -> Result { let now = kept(doc); @@ -536,7 +547,7 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet, images: &mut Im } let mut contents: HashSet = HashSet::new(); for &n in &dictionaries { - if let Some(c) = content(&mut run, doc, n)? { + if let Some(c) = content(&mut run, doc, n, images)? { contents.insert(c); } } @@ -562,7 +573,7 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet, images: &mut Im None => { let mut found = Vec::new(); for &n in list.iter() { - if matches_left(&mut run, doc, n, &left, &contents, &mut read)? { + if matches_left(&mut run, doc, n, &left, &contents, &mut read, images)? { found.push(n); } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 657ac1e5a8..fadb70adb7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1981,7 +1981,7 @@ fn an_image_is_charged_the_dictionaries_it_names() { let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 2 0 R >>"; let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("", vec![0u8; 40_000]), stream(image, b"not JBIG2")]); let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); - crate::globals::content(&mut run, &doc, 2).unwrap(); + crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap(); assert_eq!(run.spent, 10, "40,000 bytes: a unit and nine whole 4 KiB"); let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") }; assert!(matches!(crate::globals::without_dictionary(&mut run, &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(None))); @@ -1989,6 +1989,38 @@ fn an_image_is_charged_the_dictionaries_it_names() { assert_eq!(run.spent, 24); } +/// A dictionary pays the run's image allowance for what every stage of its filters reads and makes, +/// not for what the last one makes: two Flates inflate a MiB of zero bytes, which hex skips as white +/// space, so it decodes to nothing. Past the allowance the run is refused, through `unshare` too. +#[test] +fn a_dictionary_pays_for_every_stage_of_its_filters() { + let hollow = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 1 << 20])); + let dict = || stream("/Filter [/FlateDecode /FlateDecode /ASCIIHexDecode]", hollow.clone()); + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), dict()]); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let mut images = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run, &doc, 2, &mut images).unwrap().is_some()); + assert_eq!(run.spent, 1, "it decodes to nothing: a unit"); + let paid = u64::MAX - images.work_left(); + assert!(paid > 1 << 20, "the MiB the second Flate made is paid for: {paid} steps"); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let refused = crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + + // An image naming it leaves its page: finding copies of the dictionary reads it. + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>"; + let mut doc = one_page(b"q 8 0 0 8 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(image, b""), dict()]); + let before = crate::globals::kept(&doc); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + assert_eq!(crate::globals::unshare(&mut doc, &before, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(0)); +} + /// What can't be decoded is charged as much as it was allowed to decode, so going past a limit /// costs as much as reaching it: a dictionary `MAX_JBIG2_GLOBALS` (here one behind a filter that /// isn't decoded here), an image its allowance. @@ -1998,8 +2030,9 @@ fn a_decode_that_fails_is_charged_what_it_was_allowed() { let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("/Filter /DCTDecode", b"not a JPEG"), stream(image, b"not JBIG2")]); let units = pdfcraft_cos::MAX_JBIG2_GLOBALS / 4096 + 1; let run = |budget| crate::globals::Run::new(budget); - assert!(crate::globals::content(&mut run(units), &doc, 2).is_ok()); - assert_eq!(crate::globals::content(&mut run(units - 1), &doc, 2), Err(RedactError::SharedJbig2Budget)); + let images = || pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run(units), &doc, 2, &mut images()).is_ok()); + assert_eq!(crate::globals::content(&mut run(units - 1), &doc, 2, &mut images()), Err(RedactError::SharedJbig2Budget)); // The image's allowance: (one byte a row of 8, and a byte a row) × 3 a byte, and 4096: 4144 // bytes, two units. let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") };