redact: the shared-JBIG2 walk names by object number, and reads what it stores by number and generation

Main's #1143 (863af864a), merged in 77b9cfa31, makes cos read a reference whose generation isn't
its object's as null. The walk that finds what shares a /JBIG2Globals dictionary with what left
fetched through `Document::get`, but remembered what it read by object number alone, so a
reference of another generation and the object's own one shared what the first of them read:

- What a parameters value names (`Run::names_in`): `/DecodeParms [10 1 R 10 0 R]` on an image
  that left, object 10 naming dictionary 9, read 10 1 R as null, named nothing, and remembered
  that for object 10. The 10 0 R after it found it, so nothing named 9, and the dictionary
  stayed in the file with the image another page kept.
- A size, filters or parameters an image is stored again with (`Run::follow`): an image whose
  `/Width 11 1 R` reads as null can't be stored again, and refused the run alone. Once another
  image had read 11 0 R, it took that width and was stored with it.

What an image names is now read by object number, whatever generation the reference gives
(`Run::fetch_numbered`), in `names_in` and in the `/JBIG2Globals` chain alike: a reader that
doesn't compare generations draws with that object, so what an image that left names this way
may hold its shapes. That is how the chain already named `9 1 R` (the "another generation" way
out), and how both read before #1143. What is read by number is remembered by number. The
values an image is stored again with are read as cos reads them, and remembered by number and
generation, so a reference of another generation reads its own way.

`kept`'s comment said a reference reads the object of its number whatever its generation, which
is no longer so for a full save. It says how `reachable_objects` reads one now. The README and
`Run`'s comments say which reads take the number and which the generation too.

Tests:
- `a_redacted_scans_shared_jbig2_dictionary_leaves_the_file` gains three ways out:
  - parameters through another generation, then their own;
  - parameters through another generation alone;
  - a `/JBIG2Globals` reference to a reference of another generation.
- `a_size_through_another_generation_isnt_the_one_its_own_reference_reads` stores no image
  with the width another reference read. It refuses instead.

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-09 12:38:38 +01:00
1 parent 77b9cfa310
commit 0a00862e91
3 files changed
+73 -14

No files matched your search

