Files
pdfcraft/vendor/hayro-interpret/src/pattern.rs
T
e88def3465 Never crash: decompression bombs can't exhaust the renderer's memory (#411)
* Never crash: decompression bombs can't exhaust the renderer's memory

The renderer's parser (vendored hayro-syntax) decoded Flate, LZW and
RunLength streams with no output limit. A 2 MB page whose content
stream inflates to 2 GB, or whose 8 x 8 image does, took `pdfcraft-cli
render` and `text` past 3 GB in under a second, and a few more MB of
file scale it further. On the desktop that is the first-page render of
an emailed PDF, and the allocation-failure abort takes every open
document with it. Our own layers already bound single streams (lopdf's
load-time decoding at 256 MiB, cos at 1 GiB), so `info` stays at a few
MB; the renderer path didn't. Random mutation almost never makes a
high-ratio stream, so `cargo xtask fuzz` doesn't find this; found by
hand while triaging the fuzz run.

Vendored hayro-syntax patch (6):
- `decode_limit` gives each filter an output limit, and Flate (both
  flate2 and the fallback inflater), LZW and RunLength never write past
  it (the buffer can still grow to under twice it):
  `MAX_DECODED_STREAM`, 256 MiB, lopdf's figure; or, for the last filter
  of an image whose size, bits per component and components are known,
  twice its pixel bytes (a PNG predictor tag byte per byte at most),
  capped at 1 GiB. Earlier filters in a chain produce compressed data
  and get the general limit.
- `Stream::decoded_within(max)` for callers that budget.
- A page's `/Contents` array shares one `MAX_DECODED_STREAM` (naming one
  bomb twenty times decoded 5 GB), reserving exactly what it appends.

Vendored hayro-interpret patch (18):
- The content a page holds at once (its contents, and the forms, tiling
  patterns and Type 3 glyph procedures being painted) stays within
  `MAX_PAGE_CONTENT`, 512 MiB, reset per page like the existing paint
  budget. Forms nest fifty deep and each level kept its content while
  the next ran: a form painting itself around a 256 MiB stream would
  have held over 12 GB. Each level gives its bytes back when it is
  dropped, so content painted again and again keeps drawing, and a
  stream without filters (borrowed from the file) costs nothing.
- Type 0 sampled functions read every sample into a 4-byte value and
  then a table entry: 256 MiB of 1-bit samples asked for over 8 GB. They
  now take at most 2^21 table points and 2^23 values (the largest real
  tables, DeviceN tint transforms such as 33^4 points x 3 outputs, are
  1.2 million and 3.6 million), counted with checked arithmetic, and
  read no further than `/Size` asks. `/Size 0` no longer underflows
  building the default `/Encode`.

Three rounds of independent review found the aggregate cases, buffer
doubling past the limit, a chain/predictor shape the first image limit
would have truncated, the Type 0 amplifier, and a first, cumulative
budget that would have dropped legitimately repeated content, and a
first Type 0 cap low enough to refuse real tint transforms; all fixed
here.

Measured in a release build (render; OS peak commit):
- content stream inflating to 2 GB: 389 MB, 0.3 s
- 8 x 8 image inflating to 2 GB: 4 MB, 0.2 s
- /Contents naming it twenty times: 516 MB, 0.3 s
- a form painting itself around it: 644 MB, 0.4 s

Synthetic regression tests in render (flate2 is a new dev-dependency
there, already in the workspace):
- `decompression_bombs_stop_at_the_stream_limit`: 288 MiB bombs through
  flate2, the fallback inflater (a stored block with a wrong length
  check, which flate2 refuses and the fallback accepts), LZW and
  RunLength each decode exactly 256 MiB, and at most 128 bytes as an
  8 x 8 image.
- `page_contents_and_nested_forms_share_one_budget`: three /Contents
  references decode one stream's worth; a self-painting form finishes.
- `content_painted_again_and_again_keeps_drawing`: a 2 MiB form painted
  300 times draws the 300th time.
- `sampled_functions_with_billions_of_samples_are_refused`.
Ten mutations each turn them red: no limit at all; no limit in flate2,
the fallback, LZW, RunLength or for images; an unshared /Contents limit;
no page budget; no refund on drop; no Type 0 sample cap. (Taking a form
out of the budget no longer compiles: its content is the budget guard.)

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

* M2: normalize JPEG2000 samples without overflowing

Use bounded integer sample scaling for 1-32-bit JPX images and reject empty dimensions before chunking. Cover public decoding and continued page rendering with synthetic regressions.

Co-Authored-By: OpenAI Codex <codex@openai.com>

* Never crash: decompression cut at its limit is logged, not silent; vendor README renumbered after #575

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:10:50 -04:00

352 lines
10 KiB
Rust

