M8: a dictionary pays the image allowance for every stage of its filters

content(), which reads each JBIG2 dictionary to know copies of it, charged the
run budget for what its filters made at the end, a unit for each 4 KiB. That
says nothing of the work: three Flates can inflate a 204-byte stream to
32 MiB, a 1-bit TIFF predictor run over all of it, and ASCIIHex skip it as
white space to nothing. The Resources review of f802198f4 measured 1.33 s for
one unit, apply returning Ok after 26.6 s with 20 of them in 11 KB, about 50 to
70 minutes a MB, never refused; remove_hidden and Optimize's discard too.

It now decodes through the run's ImageBudget (decode_filters), which pays for
what every stage reads and makes, as the images stored again already are. Past
it the run is refused with ImagesPastAllowance, which remove_hidden gives as
SharedJbig2Budget. The run budget still counts the decoded size.

a_dictionary_pays_for_every_stage_of_its_filters: two Flates of a MiB of zero
bytes under ASCIIHex spend one unit of the run and more than a MiB of the
allowance; with a MiB of allowance content() and unshare are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-02 18:27:00 +01:00
1 parent f802198f4a
commit b1f2216115
2 files changed
+56 -12

No files matched your search

+20 -9
View File
@@ -39,7 +39,8 @@ pub(crate) fn followed(doc: &Document, o: &Object) -> Arc<Object> {
/// ([`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<Option<u64>, 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<Option<u64>, 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<u32>,
contents: &HashSet<u64>,
read: &mut HashMap<u32, Option<u64>>,
images: &mut ImageBudget,
) -> Result<bool, crate::RedactError> {
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<u32>, images: &mut ImageBudget) -> Result<usize, crate::RedactError> {
let now = kept(doc);
@@ -536,7 +547,7 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet<u32>, images: &mut Im
}
let mut contents: HashSet<u64> = 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<u32>, 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);
}
}
+36 -3
View File
@@ -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") };