fix(create): bound a TIFF's decoded pages together, and read one when one is wanted

A TIFF's pages were each capped at 2 GiB but all kept in memory until
embedded, so many small directories sharing one compressed strip could ask
for many times that; embedding one image also decoded every page. The pages
of one TIFF are now refused past 4 GiB together, and image_xobject reads
only the first page.
This commit is contained in:
aiongg
2026-10-22 21:17:15 +00:00
parent b979d4d9c2
commit 7e9f5a4435
2 changed files with 48 additions and 8 deletions
+23 -8
View File
@@ -402,8 +402,17 @@ fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embed
/// twice over.
const MAX_DECODED_BYTES: usize = 2 << 30;
/// Every page of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until
/// they are embedded, and many small directories can share one compressed strip.
const MAX_DECODED_TOTAL: usize = 2 * MAX_DECODED_BYTES;
/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL)
}
/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes.
fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result<Vec<Embedded>, CreateError> {
use tiff::ColorType as C;
use tiff::decoder::{Decoder, DecodingResult, Limits};
use tiff::tags::Tag;
@@ -415,6 +424,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
limits.decoding_buffer_size = MAX_DECODED_BYTES;
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits);
let mut out = Vec::new();
let mut kept = 0usize;
loop {
let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?;
let ct = dec.colortype().map_err(|e| bad(e.to_string()))?;
@@ -469,8 +479,12 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
}
other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))),
};
kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len()));
if kept > budget {
return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20)));
}
out.push(page);
if !dec.more_images() {
if out.len() >= wanted || !dec.more_images() {
break;
}
dec.next_image().map_err(|e| bad(e.to_string()))?;
@@ -526,14 +540,15 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
}
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Detect the image format from its bytes; a TIFF may hold several pages, of which the first
/// `wanted` are read.
fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
tiff_pages(name, bytes)
tiff_pages(name, bytes, wanted)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
@@ -548,7 +563,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the
/// object and the image's natural size in points (from its resolution).
pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> {
let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let img = embed(name, bytes, 1)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
Ok((add_image(doc, img, &mut Vec::new()), size))
}
@@ -580,7 +595,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
}
let mut doc = Document::new_empty();
let mut profiles = Vec::new();
for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
let dpi = match resolution {
ImageResolution::Embedded => img.dpi,
ImageResolution::Dpi(dpi) => (dpi, dpi),
+25
View File
@@ -1197,3 +1197,28 @@ fn a_cyclic_indexed_colour_space_is_refused() {
assert!(out.images.is_empty());
assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped);
}
/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read.
fn gray_tiff(pages: usize, unreadable: bool) -> Vec<u8> {
let mut tif = std::io::Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap();
for _ in 0..pages {
enc.write_image::<tiff::encoder::colortype::Gray8>(64, 64, &[128; 64 * 64]).unwrap();
}
if unreadable {
enc.write_image::<tiff::encoder::colortype::RGB32Float>(1, 1, &[0.5; 3]).unwrap();
}
tif.into_inner()
}
/// Many small IFDs can each decode to a large page: the pages a TIFF keeps in memory together
/// are bounded, and embedding one image reads only its first page.
#[test]
fn tiff_pages_are_decoded_within_a_total_budget() {
let tif = gray_tiff(3, false);
assert!(matches!(tiff_pages_within("t.tif", &tif, usize::MAX, 10_000), Err(CreateError::Image(..))), "12 KiB of pages past a 10 KB budget");
assert_eq!(tiff_pages_within("t.tif", &tif, usize::MAX, 20_000).unwrap().len(), 3);
let tif = gray_tiff(1, true);
let mut doc = Document::new_empty();
assert!(image_xobject(&mut doc, "t.tif", &tif).is_ok(), "the unreadable last page is never decoded");
}