//! PDF patterns.
use crate::cache::Cache;
use crate::color::{Color, ColorSpace};
use crate::context::{Context, InterpreterCache};
use crate::device::Device;
use crate::font::Glyph;
use crate::interpret::state::{ActiveTransferFunction, State};
use crate::shading::Shading;
use crate::soft_mask::SoftMask;
use crate::util::{Float32Ext, RectExt, hash128};
use crate::{BlendMode, CacheKey, ClipPath, GlyphDrawMode, Image, PathDrawMode};
use crate::{FillRule, InterpreterSettings, Paint, interpret};
use hayro_syntax::content::TypedIter;
use hayro_syntax::object::Dict;
use hayro_syntax::object::Stream;
use hayro_syntax::object::dict::keys::{
BBOX, EXT_G_STATE, MATRIX, PAINT_TYPE, RESOURCES, SHADING, X_STEP, Y_STEP,
};
use hayro_syntax::object::{Object, dict_or_stream};
use hayro_syntax::page::Resources;
use hayro_syntax::xref::XRef;
use kurbo::{Affine, BezPath, Rect, Shape};
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
/// A PDF pattern.
#[derive(Debug, Clone)]
pub enum Pattern<'a> {
/// A shading pattern.
Shading(ShadingPattern),
/// A tiling pattern.
Tiling(Box<TilingPattern<'a>>),
}
impl<'a> Pattern<'a> {
pub(crate) fn new(
object: Object<'a>,
ctx: &Context<'a>,
resources: &Resources<'a>,
) -> Option<Self> {
match object {
Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new(
&dict,
&ctx.interpreter_cache.object_cache,
ctx.get().graphics_state.non_stroke_alpha,
)?)),
Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new(
stream, ctx, resources,
)?))),
_ => None,
}
}
pub(crate) fn pre_concat_transform(&mut self, transform: Affine) {
match self {
Self::Shading(p) => {
p.matrix = transform * p.matrix;
let transformed_clip_path = p.shading.clip_path.clone().map(|r| p.matrix * r);
Arc::make_mut(&mut p.shading).clip_path = transformed_clip_path;
}
Self::Tiling(p) => p.matrix = transform * p.matrix,
}
}
pub(crate) fn set_transfer_function(&mut self, tf: ActiveTransferFunction) {
if let Self::Shading(p) = self {
p.transfer_function = Some(tf);
}
}
}
impl CacheKey for Pattern<'_> {
fn cache_key(&self) -> u128 {
match self {
Self::Shading(p) => p.cache_key(),
Self::Tiling(p) => p.cache_key(),
}
}
}
/// A shading pattern.
#[derive(Clone, Debug)]
pub struct ShadingPattern {
/// The underlying shading of the pattern.
pub shading: Arc<Shading>,
/// A transformation matrix to apply prior to rendering.
pub matrix: Affine,
/// An additional opacity to apply to the shading pattern.
pub opacity: f32,
/// An optional transfer function to apply to the shading's output colors.
pub transfer_function: Option<ActiveTransferFunction>,
}
impl ShadingPattern {
pub(crate) fn new(dict: &Dict<'_>, cache: &Cache, opacity: f32) -> Option<Self> {
let shading = dict.get::<Object<'_>>(SHADING).and_then(|o| {
let (dict, stream) = dict_or_stream(&o)?;
Shading::new(dict, stream, cache)
})?;
let matrix = dict
.get::<[f64; 6]>(MATRIX)
.map(Affine::new)
.unwrap_or_default();
if dict.contains_key(EXT_G_STATE) {
warn!("shading patterns with ext_g_state are not supported yet");
}
Some(Self {
shading: Arc::new(shading),
opacity,
matrix,
transfer_function: None,
})
}
}
impl CacheKey for ShadingPattern {
fn cache_key(&self) -> u128 {
hash128(&(self.shading.cache_key(), self.matrix.cache_key()))
}
}
/// A tiling pattern.
#[derive(Clone)]
pub struct TilingPattern<'a> {
cache_key: u128,
ctx_bbox: Rect,
/// The bbox of the tiling pattern.
pub bbox: Rect,
/// The step in the x direction.
pub x_step: f32,
/// The step in the y direction.
pub y_step: f32,
/// A transformation to apply prior to rendering.
pub matrix: Affine,
stream: Stream<'a>,
is_color: bool,
pub(crate) stroke_paint: Color,
pub(crate) non_stroking_paint: Color,
pub(crate) parent_resources: Resources<'a>,
pub(crate) cache: InterpreterCache<'a>,
pub(crate) settings: InterpreterSettings,
pub(crate) xref: &'a XRef,
nesting_depth: u32,
}
impl Debug for TilingPattern<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("TilingPattern")
}
}
impl<'a> TilingPattern<'a> {
pub(crate) fn new(
stream: Stream<'a>,
ctx: &Context<'a>,
resources: &Resources<'a>,
) -> Option<Self> {
let cache_key = stream.cache_key();
let dict = stream.dict();
let bbox = dict.get::<hayro_syntax::object::Rect>(BBOX)?.to_kurbo();
let x_step = dict.get::<f32>(X_STEP)?;
let y_step = dict.get::<f32>(Y_STEP)?;
if x_step.is_nearly_zero() || y_step.is_nearly_zero() || bbox.is_zero_area() {
return None;
}
let is_color = dict.get::<u8>(PAINT_TYPE)? == 1;
let matrix = dict
.get::<[f64; 6]>(MATRIX)
.map(Affine::new)
.unwrap_or_default();
let state = ctx.get().clone();
let ctx_bbox = ctx.bbox();
let fill_cs = state
.graphics_state
.none_stroke_cs
.pattern_cs()
.unwrap_or(ColorSpace::device_gray());
let stroke_cs = state
.graphics_state
.stroke_cs
.pattern_cs()
.unwrap_or(ColorSpace::device_gray());
let non_stroking_paint = Color::new(
fill_cs,
state.graphics_state.non_stroke_color.clone(),
state.graphics_state.non_stroke_alpha,
);
let stroke_paint = Color::new(
stroke_cs,
state.graphics_state.stroke_color.clone(),
state.graphics_state.stroke_alpha,
);
let nesting_depth = ctx.nesting_depth() + 1;
Some(Self {
cache_key,
bbox,
x_step,
y_step,
matrix,
ctx_bbox,
is_color,
stream,
stroke_paint,
non_stroking_paint,
settings: ctx.settings.clone(),
parent_resources: resources.clone(),
cache: ctx.interpreter_cache.clone(),
xref: ctx.xref,
nesting_depth,
})
}
/// Interpret the contents of the pattern into the given device.
pub fn interpret(
&self,
device: &mut impl Device<'a>,
initial_transform: Affine,
is_stroke: bool,
) -> Option<()> {
// PdfCraft patch: a tiling pattern can paint itself (directly, or through resources it
// inherits when its own are missing), which recursed until the stack overflowed. Bound
// the nesting like XObjects do, tighter because every level rasterizes a tile.
if self.nesting_depth > crate::context::MAX_PAINT_NESTING || !crate::context::take_nested_paint(self.nesting_depth) {
warn!("tiling pattern nesting depth or paint budget exceeded");
return None;
}
let state = State::new(initial_transform);
let mut context = Context::new_with(
state.ctm,
// TODO: bbox?
(initial_transform * self.ctx_bbox.to_path(0.1)).bounding_box(),
&self.cache,
self.xref,
self.settings.clone(),
state,
self.nesting_depth,
);
// PdfCraft patch: see `context::MAX_PAGE_CONTENT`.
let decoded = crate::context::decode_content(&self.stream)?;
let resources = Resources::from_parent(
self.stream.dict().get(RESOURCES).unwrap_or_default(),
self.parent_resources.clone(),
);
let iter = TypedIter::new(decoded.as_ref());
let clip_path = ClipPath {
path: initial_transform * self.bbox.to_path(0.1),
fill: FillRule::NonZero,
};
device.push_clip_path(&clip_path);
if self.is_color {
interpret(iter, &resources, &mut context, device);
} else {
let paint = if !is_stroke {
Paint::Color(self.non_stroking_paint.clone())
} else {
Paint::Color(self.stroke_paint.clone())
};
let mut device = StencilPatternDevice::new(device, paint.clone());
interpret(iter, &resources, &mut context, &mut device);
}
device.pop_clip_path();
Some(())
}
}
impl CacheKey for TilingPattern<'_> {
fn cache_key(&self) -> u128 {
self.cache_key
}
}
struct StencilPatternDevice<'a, 'b, T: Device<'a>> {
inner: &'b mut T,
paint: Paint<'a>,
}
impl<'a, 'b, T: Device<'a>> StencilPatternDevice<'a, 'b, T> {
pub(crate) fn new(device: &'b mut T, paint: Paint<'a>) -> Self {
Self {
inner: device,
paint,
}
}
}
// Only filling, stroking of paths and stencil masks are allowed.
impl<'a, T: Device<'a>> Device<'a> for StencilPatternDevice<'a, '_, T> {
fn draw_path(
&mut self,
path: &BezPath,
transform: Affine,
_: &Paint<'_>,
draw_mode: &PathDrawMode,
) {
self.inner
.draw_path(path, transform, &self.paint, draw_mode);
}
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn push_clip_path(&mut self, clip_path: &ClipPath) {
self.inner.push_clip_path(clip_path);
}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(
&mut self,
g: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
p: &Paint<'a>,
draw_mode: &GlyphDrawMode,
) {
self.inner
.draw_glyph(g, transform, glyph_transform, p, draw_mode);
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
if let Image::Stencil(mut s) = image {
s.paint = self.paint.clone();
self.inner.draw_image(Image::Stencil(s), transform);
}
}
fn pop_clip_path(&mut self) {
self.inner.pop_clip_path();
}
fn pop_transparency_group(&mut self) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
}