+4 -1
View File
@@ -34,7 +34,10 @@ clear_marks(&mut doc, None)?; // remove marks without app
the same way, but it has changed the document by the time it refuses: a caller passes a copy it
can drop. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy
nothing names, and an image that hasn't left keeps its globals with its pixels: one another page
still draws, or one something that doesn't draw it still refers to. Finding what to store again
still draws, or one something that doesn't draw it still refers to. What an image names as
globals is read by object number, whatever generation a reference gives, as a reader that doesn't
compare generations draws it (cos reads such a reference as null); what one is stored again with,
its size, filters and parameters, is read as cos reads it. Finding what to store again
reads each object at most once for each way it is read (a `/JBIG2Globals` chain named part way,
or a ring of references or a chain of more than 32, may be read again), and what it fetches, remembers and decodes (a decode that
fails counting as much as it was allowed, and an image the dictionaries it names) counts against
+28 -13
View File
@@ -15,8 +15,9 @@ use pdfcraft_cos::{CosError, DECODE_PARMS_KEYS, Dict, Document, ImageBudget, MAX
/// The references followed from `/JBIG2Globals` towards a stream, so that a ring of them ends.
const MAX_HOPS: usize = 32;
/// The objects a full save would keep, by number: a reference reads the object of its number,
/// whatever generation it gives. Fails only when a full save couldn't number them all.
/// The objects a full save would keep, by number, as [`reachable_objects`] finds them: a reference
/// whose generation isn't its object's reads as null there (#1143), so it keeps nothing. Fails only
/// when a full save couldn't number them all.
pub(crate) fn kept(doc: &Document) -> Result<HashSet<u32>, CosError> {
reachable_objects(doc)
}
@@ -75,8 +76,11 @@ enum How {
}
/// What one redaction or Remove Hidden Information run reads, to find what shares a dictionary with
/// what left, against [`RUN_BUDGET`]. Each object is fetched at most once a run for each way it is
/// read, and kept only as what is read of it: a value many streams share, by reference, through a
/// what left, against [`RUN_BUDGET`]. What a stream names is read by object number, whatever
/// generation a reference gives ([`Run::fetch_numbered`]); what one is stored again with is read as
/// cos reads it, by number and generation, so a reference of another generation reads its own way.
/// Each object is fetched at most once a run for each way it is read, and kept only as what is read
/// of it: a value many streams share, by reference, through a
/// chain of them or through an entry, costs it once however many there are. The exceptions are a
/// `/JBIG2Globals` chain named part way, which is followed again from there, and a ring of
/// references or a chain past the limit, read again (up to 33 fetches) wherever it is met; every
@@ -85,8 +89,10 @@ pub(crate) struct Run {
budget: usize,
/// What was spent of the budget.
pub(crate) spent: usize,
/// What a reference leads to, as it is read, under every reference on the way.
reduced: HashMap<(u32, How), Rc<Object>>,
/// What a reference leads to, as it is read, under every reference on the way, by number and
/// generation: one whose generation isn't its object's reads as null (#1143), and must not give
/// another reference to that number what it read, nor take what that one read.
reduced: HashMap<(pdfcraft_cos::ObjRef, How), Rc<Object>>,
/// What a reference leads to names, under every reference on the way, with the key of the one
/// that names what it ends at: aliases and chains find it, and are counted as that one.
names: HashMap<Key, (Rc<[u32]>, Key)>,
@@ -119,10 +125,19 @@ impl Run {
Ok(doc.get(r))
}
/// Fetches the object of `r`'s number, whatever generation `r` gives, counted. Cos reads a
/// reference whose generation isn't its object's as null (#1143), but a reader that doesn't
/// compare generations draws with the object: what an image that left names this way may hold
/// its shapes, so it is named, as what names it this way is a user of it.
fn fetch_numbered(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result<Arc<Object>, crate::RedactError> {
self.fetch(doc, pdfcraft_cos::ObjRef::new(r.num, doc.generation(r.num)))
}
/// What `v` leads to through references, as `followed` goes, kept as `read` makes it. Each
/// reference is looked up before it is fetched, and what it leads to is remembered under every
/// one on the way, so one remembered costs nothing more, and an alias or a chain only the
/// references before it. A ring, or a chain past the limit, is read as the reference it ends at.
/// one on the way, so one remembered costs nothing more, and a chain only the references before
/// it. A reference of another generation is looked up as itself, and read as cos reads it. A
/// ring, or a chain past the limit, is read as the reference it ends at.
fn follow(
&mut self,
doc: &Document,
@@ -130,17 +145,17 @@ impl Run {
how: How,
read: impl FnOnce(&mut Self, &Document, &Object) -> Result<Object, crate::RedactError>,
) -> Result<Rc<Object>, crate::RedactError> {
let mut keys: Vec<(u32, How)> = Vec::new();
let mut keys: Vec<(pdfcraft_cos::ObjRef, How)> = Vec::new();
let mut fetched: Option<Arc<Object>> = None;
for _ in 0..=MAX_HOPS {
let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break };
if let Some(hit) = self.reduced.get(&(r.num, how)).cloned() {
if let Some(hit) = self.reduced.get(&(r, how)).cloned() {
for k in keys {
self.reduced.insert(k, hit.clone());
}
return Ok(hit);
}
keys.push((r.num, how));
keys.push((r, how));
fetched = Some(self.fetch(doc, r)?);
}
let made = Rc::new(read(self, doc, fetched.as_deref().unwrap_or(v))?);
@@ -177,7 +192,7 @@ impl Run {
return Ok((hit, Some(at)));
}
keys.push(key);
fetched = Some(self.fetch(doc, r)?);
fetched = Some(self.fetch_numbered(doc, r)?);
}
self.read += 1;
let held = fetched.as_deref().unwrap_or(v);
@@ -227,7 +242,7 @@ impl Run {
break;
}
found.push(at.num);
match &*self.fetch(doc, at)? {
match &*self.fetch_numbered(doc, at)? {
Object::Ref(next) => at = *next,
_ => break,
}
+41
View File
@@ -1932,6 +1932,27 @@ fn ways_out(scan: &SharedScan) -> Vec<(&'static str, [f64; 4], SharedPages)> {
SharedPages { first: format!("{} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)), ..base() },
),
("another generation", COVERS, SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 9 1 R >>"), ..base() }),
// Cos reads a reference of another generation as null (#1143); a reader that doesn't compare
// generations draws with what it names. Read first, it mustn't hide what the other names.
(
"parameters through another generation, then their own",
COVERS,
SharedPages {
first: shared_image("/DecodeParms [10 1 R 10 0 R]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"),
extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()],
..base()
},
),
(
"parameters through another generation",
COVERS,
SharedPages { first: shared_image("/DecodeParms 10 1 R"), extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()], ..base() },
),
(
"a reference to it of another generation",
COVERS,
SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 10 1 R >>"), extra: vec![b"9 0 R".to_vec()], ..base() },
),
]
}
@@ -1954,6 +1975,26 @@ fn a_redacted_scans_shared_jbig2_dictionary_leaves_the_file() {
}
}
/// A size given through a reference of another generation is null, as cos reads it (#1143), whatever
/// another image read through the reference of that number's own: the image can't be stored again, so
/// the redaction refuses, rather than storing it with the size the other reference read.
#[test]
fn a_size_through_another_generation_isnt_the_one_its_own_reference_reads() {
let scan = shared_scan();
let stale = shared_image(SHARED).replace("/Width 400", "/Width 11 1 R");
let mut doc = SharedPages {
content: [DRAWS, b" q 200 0 0 20 0 200 cm /Stale Do Q".as_slice()].concat(),
resources: "/XObject << /Im 7 0 R /Stale 12 0 R >>".into(),
other: shared_image(SHARED).replace("/Width 400", "/Width 11 0 R"),
extra: vec![b"null".to_vec(), b"400".to_vec(), stream(&stale, scan.page.clone())],
..SharedPages::new(&scan)
}
.build(&scan);
mark(&mut doc, 0, &[COVERS], "");
let result = apply(&mut doc, None);
assert!(matches!(result, Err(RedactError::SharedJbig2Globals(1))), "{result:?}");
}
/// However the image that stays names the dictionary, it comes out without it: its parameters by
/// reference, behind another filter, or a copy of the dictionary, as a file put back together from
/// pages split apart has, each page with its own. It is also read when its size or filter is given