mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
* fix(redact): size image clearing with checked arithmetic (#2177) An ICCBased image's /N comes from the file and was cast with `as usize`, then multiplied unchecked into the row size. /N 2^60 overflowed it (debug: overflow panic; release: a zero row emptied the buffer and the write loop indexed out of bounds), and a negative /N wrapped. /N is now limited to 1, 3 or 4 (ISO 32000-2 §8.6.5.5), every size product is checked, and the pixel writes use `get_mut`. An image that fails any of these is refused, and the caller already removes it whole (fail-closed). Regression test covers /N 2^60, -1 and 2. * redact tests: pass the pixel array by value (clippy needless_borrows_for_generic_args) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.3 KiB
Rust
131 lines
5.3 KiB
Rust
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
|
|
//! whose centre maps into a region is set to zero (no ink for image masks), and the result is
|
|
//! written as a new Flate image; the original object is untouched (other pages may use it).
|
|
//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the
|
|
//! caller removes the whole image instead; that is fail-closed.
|
|
|
|
use pdfcraft_content::{Matrix, overlaps};
|
|
use pdfcraft_cos::{Document, Object, Stream};
|
|
|
|
/// Most pixels an image may have to be cleared in place (larger ones are removed).
|
|
const MAX_PIXELS: u64 = 64 << 20;
|
|
|
|
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 centre is covered,
|
|
/// `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]]) -> Result<Option<Stream>, ()> {
|
|
decode_and_clear(doc, s, ctm, rects).ok_or(())
|
|
}
|
|
|
|
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> 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"
|
|
)
|
|
};
|
|
let ok = match s.dict.get(b"Filter") {
|
|
None => true,
|
|
Some(Object::Name(n)) => supported(n),
|
|
Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)),
|
|
_ => false,
|
|
};
|
|
if !ok {
|
|
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) };
|
|
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
|
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)?;
|
|
let mut data = s.decoded().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 {
|
|
let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0);
|
|
!inverted
|
|
} else {
|
|
false
|
|
};
|
|
let mut cleared = 0usize;
|
|
for j in 0..h {
|
|
// Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
|
|
let v = 1.0 - (j as f64 + 0.5) / h as f64;
|
|
let (ax, ay) = ctm.apply(0.0, v);
|
|
let (bx, by) = ctm.apply(1.0, v);
|
|
let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)];
|
|
if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) {
|
|
continue;
|
|
}
|
|
for i in 0..w {
|
|
let u = (i as f64 + 0.5) / w as f64;
|
|
let (x, y) = ctm.apply(u, v);
|
|
if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) {
|
|
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(Stream::flate(dict, &data)))
|
|
}
|