M8: the shared JBIG2 refusal says what keeps the data

When nothing that names the dictionary is stuck and something else refers
to it (a private key, say), the refusal said 1 item(s) couldn't be stored
without it. It now counts 0 and says something else refers to the data.

remove_hidden's refusal gets a test: the comment has gone by then, and the
text doesn't say nothing was changed.

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 08:26:31 +01:00
1 parent 13422bf279
commit b3b47b651d
3 files changed
+60 -11

No files matched your search

+3 -3
View File
@@ -133,8 +133,8 @@ fn without_dictionary(doc: &Document, s: &Stream) -> Option<Stream> {
/// more.
///
/// Errors: [`RedactError::SharedJbig2Globals`](crate::RedactError::SharedJbig2Globals) when a dictionary would
/// still be kept, as what names it can't be stored without it or something else refers to it. Such
/// an image may still be drawn, on a page no mark reached, so it isn't removed, and `doc` is part
/// 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). 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>) -> Result<usize, crate::RedactError> {
let now = kept(doc);
@@ -176,7 +176,7 @@ pub(crate) fn unshare(doc: &mut Document, before: &HashSet<u32>) -> Result<usize
}
let after = kept(doc);
if dictionaries.iter().any(|n| after.contains(n)) {
return Err(crate::RedactError::SharedJbig2Globals(stuck.max(1)));
return Err(crate::RedactError::SharedJbig2Globals(stuck));
}
Ok(users.len())
}
+12 -5
View File
@@ -73,11 +73,9 @@ pub enum RedactError {
TooDeep,
/// `/JBIG2Globals` used by something removed would stay: a JBIG2 image shares them and can't be
/// stored again without them, or something else refers to them. Either way the shapes they
/// hold, those of marks under the area included, would stay. Counts the images, or 1 for another
/// reference.
#[error(
"{0} item(s) share JBIG2 symbol data with something being removed and can't be stored without it, so that data, which can show what was removed, would stay in the file; this was refused"
)]
/// hold, those of marks under the area included, would stay. Counts what can't be stored without
/// them: 0 when only something else keeps them.
#[error("{}", shared_jbig2_text(*.0))]
SharedJbig2Globals(usize),
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
@@ -87,6 +85,15 @@ pub enum RedactError {
Cos(#[from] pdfcraft_cos::CosError),
}
/// [`RedactError::SharedJbig2Globals`]'s text, which says what keeps the data.
fn shared_jbig2_text(stuck: usize) -> String {
let why = match stuck {
0 => "something else in the file refers to JBIG2 symbol data shared with something being removed".to_string(),
n => format!("{n} item(s) share JBIG2 symbol data with something being removed and can't be stored without it"),
};
format!("{why}, so that data, which can show what was removed, would stay in the file; this was refused")
}
/// A redaction mark on a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Mark {
+45 -3
View File
@@ -1660,8 +1660,8 @@ fn an_alternate_of_what_stays_stays() {
assert_eq!(kept, 1);
}
/// The refusal counts the images that can't be stored, or says 1 for something else that refers to
/// the dictionary.
/// The refusal counts the images that can't be stored, and says so; when only something else refers
/// to the dictionary, it counts none and says that instead, however many such references there are.
#[test]
fn a_refused_redaction_counts_what_cant_be_stored() {
let scan = shared_scan();
@@ -1669,7 +1669,23 @@ fn a_refused_redaction_counts_what_cant_be_stored() {
let other = format!("{wrong} /SMask 10 0 R");
let mut doc = SharedPages { other, extra: vec![stream(&wrong, scan.page.clone())], ..SharedPages::new(&scan) }.build(&scan);
mark(&mut doc, 0, &[CLEARS], "");
assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(2))));
let refused = apply(&mut doc, None);
assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(2))));
assert!(refused.unwrap_err().to_string().starts_with("2 item(s) share JBIG2 symbol data with something being removed and can't be stored"));
let note = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 1 /ColorSpace /DeviceGray /Note 9 0 R";
for (other, extra, stuck) in [
(note.to_string(), vec![], 0),
(format!("{note} /SMask 10 0 R"), vec![stream(note, vec![0])], 0),
(format!("{note} /SMask 10 0 R"), vec![stream(&wrong, scan.page.clone())], 1),
] {
let mut doc = SharedPages { other: other.clone(), other_data: vec![0], extra, ..SharedPages::new(&scan) }.build(&scan);
mark(&mut doc, 0, &[CLEARS], "");
let refused = apply(&mut doc, None);
assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(n)) if n == stuck), "{other}");
let text = refused.unwrap_err().to_string();
assert_eq!(text.starts_with("something else in the file refers to JBIG2 symbol data"), stuck == 0, "{other}: {text}");
assert_eq!(text.contains("can't be stored"), stuck > 0, "{other}: {text}");
}
}
/// Remove Hidden Information leaves the same way: comments go, with what their appearances drew.
@@ -1691,6 +1707,32 @@ fn removing_hidden_information_takes_a_shared_jbig2_dictionary_too() {
assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits);
}
/// Remove Hidden Information refuses as a redaction does when the dictionary can't leave, but it has
/// changed the document by then, so the refusal doesn't say nothing was changed.
#[test]
fn a_refused_removal_of_hidden_information_doesnt_say_nothing_changed() {
let scan = shared_scan();
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >>", DRAWS);
let annotation = b"<< /Type /Annot /Subtype /Stamp /Rect [0 100 200 120] /AP << /N 11 0 R >> >>".to_vec();
let mut doc = SharedPages {
content: Vec::new(),
resources: String::new(),
page: "/Annots [10 0 R]".into(),
other: shared_image(SHARED).replace("/Filter /JBIG2Decode", "/Filter /FlateDecode"),
other_data: pdfcraft_filters::encode_flate(&scan.bits),
extra: vec![annotation, form],
..SharedPages::new(&scan)
}
.build(&scan);
let annotations = |doc: &Document| crate::annots_of(doc, &pdfcraft_model::pages(doc)[0].dict).len();
assert_eq!(annotations(&doc), 1);
let refused = crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]);
assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(1))));
assert_eq!(annotations(&doc), 0, "the comment went before the refusal");
let text = refused.unwrap_err().to_string();
assert!(text.ends_with("this was refused") && !text.contains("nothing was changed"), "{text}");
}
/// [`assert_no_secret`] for a file that may hold JBIG2 streams, which `decoded` doesn't read: a stream
/// stored again can be coded alone.
fn assert_gone(doc: &Document, secret: &[u8]) {