Files
effectcraft/crates/svg/src/tests.rs
T
DWAA1660andClaude Opus 5.5 79ebd01935 svg: bound <use> expansion by an element budget and skip reference cycles (#1236)
Depth-limited <use> expansion still let a few kilobytes of branching
references expand to 2^N elements. The parser now counts every element it
converts, <use> copies included, against a budget of max(500 000, twice the
file's own elements) and fails the import with Error::Xml once it is spent.
Targets currently being expanded are tracked, so mutual references stop at
the first repeat instead of nesting to the depth limit.

Fixes #1236

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
2026-10-13 16:59:08 -06:00

280 lines
12 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.
use super::*;
const BASIC: &[u8] = include_bytes!("../tests/fixtures/basic-shapes.svg");
const PATHS: &[u8] = include_bytes!("../tests/fixtures/paths-gradients.svg");
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[img.idx(x, y)]
}
fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
assert_eq!((d.width, d.height), (200.0, 100.0));
assert_eq!(d.pixel_size(), (200, 100));
// viewBox 400×200 into 200×100.
assert!((d.root.transform.as_coeffs()[0] - 0.5).abs() < 1e-12);
let shapes = d.flatten();
assert_eq!(shapes.len(), 6);
let (m, o, s) = shapes[1];
assert_eq!(s.name, "box");
assert!(matches!(s.geom, Geom::Rect { w, h, rx, .. } if w == 120.0 && h == 80.0 && rx == 12.0));
assert!((o - 0.8).abs() < 1e-12);
// translate(20 20) then the viewBox scale.
let p = m * kurbo::Point::new(10.0, 10.0);
assert!((p.x - 15.0).abs() < 1e-9 && (p.y - 15.0).abs() < 1e-9);
let st = s.stroke.as_ref().unwrap();
assert_eq!(st.width, 6.0);
assert_eq!(st.paint, Paint::Color([1.0, 1.0, 1.0]));
assert!(matches!(shapes[2].2.geom, Geom::Ellipse { rx, ry, .. } if rx == 30.0 && ry == 30.0));
assert_eq!(shapes[3].2.fill_opacity, 0.75);
assert!(shapes[4].2.fill.is_none());
assert_eq!(shapes[4].2.stroke.as_ref().unwrap().join, Join::Round);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().cap, Cap::Round);
}
#[test]
fn basic_shapes_raster() {
let d = parse(BASIC).unwrap();
let img = rasterize(&d, 200, 100, 1.0);
let bg = [0x20 as f32 / 255.0, 0x28 as f32 / 255.0, 0x30 as f32 / 255.0, 1.0];
assert!(near(px(&img, 2, 2), bg, 1e-3));
// Inside the box: tomato at group opacity 0.8 over the background.
let tomato = [1.0, 99.0 / 255.0, 71.0 / 255.0];
let want = [0, 1, 2].map(|i| tomato[i] * 0.8 + bg[i] * 0.2);
let got = px(&img, 45, 35);
assert!(near(got, [want[0], want[1], want[2], 1.0], 2e-3), "{got:?} vs {want:?}");
// Scaled ×2: the same point lands at double the coordinates, edges stay sharp.
let big = rasterize(&d, 400, 200, 2.0);
assert!(near(px(&big, 90, 70), got, 2e-3));
}
#[test]
fn paths_gradients_css_use() {
let d = parse(PATHS).unwrap();
assert_eq!(d.pixel_size(), (160, 120));
let shapes = d.flatten();
assert_eq!(shapes.len(), 6, "{:?}", shapes.iter().map(|s| &s.2.name).collect::<Vec<_>>());
// CSS class → gradient fill.
let Some(Paint::Gradient(g)) = &shapes[0].2.fill else { panic!("gradient expected") };
assert!(g.bbox_units);
assert_eq!(g.stops.len(), 2);
assert_eq!(shapes[1].2.fill_rule, FillRule::EvenOdd);
// #wave: id selector beats nothing; stroke from CSS.
assert!(shapes[2].2.fill.is_none());
assert_eq!(shapes[2].2.stroke.as_ref().unwrap().width, 5.0);
// use → the referenced path with the use's fill, translated.
assert_eq!(shapes[4].2.fill, Some(Paint::Color([128.0 / 255.0, 0.0, 128.0 / 255.0])));
let p = shapes[4].0 * kurbo::Point::new(0.0, 0.0);
assert!((p.x - 100.0).abs() < 1e-9 && (p.y - 110.0).abs() < 1e-9);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().dash, Some((vec![4.0, 2.0], 0.0)));
let img = rasterize(&d, 160, 120, 1.0);
// Vertical gradient: yellow at the top, red at the bottom.
let top = px(&img, 40, 11);
let bottom = px(&img, 40, 48);
assert!(top[1] > 0.7 && bottom[1] < 0.3, "{top:?} {bottom:?}");
// Even-odd hole.
assert_eq!(px(&img, 120, 35)[3], 0.0);
assert!(px(&img, 95, 15)[3] > 0.99);
// Radial gradient: white centre.
let c = px(&img, 40, 90);
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
}
#[test]
fn root_svg_percentages_use_the_document_viewport() {
for (attrs, width, height) in [
("width=\"100\" height=\"80\"", 100.0, 80.0),
("width=\"50%\" height=\"200%\"", 150.0, 300.0),
("", 300.0, 150.0),
("width=\"100\" height=\"80\" viewBox=\"0 0 200 160\"", 200.0, 160.0),
("width=\"NaN%\" height=\"1e400\"", 300.0, 150.0),
] {
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" {attrs}><rect x="25%" y="25%" width="50%" height="50%"/></svg>"#);
let d = parse(svg.as_bytes()).unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(
matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, .. }
if x == width / 4.0 && y == height / 4.0 && w == width / 2.0 && h == height / 2.0),
"{attrs}: {:?}",
shapes[0].2.geom
);
}
}
#[test]
fn nested_svg_percentages_use_the_nearest_viewport() {
let d = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80" viewBox="0 0 100 80">
<svg x="10" y="10" width="40" height="20">
<rect id="inner" width="50%" height="50%" fill="red"/>
<svg x="50%" width="50%" height="50%">
<rect id="deep" width="50%" height="50%" fill="blue"/>
</svg>
</svg>
<rect id="outer" y="60" width="50%" height="10" fill="green"/>
<svg x="60" width="40" height="20" viewBox="0 0 200 100">
<rect id="viewbox" width="50%" height="50%"/>
</svg>
</svg>"#,
)
.unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 4);
for ((_, _, shape), (name, width, height)) in
shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)])
{
assert_eq!(shape.name, name);
assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom);
}
let img = rasterize(&d, 100, 80, 1.0);
assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&img, 35, 17)[3], 0.0);
assert_eq!(px(&img, 45, 65)[3], 1.0);
assert_eq!(px(&img, 55, 65)[3], 0.0);
}
#[test]
fn non_finite_viewboxes_do_not_panic_or_produce_nan_geometry() {
for attrs in ["viewBox=\"0 0 1e400 100\"", "viewBox=\"0 0 100 1e400\"", "viewBox=\"-1e400 0 100 100\""] {
for shape in ["<rect width=\"0%\" height=\"50%\"/>", "<circle r=\"0%\"/>"] {
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" {attrs}>{shape}<rect width="50%" height="50%"/></svg>"#);
let d = parse(svg.as_bytes()).unwrap();
assert_eq!((d.width, d.height), (300.0, 150.0));
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry }
if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w == 150.0 && h == 75.0));
assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn non_finite_nested_viewports_do_not_panic_or_produce_nan_geometry() {
for attrs in ["width=\"1e400\" height=\"10\"", "width=\"10\" height=\"1e400\""] {
for shape in ["<rect width=\"0%\" height=\"50%\"/>", "<circle r=\"0%\"/>"] {
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><svg {attrs}>{shape}<rect width="50%" height="50%"/></svg></svg>"#);
let d = parse(svg.as_bytes()).unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry }
if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0));
assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite()));
}
}
}
#[test]
fn zero_size_nested_viewport_draws_nothing() {
for attrs in ["width=\"0\" height=\"10\"", "width=\"10\" height=\"0\""] {
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><svg {attrs}><rect width="50%" height="50%"/></svg></svg>"#);
assert!(parse(svg.as_bytes()).unwrap().flatten().is_empty(), "{attrs}");
}
}
#[test]
fn malformed_input() {
assert!(parse(b"<html/>").is_err());
assert!(parse(b"not xml").is_err());
let d = parse(b"<svg xmlns='http://www.w3.org/2000/svg'><path d='M0 0 L 10'/><rect width='-5' height='5'/><text>hi</text></svg>").unwrap();
assert_eq!(d.pixel_size(), (300, 150));
assert_eq!(d.skipped, vec!["text".to_string()]);
assert!(looks_like_svg(BASIC));
assert!(!looks_like_svg(b"\x89PNG"));
}
#[test]
fn transforms() {
let m = parse_transform("translate(10,20) rotate(90) scale(2 3)");
let p = m * kurbo::Point::new(1.0, 1.0);
assert!((p.x - 7.0).abs() < 1e-9 && (p.y - 22.0).abs() < 1e-9, "{p:?}");
let m = parse_transform("matrix(1 0 0 1 5 6) skewX(45)");
let p = m * kurbo::Point::new(0.0, 1.0);
assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9);
}
#[test]
fn deep_nesting_errors_instead_of_overflowing_the_stack() {
// 100 000 nested groups overflowed the XML parser's stack and aborted the process.
let deep = |n: usize| format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}<rect width=\"5\" height=\"5\"/>{}</svg>", "<g>".repeat(n), "</g>".repeat(n));
assert!(matches!(crate::parse(deep(100_000).as_bytes()), Err(crate::Error::Xml(_))));
assert!(crate::parse(deep(100).as_bytes()).is_ok());
// Comments, CDATA, quoted '>' and self-closing tags don't count.
let s = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><!-- {} --><![CDATA[{}]]><g title=\"a>b\">{}</g></svg>",
"<g>".repeat(1000),
"<g>".repeat(1000),
"<rect/>".repeat(1000)
);
assert_eq!(crate::xml_depth(&s), 2);
assert!(crate::parse(s.as_bytes()).is_ok());
}
/// How deep `g`'s groups nest, and how many shapes it holds.
fn depth_and_shapes(g: &Group) -> (usize, usize) {
let mut depth = 0;
let mut shapes = 0;
for c in &g.children {
match c {
Node::Group(sub) => {
let (d, s) = depth_and_shapes(sub);
depth = depth.max(d + 1);
shapes += s;
}
Node::Shape(_) => shapes += 1,
Node::Image(_) => {}
}
}
(depth, shapes)
}
#[test]
fn branching_use_references_stop_at_the_expansion_budget() {
// Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a
// file of a few kilobytes (#1236).
let mut defs = String::from(r#"<g id="g0"><rect width="1" height="1"/></g>"#);
for i in 1..=30 {
defs += &format!(r##"<g id="g{i}"><use href="#g{p}"/><use href="#g{p}" x="1"/></g>"##, p = i - 1);
}
let svg = format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs>{defs}</defs><use href="#g30"/></svg>"##);
let t0 = std::time::Instant::now();
let e = parse(svg.as_bytes()).unwrap_err();
assert!(e.to_string().contains("expand to more than"), "{e}");
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed());
// Below the budget the same pattern imports whole: 2^10 rectangles.
let small = svg.replace(r##"<use href="#g30"/></svg>"##, r##"<use href="#g10"/></svg>"##);
let doc = parse(small.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 1024);
}
#[test]
fn mutually_referencing_uses_are_cut_at_the_first_repeat() {
// A uses B and B uses A, neither inside the other: once each, not down to the depth limit.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs>
<g id="a"><rect width="1" height="1"/><use href="#b"/></g>
<g id="b"><rect width="2" height="2"/><use href="#a"/></g>
</defs><use href="#a"/></svg>"##;
let doc = parse(svg.as_bytes()).unwrap();
let (depth, shapes) = depth_and_shapes(&doc.root);
assert_eq!(shapes, 2, "a's and b's rectangle, once each");
assert!(depth <= 6, "{depth}");
}
#[test]
fn a_symbol_used_a_thousand_times_still_imports() {
let uses: String = (0..1000).map(|i| format!(r##"<use href="#icon" x="{i}"/>"##)).collect();
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs><symbol id="icon"><rect width="1" height="1"/><circle r="1"/><ellipse rx="1" ry="2"/></symbol></defs>{uses}</svg>"##
);
let doc = parse(svg.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 3000);
}