M2.6: redaction reads past /Crypt, and what can't be averaged spends nothing

Redaction checked the raw /Filter array, so /Crypt in a chain removed an image it could have cleared,
though decoding leaves /Crypt out. It leaves it out too.

Decoded only to be resampled, an image that can't be averaged came to None after the decode, having
spent the run's allowance and perhaps been counted as refused. Each such case now stops before it.

Found by CodeRabbit.

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-10-31 17:36:44 +01:00
1 parent bd5bcb7f10
commit 199354246b
3 files changed
+52 -6

No files matched your search

+34 -1
View File
@@ -1102,7 +1102,10 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
// JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone.
let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4;
let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0)));
if only_to_resample && !downsample {
// What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that
// would spend the run's allowance on it.
let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS));
if only_to_resample && (!downsample || defaults || too_wide || (jbig2 && dark_is_one(doc, d).is_none())) {
return None;
}
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?;
@@ -1398,6 +1401,36 @@ mod dimension_tests {
assert_eq!(changed, 2, "the third is as it was");
}
/// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it
/// takes nothing from the images that can be, and isn't counted as refused past the allowance.
#[test]
fn a_jbig2_image_that_cant_be_averaged_spends_nothing() {
let image = |edit: &dyn Fn(&mut Dict)| {
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
edit(&mut dict);
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap())
};
let settings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE };
let spent = |s: &Stream, defaults: bool| {
let mut budget = ImageBudget::with_work(u64::MAX);
let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget);
u64::MAX - budget.work_left()
};
let plain = image(&|_| {});
assert!(spent(&plain, false) > 0, "one that can be averaged is decoded");
assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped");
let wide = image(&|d| d.set(b"Width".to_vec(), Object::Int(i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS) + 1)));
assert_eq!(spent(&wide, false), 0, "too wide to average");
let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())])));
assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark");
}
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
#[test]
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
+11 -5
View File
@@ -59,13 +59,19 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF"));
let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice());
let readable = |f: &Object| f.as_name().is_some_and(supported);
// `/Crypt` was undone when the object was loaded (ISO 32000-2 §7.4.10), and decoding leaves it
// out wherever it stands.
let crypt = |f: &Object| f.as_name() == Some(b"Crypt".as_slice());
let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") {
None => (true, false, false),
Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)),
Some(Object::Array(a)) => match a.split_last() {
Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)),
_ => (a.iter().all(readable), false, false),
},
Some(f @ Object::Name(_)) => (readable(f) || crypt(f) || fax(f) || jbig2(f), fax(f), jbig2(f)),
Some(Object::Array(a)) => {
let chain: Vec<&Object> = a.iter().filter(|f| !crypt(f)).collect();
match chain.split_last() {
Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(|f| readable(f)), fax(last), jbig2(last)),
_ => (chain.iter().all(|f| readable(f)), false, false),
}
}
_ => (false, false, false),
};
if !ok {
+7
View File
@@ -804,6 +804,13 @@ fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() {
(
format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"),
hex,
vec![globals.clone()],
[0x00, 0xFF, 0xFF, 0xFF],
),
// `/Crypt` was undone on load, and takes a place in the parameters.
(
format!("{image} /ColorSpace /DeviceGray /Filter [/Crypt /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"),
white.clone(),
vec![globals],
[0x00, 0xFF, 0xFF, 0xFF],
),