Files
pdfcraft/crates/redact/src/image.rs
T
d7ce875dd6 redact: independent redaction proof machinery (snapshot, sweep, budget) (#5557)
* redact: add the independent redaction proof machinery

* redact: harden the proof's paint check and count removed codes

The manifest counts character codes, not raw bytes: a composite font's
codes are several bytes, so the byte length overstated what was cut.

Annotation appearances are checked whatever their storage (a direct
dictionary has no reference of its own to dedupe on), and a stream
shared by several annotations is checked at each annotation's own
placement. The graphics-state stack and the clip holes of one walk are
bounded; deeper or more is unverifiable, never unbounded memory.
Even-odd clips cut holes only where the rule can be told cheaply - all
subpaths axis-aligned, disjoint, few - and anything else keeps its
paint reported, failing closed. The /W widths of a descendant font
cost bounded work, like a CMap.

* redact: settle the survivors a page sweep counted before its work ran out

A page that the search budget stops on left the counts made on earlier
pages unsettled: the survivors were seen but never reported, and the
regions that owned them were never marked. The sweep now stops and
still settles, so a located survivor is reported where it sits (the
page reads unswept either way, and the proof still fails).

* redact: prove the redacted output of every supported encryption

The authenticated-reopen regression now covers all four crypt
algorithms the writer supports, not only AES-128: the caller's
credentials prove the saved output, and empty or wrong credentials
still fail closed.

* redact: answer the review round on the proof

A glyph lying under several regions is now accounted for by every one
of them, so overlapping marks each carry their own share in a shared
proof instead of the first one absorbing the rest and the others
reading as if no text was there.

Each finding is recorded once per region as it is found, so a page
that paints over a region a thousand times neither grows the report
nor the memory with repeats.

The byte search walks the haystack once (the pattern's prefix function
is built once), so a needle of one letter over a haystack full of it
can no longer spend comparisons the search budget never charged for.

A numeric XML character reference may carry any number of leading
zeros; the padded form now decodes like the short one, so a survivor
spelled that way in the XMP is still found.

The /W widths of a descendant font are paid for from the shared work
budget: a form invoked again rebuilds its fonts, and a per-font
counter alone let the same array be paid for many times over.

* redact: sweep the proof's survivors out of every place a reader could look

Plant the removed text where the review listed it and expect the sweep to
report it: a compressed object stream, a page thumbnail, a form field
value, an XFA packet, and an object nothing refers to. A file whose page
content is bigger than the proof may decode for one stream is now shown
to come out unverified on its surfaces and its region, never clean.

* redact: spare the names a redacted word legitimately sits in, and charge failed decodes

A redacted word that is also a structural name (a /Subtype, a /Filter, a
/BaseFont) is structure, not a leftover copy: the name surface is now
swept against the matches the input itself had, and only a surplus of
matches is a survivor. The input's names are counted when the snapshot is
taken, for the region's needles and for strings added later alike.

A decode that fails after doing part of its work paid nothing: the
proof's cumulative decode allowance now charges every attempt, failures
included, so the same bomb cannot be decoded (and fail) again and again
for free.

* redact: count the input's names once each, and say so when the count runs out

The name allowance counted one entry per occurrence, so a document whose
pages repeat the same structural name paid for the same scan again and
again; names are now kept once, with their count. A name search that runs
out of work read as no matches, which would have swept names with none
allowed: that is reported on the text surface now, and the proof fails
rather than pass on an uncounted allowance.

* Bound redaction proof searches across many needles

Assisted-by: OpenAI Codex

* Redaction proof: drop find_all, which nothing calls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: put aho-corasick in its sorted place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Redaction proof: review notes

A word split across a TJ array is looked for whole. Overlapping marks count a
removed copy once. A spent decode budget is a problem, not a raw-only stream.
Short needles stay out of the attachment text search, and an indirect /Decode
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:15:07 +01:00

247 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it
//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it).
//! CCITT fax and JBIG2 images decode through `ImageBudget::decode`, but for Group 3 on
//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the
//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the
//! caller removes the whole image instead; that is fail-closed.
use pdfcraft_content::{Matrix, overlaps};
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
/// Most pixels an image may have to be cleared in place (larger ones are removed), or to be stored
/// again without shared `/JBIG2Globals` (see [`without_globals`]).
pub(crate) const MAX_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS;
fn components(doc: &Document, s: &Stream) -> Option<usize> {
let cs = doc.resolve(s.dict.get(b"ColorSpace")?);
let cs = &*cs;
let name = match cs {
Object::Name(n) => n.as_slice(),
Object::Array(a) => a.first()?.as_name()?,
_ => return None,
};
Some(match name {
b"DeviceGray" | b"G" | b"CalGray" | b"Indexed" | b"I" | b"Separation" => 1,
b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" => 3,
b"DeviceCMYK" | b"CMYK" => 4,
b"ICCBased" => {
// /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5).
match &*doc.resolve(cs.as_array()?.get(1)?) {
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?,
_ => return None,
}
}
_ => return None,
})
}
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Result<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects, budget).ok_or(())
}
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Option<Option<Stream>> {
// Separate masks can retain the original image data.
if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") {
return None;
}
let supported = |n: &[u8]| {
matches!(
n,
b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL"
)
};
// A CCITT fax or JBIG2 image (its last filter) decodes through `ImageBudget::decode`,
// after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other
// image.
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) || 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 {
return None;
}
// Group 3 on bytes is removed whole for now. The decoder reads it by its end-of-line codes
// when the data starts with one, but PdfCraft's renderer draws such rows (the fill ending
// each code on a byte) as other rows, so the pixels a mark covers on screen needn't be the
// ones cleared.
if is_fax && doc.fax_params(s).is_none_or(|p| p.k >= 0 && p.encoded_byte_align) {
return None;
}
let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);
if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {
return None;
}
let (w, h) = (w as usize, h as usize);
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) };
// JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) {
return None;
}
// Every size comes from the file: multiply checked, so an overflowing product refuses the image
// instead of sizing the writes below.
let bits_per_pixel = ncomp.checked_mul(bpc)?;
let row = w.checked_mul(bits_per_pixel)?.div_ceil(8);
let bytes = row.checked_mul(h)?;
let row_bits = row.checked_mul(8)?;
bytes.checked_mul(8)?;
// Decode no further than the raster can need: a filter stage may legitimately produce up to
// three bytes for each of it and a PNG predictor's byte per row (ASCIIHex text under Flate,
// two digits a byte broken into lines or spaced), as the optimizer allows. A fax image's
// code, which the filters before its decoder produce, takes at most about a byte per pixel
// plus each row's end. Data past that can only be a decompression bomb; the image is then
// removed whole.
let allowance = if is_fax {
w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?.checked_add(4096)?
} else {
bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?
};
// Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*`
// alone would skip such a predictor, and the cleared bytes would then miss the covered pixels),
// and refuses a JBIG2 page of another size than the image: the renderer draws the whole page
// into the image, so the rows cleared wouldn't be the ones a mark covers on screen.
let mut data = budget.decode(doc, s, allowance).ok()?;
if data.len() < bytes {
return None;
}
data.truncate(bytes);
// Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint.
let clear_bit = if mask {
// The array, and its first number, given by reference too.
let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d));
let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v));
let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0);
!inverted
} else {
false
};
let mut cleared = 0usize;
for j in 0..h {
// Image space has its origin at the bottom left; row 0 is at the top.
let v0 = 1.0 - (j + 1) as f64 / h as f64;
let v1 = 1.0 - j as f64 / h as f64;
let row_box = ctm.bbox([0.0, v0, 1.0, v1]);
if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) {
continue;
}
for i in 0..w {
let u0 = i as f64 / w as f64;
let u1 = (i + 1) as f64 / w as f64;
// The bounding box may cover extra pixels for a rotated or skewed image; that
// conservative over-clear prevents a touched sample surviving in the file.
let pixel_box = ctm.bbox([u0, v0, u1, v1]);
if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) {
continue;
}
cleared += 1;
// `bytes * 8` fits, and every bit index is below it, so this arithmetic can't overflow.
let start = j * row_bits + i * bits_per_pixel;
for bit in start..start + bits_per_pixel {
let (byte, shift) = (bit / 8, 7 - bit % 8);
let b = data.get_mut(byte)?;
if clear_bit {
*b |= 1 << shift;
} else {
*b &= !(1 << shift);
}
}
}
}
if cleared == 0 {
return Some(None);
}
let mut dict = s.dict.clone();
dict.remove(b"Length");
dict.remove(b"DecodeParms");
dict.remove(b"Alternates");
Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2)))
}
/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit
/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT
/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already
/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's
/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's
/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out.
fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream {
let flate = Stream::flate(dict.clone(), data);
let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate };
let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| {
let mut dict = dict.clone();
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows)));
dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
Stream::from_raw(dict, coded)
});
let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| {
let mut dict = dict.clone();
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
Stream::from_raw(dict, coded)
});
[g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best })
}
/// The bytes stream `s` takes in a file: its data and its dictionary as written.
fn stored_len(s: &Stream) -> usize {
let mut dict = Vec::new();
pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict);
dict.len().saturating_add(s.raw.len())
}
/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn,
/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest
/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large,
/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the
/// image, or not a one-bit image of one component). Redaction does this to every image that
/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn
/// under the area too.
pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option<Stream> {
let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);
if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {
return None;
}
let (w, h) = (w as usize, h as usize);
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
// JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) {
return None;
}
let bytes = w.div_ceil(8).checked_mul(h)?;
// As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one
// of the raster and a byte a row.
let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?;
let mut data = budget.decode(doc, s, allowance).ok()?;
if data.len() < bytes {
return None;
}
data.truncate(bytes);
let mut dict = s.dict.clone();
dict.remove(b"Length");
// The globals are named in the parameters; the copy is coded alone, as `clear`'s are.
dict.remove(b"DecodeParms");
dict.remove(b"Alternates");
Some(smallest(dict, &data, w, h, true, true))
}