From 64e91207f1b1b464237692bf122d895367c807f2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:49:06 -0500 Subject: [PATCH] svg import: bound the layer stack a filter chain builds (#2252) --- crates/svg/src/import/fx.rs | 18 +++++++++++++----- crates/svg/src/import/fx_tests.rs | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 0708352e7..4fe59a7b6 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -70,10 +70,15 @@ impl Img { } } -fn stack(bottom: Img, top: Img) -> Img { +/// The most layers a stack may hold. Results that name earlier results can double a stack at each +/// of up to 64 primitives; real shadow chains hold a handful of layers, and a longer stack isn't +/// an effect list anyone wrote. +const MAX_STACK: usize = 64; + +fn stack(bottom: Img, top: Img) -> Option { let mut v = bottom.into_layers(); v.extend(top.into_layers()); - Img::Stack(v) + (v.len() <= MAX_STACK).then_some(Img::Stack(v)) } /// The effects `filters` amount to on a layer whose user space maps to the page by `world`; `None` @@ -148,13 +153,16 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option) -> Option { let mut v = Vec::new(); for i in m.inputs() { v.extend(get(i)?.into_layers()); + if v.len() > MAX_STACK { + return None; + } } Img::Stack(v) } Kind::Blend(b) => { let (top, bottom) = (get(b.input1())?, get(b.input2())?); match b.mode() { - usvg::BlendMode::Normal => stack(bottom, top), + usvg::BlendMode::Normal => stack(bottom, top)?, _ => return None, } } @@ -162,7 +170,7 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option) -> Option { let sigma = (f64::from(d.std_dev_x().get()) + f64::from(d.std_dev_y().get())) / 2.0; let s = Sh { dx: f64::from(d.dx()), dy: f64::from(d.dy()), sigma, color: color(d.color(), d.opacity().get()), ..Sh::alpha() }; match get(d.input())? { - Img::Layer(b) => stack(Img::Shadow(s), Img::Layer(b)), + Img::Layer(b) => stack(Img::Shadow(s), Img::Layer(b))?, _ => return None, } } @@ -190,7 +198,7 @@ fn tint(v: &[f32], s: Sh) -> Option { fn composite(a: Img, b: Img, op: CompositeOperator) -> Option { let tinted = |s: Sh, c: Color| Sh { color: Color::new(c.r, c.g, c.b, (c.a * s.color.a).clamp(0.0, 1.0)), ..s }; Some(match (op, a, b) { - (CompositeOperator::Over, a, b) => stack(b, a), + (CompositeOperator::Over, a, b) => stack(b, a)?, (CompositeOperator::In, Img::Flood(c), Img::Shadow(s)) | (CompositeOperator::In, Img::Shadow(s), Img::Flood(c)) => Img::Shadow(tinted(s, c)), // A flood cut to everything but a shadow, then cut to the layer: an inner shadow. (CompositeOperator::Out, Img::Flood(c), Img::Shadow(s)) if !s.inner && !s.inverted => Img::Shadow(Sh { inverted: true, ..tinted(s, c) }), diff --git a/crates/svg/src/import/fx_tests.rs b/crates/svg/src/import/fx_tests.rs index 8fa77e86e..33f0413ae 100644 --- a/crates/svg/src/import/fx_tests.rs +++ b/crates/svg/src/import/fx_tests.rs @@ -228,3 +228,15 @@ fn coloured_copies_of_a_blurred_layer_arent_shadows() { assert!(!notes.is_empty(), "{filter}: reported"); } } + +#[test] +fn a_doubling_chain_of_merges_is_refused_not_expanded() { + // Each merge holds the previous result twice: 2^40 layers if expanded. + let mut f = String::from(r##""##); + for i in 1..40 { + f.push_str(&format!(r##""##, i - 1, i - 1)); + } + let (fx, notes) = effects(&f, ""); + assert!(fx.is_empty(), "{fx:?}"); + assert!(!notes.is_empty()); +}