From 43f65a9210218d035b9400b3078acf15d3123dca Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:46:38 -0500 Subject: [PATCH] craft-svg: usvg behind a bounded `tree` feature; attribute guard; Inkscape layer names (#937) * craft-svg paths: a shape carries the name of its Inkscape layer Shape::layer is the inkscape:label (else the id) of the innermost around the element, so a CAD or cutter import can keep the drawing's layers. * craft-svg guard: bound attributes per element and over the document; find entities holding markup too_wide is the attribute check of storytold/figcraft#2251: an XML reader checks each attribute against the earlier ones on its element, so an element with millions of them is quadratic work before any other limit applies. entity_holds_markup lets a caller refuse a document type whose entities become elements, as PhotoCraft does. * craft-svg: feature `tree`, usvg behind one bounded entry point usvg 0.48.1 (Apache-2.0 OR MIT), re-exported as craft_svg::tree::usvg, so every app parses with one version and one set of checks: tree::parse runs svgz, the entity, attribute and nesting guards, the reference-chain height (VectorCraft's depth::height, on this crate's cascade), then usvg. tree::image_resolver bounds embedded SVGs (FigCraft's budget::resolver). tree::draw bounds what drawing a tree with resvg allocates (PhotoCraft's svg/limits.rs). Part of #778 and #789. --- Cargo.lock | 77 ++++++- README.md | 5 +- crates/svg/Cargo.toml | 11 + crates/svg/src/guard.rs | 116 +++++++++- crates/svg/src/lib.rs | 4 + crates/svg/src/paths.rs | 54 ++++- crates/svg/src/tree.rs | 327 ++++++++++++++++++++++++++ crates/svg/src/tree/draw.rs | 431 +++++++++++++++++++++++++++++++++++ crates/svg/src/tree/tests.rs | 304 ++++++++++++++++++++++++ 9 files changed, 1320 insertions(+), 9 deletions(-) create mode 100644 crates/svg/src/tree.rs create mode 100644 crates/svg/src/tree/draw.rs create mode 100644 crates/svg/src/tree/tests.rs diff --git a/Cargo.lock b/Cargo.lock index c2dca8156..21909a8af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,7 @@ dependencies = [ "kurbo 0.13.1", "roxmltree 0.21.2", "thiserror 2.0.21", + "usvg 0.48.1", ] [[package]] @@ -2453,6 +2454,27 @@ dependencies = [ "serde", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "slotmap", + "tinyvec", +] + [[package]] name = "fontique" version = "0.11.1" @@ -3428,6 +3450,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "2.14.2" @@ -5359,9 +5387,9 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia", - "usvg", + "usvg 0.45.1", ] [[package]] @@ -6230,6 +6258,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "svgtypes" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f12b12995e3638c70618c1facb1ce65302f6a6f706b675f07c58a5d7391120e" +dependencies = [ + "kurbo 0.13.1", + "siphasher", +] + [[package]] name = "symphonia" version = "0.6.1" @@ -6988,6 +7026,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -7065,11 +7109,38 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia-path 0.11.4", "xmlwriter", ] +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "fontdb", + "harfrust", + "imagesize 0.15.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.2", + "simplecss", + "siphasher", + "skrifa 0.44.0", + "strict-num", + "svgtypes 0.16.2", + "tiny-skia-path 0.12.0", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf16_iter" version = "1.0.5" diff --git a/README.md b/README.md index c1ab7967e..a29bbfeb0 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`/`` instances and the CSS cascade as Bézier paths in millimetres, each tagged by the caller); `color` parses CSS colours; `write` writes documents - (elements with escaping, path data, numbers). + (elements with escaping, path data, numbers). Feature `tree`: the full parser, usvg at the one + version every Craft app uses, re-exported behind an entry point that runs those checks first + (plus nesting with references followed, attribute counts, bounded embedded SVGs), and a budget + check for drawing a tree with resvg. Apps take usvg from here, not as their own dependency. - [`craft-svg-paths`](crates/svg-paths/src/lib.rs): the former name of `craft-svg`'s `paths`, `color` and `write`; re-exports them for existing users. - [`craft-text`](crates/text/README.md): text processing below the composer. Its `hyphen` module finds diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 3baa0c734..49b22e213 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -17,3 +17,14 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] kurbo = { workspace = true } roxmltree = "0.21.2" thiserror = { workspace = true } +# The full SVG parser (Apache-2.0 OR MIT). The one version every Craft app uses: apps depend on +# craft-svg's `tree` feature and take usvg from its re-export, never directly. +usvg = { version = "=0.48.1", default-features = false, optional = true } + +[features] +# `craft_svg::tree`: usvg behind the bounded entry point. +tree = ["dep:usvg"] +# Text converted to paths (needs a font database in the options). +tree-text = ["tree", "usvg/text"] +# The system's fonts in `usvg::fontdb`. +tree-system-fonts = ["tree-text", "usvg/system-fonts"] diff --git a/crates/svg/src/guard.rs b/crates/svg/src/guard.rs index ba6f520df..073bfe34c 100644 --- a/crates/svg/src/guard.rs +++ b/crates/svg/src/guard.rs @@ -4,7 +4,8 @@ //! entities with no total limit, so a short hostile file can overflow the stack (an abort, not //! an error) or allocate far more than it holds. [`prescan`] measures how deep the elements nest //! in one pass over the text; [`entities_expand_past`] bounds what the entity references expand -//! to. Both read the text as it is written and never build a tree. +//! to; [`too_wide`] bounds the attributes an element has. All read the text as it is written and +//! never build a tree. use std::borrow::Cow; use std::collections::HashMap; @@ -94,6 +95,85 @@ pub fn entities_expand_past(svg: &str, most: usize) -> bool { false } +/// Whether the document type of `svg` declares an entity whose value holds markup (a `<`). Such +/// an entity becomes elements where it is referenced, so what the text shows of the tree is not +/// what a reader builds; drawing apps declare entities for namespace names only. A caller may +/// refuse these outright instead of counting them ([`prescan`] counts them ten times). Text that +/// only looks like a declaration (in a comment) counts too. `<` is text, not markup. +pub fn entity_holds_markup(svg: &str) -> bool { + declared(svg).values().flatten().any(|value| value.contains('<')) +} + +/// The most attributes one element may have, as a limit for [`too_wide`]: real elements have a +/// few dozen at most, and SVG's attribute set is a few hundred names in total. +pub const MAX_ATTRIBUTES: usize = 4096; + +/// The most the duplicate-attribute checks of an XML reader may cost over a whole document, as +/// a limit for [`too_wide`]: the sum of each element's attribute count squared (a real file +/// with 100,000 elements of 20 attributes is 40 million). +pub const MAX_ATTRIBUTE_WORK: usize = 1 << 28; + +/// Whether an element of `svg` has more than `most` attributes, or all elements cost more than +/// `most_work` (the sum of each element's attribute count squared). An XML reader checks each +/// attribute against the earlier ones on its element, so an element with millions of them is +/// quadratic work before any other limit applies. Counted as quoted values per start tag, +/// before a reader sees them; the declarations of a document type count as one element, as +/// entities can hold elements. +pub fn too_wide(svg: &str, most: usize, most_work: usize) -> bool { + let data = svg.as_bytes(); + let find = |from: usize, pat: &[u8]| -> usize { + data.get(from..).and_then(|r| r.windows(pat.len()).position(|w| w == pat)).map_or(data.len(), |p| from + p + pat.len()) + }; + let (mut i, mut work) = (0usize, 0usize); + let mut over = |attrs: usize| { + work = work.saturating_add(attrs.saturating_mul(attrs)); + attrs > most || work > most_work + }; + while let Some(off) = data.get(i..).and_then(|r| r.iter().position(|b| *b == b'<')) { + i += off; + let rest = data.get(i..).unwrap_or(&[]); + if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b"` closing its subset (the first `>` when it has none). + let end = match (rest.iter().position(|b| *b == b'['), rest.iter().position(|b| *b == b'>')) { + (Some(o), Some(g)) if o < g => find(i, b"]>"), + _ => find(i, b">"), + }; + let quotes = data.get(i..end).unwrap_or(&[]).iter().filter(|b| matches!(**b, b'"' | b'\'')).count(); + if over(quotes / 2) { + return true; + } + i = end; + } else if rest.starts_with(b""); + } else { + // A start tag: to its `>` outside quotes. + let (mut quote, mut j, mut attrs) = (None, i + 1, 0usize); + while let Some(&c) = data.get(j) { + match quote { + Some(q) if c == q => quote = None, + Some(_) => {} + None if c == b'"' || c == b'\'' => { + quote = Some(c); + attrs += 1; + } + None if c == b'>' => break, + None => {} + } + j += 1; + } + if over(attrs) { + return true; + } + i = j + 1; + } + } + false +} + /// What one pass over the XML text of an SVG finds before any XML reader ([`prescan`]). pub struct Prescan<'s> { /// Whether the elements nest deeper than the cap. The pass stops there. @@ -259,6 +339,40 @@ mod tests { assert!(start.elapsed() < std::time::Duration::from_secs(120), "{:?}", start.elapsed()); } + #[test] + fn an_entity_holding_markup_is_found_and_a_namespace_entity_is_not() { + assert!(entity_holds_markup("\">]>&a;")); + assert!(entity_holds_markup("'>]>")); + assert!(!entity_holds_markup("]>")); + assert!(!entity_holds_markup("]>")); + assert!(!entity_holds_markup("")); + } + + fn wide(svg: &str) -> bool { + too_wide(svg, MAX_ATTRIBUTES, MAX_ATTRIBUTE_WORK) + } + + #[test] + fn an_element_with_thousands_of_attributes_is_too_wide() { + let many = |n: usize| (0..n).map(|i| format!(" a{i}=\"1\"")).collect::(); + assert!(!wide(&format!("", many(4096)))); + assert!(wide(&format!("", many(4097)))); + assert!(!wide(&format!("{}", "".repeat(10_000))), "per element, not per file"); + assert!(too_wide("", 2, usize::MAX) && !too_wide("", 3, usize::MAX)); + assert!(!wide(" '' \"\" "), "quotes outside tags are no attributes"); + } + + #[test] + fn attribute_work_is_bounded_over_the_whole_document_and_its_dtd() { + let many = |n: usize| (0..n).map(|i| format!(" a{i}=\"1\"")).collect::(); + let tag = format!("", many(4000)); + assert!(!wide(&format!("{}", tag.repeat(10)))); + assert!(wide(&format!("{}", tag.repeat(40))), "each element is within the limit, all together are not"); + let dtd = format!("'>]>&e;", many(5000)); + assert!(wide(&dtd), "entities can hold elements"); + assert!(!wide("]>")); + } + #[test] fn declarations_in_comments_and_long_names_cannot_hide_a_large_entity() { let big = "x".repeat(1 << 20); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index e057b9b1f..fd6ca685e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -9,6 +9,8 @@ //! and CAD import. //! - [`color`]: CSS colours. //! - [`write`]: a writer for SVG documents, their path data and numbers. +//! - `tree` (feature `tree`): the full parser. usvg at the one version the Craft apps share, +//! re-exported, behind an entry point that runs the checks above before it parses. //! //! Input is treated as hostile throughout: every size, count and depth is bounded, and a file //! past a bound is an error, never a panic or an unbounded allocation. @@ -20,4 +22,6 @@ pub mod guard; pub mod paths; pub mod sniff; pub mod svgz; +#[cfg(feature = "tree")] +pub mod tree; pub mod write; diff --git a/crates/svg/src/paths.rs b/crates/svg/src/paths.rs index d09620e57..e909283fd 100644 --- a/crates/svg/src/paths.rs +++ b/crates/svg/src/paths.rs @@ -72,8 +72,16 @@ pub struct Shape { pub stroke: Option, /// The regions the shape is clipped by, as indices into [`Imported::clips`], outermost first. pub clips: Vec, + /// The name of the innermost Inkscape layer the element is in: a `` with + /// `inkscape:groupmode="layer"`, named by its `inkscape:label`, else its `id`, else empty. + /// `None` outside any layer. At most [`MAX_LAYER_NAME`] characters. + pub layer: Option, } +/// Most characters of a layer name kept in [`Shape::layer`]. +pub const MAX_LAYER_NAME: usize = 256; +const INKSCAPE_NS: &str = "http://www.inkscape.org/namespaces/inkscape"; + /// What [`import`] read. Shapes may lie outside the `width_mm` × `height_mm` viewport. #[derive(Debug, Clone, PartialEq)] pub struct Imported { @@ -102,6 +110,8 @@ struct ImportState { inherited_style: std::collections::HashMap, /// Active `clip-path` regions (indices into the context's list), outermost first. clips: Vec, + /// The innermost Inkscape layer's name ([`Shape::layer`]). + layer: Option>, } impl Default for ImportState { @@ -113,6 +123,7 @@ impl Default for ImportState { visibility_hidden: false, inherited_style: std::collections::HashMap::new(), clips: Vec::new(), + layer: None, } } } @@ -127,7 +138,7 @@ const MAX_SVG_CLIP_PIECES: usize = 100_000; const MAX_SVG_SEGMENTS: usize = 4_000_000; /// Selector tests across the whole document: rules × elements styled, plus each parent, ancestor or /// sibling a combinator walks. -const MAX_CSS_SELECTOR_CHECKS: usize = 5_000_000; +pub(crate) const MAX_CSS_SELECTOR_CHECKS: usize = 5_000_000; /// Reads the drawable geometry of an SVG document, in document order. /// @@ -299,6 +310,10 @@ fn import_node<'a, 'input, K: Kind>( let local = parse_transform(transform).map_err(|e| svg_error(context.path_name, &format!("<{}> transform: {e}", tag)))?; state.transform = compose(inherited.transform, local); } + if tag == "g" && node.attribute((INKSCAPE_NS, "groupmode")) == Some("layer") { + let name = node.attribute((INKSCAPE_NS, "label")).or_else(|| node.attribute("id")).unwrap_or(""); + state.layer = Some(name.chars().take(MAX_LAYER_NAME).collect::().into()); + } context.charge_cascade()?; let style = computed_style(node, &state.inherited_style, &context.css_rules, K::PROPERTIES, &mut context.selector_checks); context.check_selector_budget()?; @@ -406,6 +421,7 @@ fn import_node<'a, 'input, K: Kind>( label: label(node), stroke: stroke_paint(&style), clips: state.clips.clone(), + layer: state.layer.as_deref().map(str::to_string), }); } for child in node.children().filter(|n| n.is_element()) { @@ -849,7 +865,7 @@ type CssPriority = (bool, u32, u32, u32, usize); type CascadeWinners = std::collections::HashMap; #[derive(Clone)] -struct CssRule { +pub(crate) struct CssRule { selectors: Vec, declarations: StyleMap, important: std::collections::HashSet, @@ -985,7 +1001,12 @@ impl SimpleSelector { /// CSS block at-rules whose content never styles geometry; skipped without a warning. const QUIET_AT_RULES: &[&str] = &["font-face", "keyframes", "-webkit-keyframes", "page", "counter-style", "charset", "namespace"]; -fn parse_stylesheets(root: roxmltree::Node<'_, '_>, extra: &[&str], path_name: &str, warnings: &mut Vec) -> Result, Error> { +pub(crate) fn parse_stylesheets( + root: roxmltree::Node<'_, '_>, + extra: &[&str], + path_name: &str, + warnings: &mut Vec, +) -> Result, Error> { let mut rules = Vec::new(); let mut dropped = 0usize; let mut warn = |message: String| push_warning(warnings, &mut dropped, message); @@ -1292,7 +1313,7 @@ fn parse_declarations(raw: &str, extra: &[&str], path_name: &str) -> Result<(Sty Ok((declarations, important)) } -fn computed_style(node: roxmltree::Node<'_, '_>, inherited: &StyleMap, rules: &[CssRule], extra: &[&str], checks: &mut usize) -> StyleMap { +pub(crate) fn computed_style(node: roxmltree::Node<'_, '_>, inherited: &StyleMap, rules: &[CssRule], extra: &[&str], checks: &mut usize) -> StyleMap { let mut result = inherited.clone(); result.remove("display"); let mut winners = CascadeWinners::new(); @@ -2081,4 +2102,29 @@ mod tests { let got = read(r#""#); assert_eq!(got.shapes[0].kind, Tag::Engrave); } + + #[test] + fn inkscape_layers_name_the_shapes_inside_them() { + let read = read( + r#" + + + + + + + + + + "#, + ); + let layers: Vec> = read.shapes.iter().map(|s| s.layer.as_deref()).collect(); + assert_eq!(layers, [None, Some("Cut & score"), Some("Cut & score"), Some("layer2"), Some(""), None, None]); + // A long name is cut, by characters. + let long = format!( + r#""#, + "é".repeat(MAX_LAYER_NAME + 50) + ); + assert_eq!(import::(&long, "test.svg").unwrap().shapes[0].layer.as_ref().map(|n| n.chars().count()), Some(MAX_LAYER_NAME)); + } } diff --git a/crates/svg/src/tree.rs b/crates/svg/src/tree.rs new file mode 100644 index 000000000..6c79c33a7 --- /dev/null +++ b/crates/svg/src/tree.rs @@ -0,0 +1,327 @@ +//! The full SVG parser: usvg, behind one bounded entry point. +//! +//! usvg (re-exported as [`usvg`]) turns an SVG into a tree with styles resolved, `use` expanded, +//! text laid out and units converted. It is the parser every Craft app that reads SVG in full +//! shares, at the one version this crate pins; apps take it from here and not as a dependency of +//! their own, so they also share the checks it needs. usvg trusts its input more than a file +//! from anywhere deserves: it unpacks SVGZ with no limit, expands entities with no total limit, +//! and converts the tree by recursion, a few stack frames an element, so a small hostile file +//! can exhaust memory or overflow the stack (an abort, not an error). +//! +//! [`parse`] is the entry point: the bytes of an SVG or SVGZ file and [`Limits`] in, a +//! [`usvg::Tree`] out. It runs, in order: [`crate::svgz::text`] (bounded unpacking, encodings), +//! [`crate::guard::entities_expand_past`], [`crate::guard::too_wide`] (attributes an element), +//! [`crate::guard::prescan`] (nesting in the text), +//! [`height`] (nesting with references followed, as usvg walks them), then usvg. +//! [`image_resolver`] bounds the SVG documents embedded in `` data URLs the same way. +//! [`draw`] bounds what drawing a tree with resvg allocates. +//! +//! Stack: usvg uses about 15 kB of stack for each level of nesting (measured natively by +//! VectorCraft). [`Limits::depth`] levels, times one more than [`Limits::embedded_depth`], must +//! fit the stack of the thread that calls [`parse`]: the default of 256 needs a thread with +//! 16 MB or so, and a 1 MB stack takes a depth of about 34. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub use usvg; + +use crate::paths::{MAX_CSS_SELECTOR_CHECKS, StyleMap, computed_style, parse_stylesheets}; +use crate::{guard, svgz}; + +pub mod draw; + +/// What one import may use. The defaults suit a desktop app that parses on a thread with a +/// large stack (see the module text). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Bytes of the document, unpacked if it is SVGZ. + pub bytes: usize, + /// How deep elements may nest, references followed (the root alone is 1). + pub depth: usize, + /// Bytes the document type's entity references may expand to in all. + pub entity_bytes: usize, + /// Refuse a document type that declares an entity holding markup, instead of counting what + /// it expands to ([`guard::entity_holds_markup`]). + pub refuse_entity_markup: bool, + /// Attributes one element may have ([`guard::too_wide`]). + pub attributes: usize, + /// The sum over all elements of their attribute count squared ([`guard::too_wide`]). + pub attribute_work: usize, + /// Nodes the XML may hold once entities are expanded. + pub xml_nodes: u32, + /// Bytes of all SVG documents embedded in `` data URLs, unpacked, for + /// [`image_resolver`]. + pub embedded_bytes: usize, + /// SVG documents embedded inside one another, for [`image_resolver`]. + pub embedded_depth: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + bytes: 64 << 20, + depth: 256, + entity_bytes: guard::MAX_ENTITY_EXPANSION, + refuse_entity_markup: false, + attributes: guard::MAX_ATTRIBUTES, + attribute_work: guard::MAX_ATTRIBUTE_WORK, + xml_nodes: 4_000_000, + embedded_bytes: 32 << 20, + embedded_depth: 4, + } + } +} + +/// Why a file was not parsed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Too large, a damaged or nested SVGZ, or text in no encoding SVG files use. + #[error(transparent)] + Text(#[from] svgz::Error), + #[error("the SVG's entities expand to too much text")] + Entities, + #[error("the SVG's document type declares an entity holding markup, which isn't supported")] + EntityMarkup, + /// Elements nest deeper than this many levels, references followed. + #[error("the SVG's elements are nested more than {0} levels deep")] + TooDeep(usize), + /// An element has more than this many attributes, or all elements together too many. + #[error("an element of the SVG has more than {0} attributes, or its elements have too many in all")] + TooWide(usize), + /// A style sheet that names other elements (`url(#…)`) and that this crate cannot read as + /// usvg will, so the nesting it makes cannot be measured. + #[error("the SVG's style sheet can't be checked: {0}")] + Stylesheet(String), + #[error("invalid SVG: {0}")] + Xml(String), + /// usvg's own error. + #[error("invalid SVG: {0}")] + Parse(String), +} + +/// The SVG text of the bytes of an SVG or SVGZ file, checked against `limits`: what [`parse`] +/// hands usvg. For a caller that edits the text before it parses ([`parse_text`]). +pub fn text<'a>(bytes: &'a [u8], limits: &Limits) -> Result, Error> { + let text = svgz::text(bytes, limits.bytes)?; + checked(&text, limits)?; + Ok(text) +} + +/// The tree of an SVG or SVGZ file, within `limits`. `options` are usvg's: fonts, the default +/// size, and the image resolver ([`image_resolver`] unless the caller reads linked files). +pub fn parse(bytes: &[u8], limits: &Limits, options: &usvg::Options<'_>) -> Result { + parse_text(&svgz::text(bytes, limits.bytes)?, limits, options) +} + +/// [`parse`] for SVG text the caller already holds. +pub fn parse_text(svg: &str, limits: &Limits, options: &usvg::Options<'_>) -> Result { + let xml = checked(svg, limits)?; + usvg::Tree::from_xmltree(&xml, options).map_err(|e| Error::Parse(e.to_string())) +} + +/// Every check on SVG text, cheapest first; the XML it parses to on the way. +fn checked<'s>(svg: &'s str, limits: &Limits) -> Result, Error> { + if svg.len() > limits.bytes { + return Err(Error::Text(svgz::Error::TooLarge(limits.bytes))); + } + if limits.refuse_entity_markup && guard::entity_holds_markup(svg) { + return Err(Error::EntityMarkup); + } + if guard::entities_expand_past(svg, limits.entity_bytes) { + return Err(Error::Entities); + } + if guard::too_wide(svg, limits.attributes, limits.attribute_work) { + return Err(Error::TooWide(limits.attributes)); + } + if guard::deeper_than(svg, limits.depth) { + return Err(Error::TooDeep(limits.depth)); + } + let options = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: limits.xml_nodes, ..Default::default() }; + let xml = roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string()))?; + if height(&xml, limits.depth)?.is_none() { + return Err(Error::TooDeep(limits.depth)); + } + Ok(xml) +} + +/// Properties whose value an element's descendants use too: the paint server they name is +/// converted at each descendant, under everything between. +const INHERITED: [&str; 6] = ["fill", "stroke", "marker", "marker-start", "marker-mid", "marker-end"]; + +/// Properties that name other elements and that the cascade does not track by itself. +const MARKERS: [&str; 4] = ["marker", "marker-start", "marker-mid", "marker-end"]; + +/// The ids of the `url(#id)` references in a property value. +fn urls(value: &str) -> impl Iterator { + value.split("url(").skip(1).filter_map(|rest| { + let id = rest.trim_start().trim_start_matches(['"', '\'']).strip_prefix('#')?; + id.split([')', '"', '\'']).next().map(str::trim).filter(|id| !id.is_empty()) + }) +} + +type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; + +/// How deep the elements of `xml` nest as usvg walks them (the root alone is 1); `None` past +/// `most`. Without recursion. +/// +/// What counts is the tree usvg converts, not the XML alone: a `` holds a copy of what it +/// refers to, and a clip path, mask, pattern, marker or filter is converted where it is used +/// (`clip-path: url(#a)` on an element of a clip path makes a chain as deep as there are clip +/// paths). So each element's depth is the deepest of what it holds and what it refers to, by +/// attribute, `style` and style sheet. A loop of references is not deep: usvg leaves it out. +/// +/// An error when a style sheet names elements and cannot be read here as usvg reads it (a +/// selector or at-rule this crate's cascade does not apply, a sheet for another medium, a sheet +/// past its size or work limits): the nesting such a sheet makes cannot be measured. +pub fn height(xml: &roxmltree::Document<'_>, most: usize) -> Result, Error> { + let elements = || xml.descendants().filter(|n| n.is_element()); + // The first element of an id, as usvg resolves references: a later one of the same id would + // hide a deep target behind a shallow one. + let mut ids: HashMap<&str, XNode> = HashMap::new(); + for n in elements() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } + let mut warnings = Vec::new(); + let rules = + parse_stylesheets(xml.root_element(), &MARKERS, "", &mut warnings).map_err(|e| Error::Stylesheet(e.0.trim_start_matches(": ").to_string())); + let sheets = || elements().filter(|n| n.tag_name().name() == "style"); + let names_elements = sheets().any(|n| n.text().is_some_and(|t| t.contains("url("))); + let rules = match rules { + Ok(rules) => rules, + // A sheet that names no element adds no nesting, whatever else is in it. + Err(_) if !names_elements => Vec::new(), + Err(e) => return Err(e), + }; + if names_elements { + if let Some(w) = warnings.first() { + return Err(Error::Stylesheet(w.clone())); + } + if sheets().any(|n| n.attribute("media").is_some_and(|m| !matches!(m.trim(), "" | "all" | "screen"))) { + return Err(Error::Stylesheet("a style sheet for another medium names elements".into())); + } + } + let nothing = StyleMap::new(); + let mut checks = 0usize; + // The elements `n` refers to, each with whether `n`'s descendants use it too. + let mut references = |n: XNode, out: &mut Vec<(roxmltree::NodeId, bool)>| -> Result<(), Error> { + for a in n.attributes().filter(|a| a.name() == "href") { + out.extend(a.value().trim().strip_prefix('#').and_then(|id| ids.get(id)).map(|t| (t.id(), false))); + } + for (name, value) in computed_style(n, ¬hing, &rules, &MARKERS, &mut checks) { + let inherited = INHERITED.contains(&name.as_str()); + out.extend(urls(&value).filter_map(|id| ids.get(id)).map(|t| (t.id(), inherited))); + } + if checks > MAX_CSS_SELECTOR_CHECKS { + return Err(Error::Stylesheet("the style sheet is too large for this many elements".into())); + } + Ok(()) + }; + // How deep each element's own tree goes, itself included; 0 while it is being worked out: a + // reference back to it then is a loop, which usvg leaves out. + let mut heights: HashMap = HashMap::new(); + enum Step { + Enter(roxmltree::NodeId), + Leave(roxmltree::NodeId, Vec<(roxmltree::NodeId, bool)>), + } + let mut stack = vec![Step::Enter(xml.root_element().id())]; + while let Some(step) = stack.pop() { + match step { + Step::Enter(id) => { + let Some(n) = xml.get_node(id) else { continue }; + match heights.entry(id) { + Entry::Occupied(_) => continue, + Entry::Vacant(v) => v.insert(0), + }; + let mut refs = vec![]; + references(n, &mut refs)?; + let targets: Vec = refs.iter().map(|(t, _)| *t).collect(); + stack.push(Step::Leave(id, refs)); + stack.extend(n.children().filter(|c| c.is_element()).map(|c| c.id()).chain(targets).map(Step::Enter)); + } + Step::Leave(id, refs) => { + let Some(n) = xml.get_node(id) else { continue }; + let of = |id: roxmltree::NodeId| heights.get(&id).copied().unwrap_or(0); + let held = n.children().filter(|c| c.is_element()).map(|c| of(c.id())).max().unwrap_or(0); + let used = |inherited: bool| refs.iter().filter(|(_, i)| *i == inherited).map(|(t, _)| of(*t)).max().unwrap_or(0); + let height = 1 + held.max(used(false)) + used(true); + if height > most { + return Ok(None); + } + heights.insert(id, height); + } + } + } + Ok(Some(heights.get(&xml.root_element().id()).copied().unwrap_or(0))) +} + +/// What the embedded SVGs of one import have used so far. +struct Used { + bytes: AtomicUsize, + depth: AtomicUsize, +} + +fn raster(data: &[u8]) -> bool { + data.starts_with(b"\x89PNG") + || data.starts_with(&[0xff, 0xd8, 0xff]) + || data.starts_with(b"GIF8") + || (data.len() > 12 && data.get(8..12) == Some(b"WEBP")) +} + +/// An embedded SVG document, if the budgets allow it. +fn embedded(used: &Used, limits: &Limits, data: &[u8], options: &usvg::Options<'_>) -> Option { + if used.depth.load(Ordering::Relaxed) >= limits.embedded_depth { + return None; + } + let left = limits.embedded_bytes.saturating_sub(used.bytes.load(Ordering::Relaxed)); + let text = match svgz::text(data, left.min(limits.bytes)) { + Ok(text) => text, + // What a refused document unpacked before it was refused was work too: it uses up what + // is left, so the next image can't repeat it. + Err(_) => { + used.bytes.fetch_add(left, Ordering::Relaxed); + return None; + } + }; + used.bytes.fetch_add(text.len(), Ordering::Relaxed); + let xml = checked(&text, limits).ok()?; + used.depth.fetch_add(1, Ordering::Relaxed); + let tree = usvg::Tree::from_xmltree(&xml, options); + used.depth.fetch_sub(1, Ordering::Relaxed); + tree.ok().map(usvg::ImageKind::SVG) +} + +/// An image resolver for `usvg::Options::image_href_resolver` that reads `` data URLs +/// only: raster images as they are (PNG, JPEG, GIF, WebP), SVG documents (SVGZ too) through the +/// same checks as [`parse`] and within [`Limits::embedded_bytes`] in all and +/// [`Limits::embedded_depth`] inside one another. An image past a limit is left out. Linked +/// files are never read. One resolver is one import's budget: make a new one for each [`parse`]. +pub fn image_resolver(limits: Limits) -> usvg::ImageHrefResolver<'static> { + let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) }); + usvg::ImageHrefResolver { + resolve_data: Box::new(move |mime, data, options| match mime { + "image/jpg" | "image/jpeg" => Some(usvg::ImageKind::JPEG(data)), + "image/png" => Some(usvg::ImageKind::PNG(data)), + "image/gif" => Some(usvg::ImageKind::GIF(data)), + "image/webp" => Some(usvg::ImageKind::WEBP(data)), + "image/svg+xml" => embedded(&used, &limits, &data, options), + "text/plain" if raster(&data) => match data.first() { + Some(0x89) => Some(usvg::ImageKind::PNG(data)), + Some(0xff) => Some(usvg::ImageKind::JPEG(data)), + Some(b'G') => Some(usvg::ImageKind::GIF(data)), + _ => Some(usvg::ImageKind::WEBP(data)), + }, + "text/plain" => embedded(&used, &limits, &data, options), + _ => None, + }), + resolve_string: Box::new(|_, _| None), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/tree/draw.rs b/crates/svg/src/tree/draw.rs new file mode 100644 index 000000000..fed7ac569 --- /dev/null +++ b/crates/svg/src/tree/draw.rs @@ -0,0 +1,431 @@ +//! Hard caps on the memory resvg needs to draw a subtree of a [`usvg::Tree`], checked before it +//! draws. +//! +//! A cap on the target pixmap bounds the target only. While it draws, resvg also allocates, and +//! a failed allocation aborts the process rather than returning an error: +//! - a layer for every isolated group (opacity, blend mode, clip, mask, filter), clamped to 5 × 5 +//! times the target, and one more per link of a clip-path chain, per clip child that has its own +//! clip-path, and per link of a mask-on-mask chain; +//! - for a filter, a result per primitive (all kept until the filter ends) and a clone of the layer. +//! The filter region is NOT clamped: `x`, `y`, `width` and `height` in the file set the size; +//! - a pattern tile of `width × height × scale`; +//! - for an embedded SVG image or an `feImage`, a pixmap of the current layer or filter region with +//! a nested drawing inside. +//! +//! The checks are worst-case products, not a model of resvg's phases. A node is checked against +//! the layers of its ancestors (which are alive while it is drawn) and nothing else, and each +//! shared definition is walked once per size class, so the walk is linear in the file. +//! +//! The allocation counts were read from resvg 0.45.1 (PhotoCraft's `svg/limits.rs`, where this +//! comes from). This crate does not depend on resvg; a caller that draws with another version +//! should treat the byte figures as an estimate with the same shape. + +use std::collections::HashMap; + +use usvg::Transform; + +/// Filter primitives per filter. A result of every primitive is kept; real filters have 1 to 12 +/// (Inkscape's drop shadow has 5), and 64 leaves headroom for generated ones. +const MAX_PRIMITIVES: usize = 64; +/// Links of one clip-path chain, mask-on-mask chain or nested clip children with their own +/// clip-path. Each link keeps a layer-sized buffer alive while the next is applied; real files use +/// one or two, and 16 layers of a 24 MP canvas (1.5 GiB) is the whole budget anyway. +const MAX_LINKS: usize = 16; +/// Patterns drawn inside patterns. Each tile is alive while the inner one is drawn. +const MAX_PATTERN_DEPTH: usize = 8; +/// Embedded SVG images inside embedded SVG images. Each draws on a pixmap of the enclosing +/// layer, which the live-bytes check charges, so deep chains of small images are harmless and +/// chains of big ones stop at the budget long before this. +const MAX_IMAGE_DEPTH: usize = 32; +/// Nested isolated groups, patterns, embedded images and filter images followed. It keeps this +/// walk, and resvg's own recursion, far from a stack overflow; real drawings stay under a few dozen. +const MAX_DEPTH: usize = 200; +/// Nested groups of any kind. usvg expands `` chains up to its own 1024-level limit, possibly +/// two groups per level; resvg recurses three frames per level too, so a walk that stops here is +/// lighter on the stack than the drawing it guards. +const MAX_NEST: usize = 2_100; +/// Nodes the walk visits before it gives up as too complex to check. A node costs a few +/// nanoseconds, so this bounds the walk to well under a second. +const MAX_NODES: usize = 2_000_000; + +/// Why a drawing should not be drawn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + /// Too many elements to check, rather than too much memory. + pub complex: bool, + /// Over the memory budget, as opposed to a nesting, chain or element-count cap. + pub memory: bool, + pub message: String, +} + +fn mib(bytes: f64) -> String { + if bytes < 1e15 { format!("about {} MiB", (bytes / 1_048_576.0).ceil()) } else { "an enormous amount of memory".to_string() } +} + +fn memory(what: &str, bytes: f64, budget: f64) -> Refusal { + let limit = budget as u64 >> 20; + Refusal { complex: false, memory: true, message: format!("{what} needs {}, over the {limit} MiB limit", mib(bytes)) } +} + +fn limit(what: &str, value: usize) -> Refusal { + Refusal { complex: false, memory: false, message: format!("{what} (limit {value})") } +} + +/// A pixel count that is never NaN: unknown sizes count as unbounded. +pub fn px(v: f64) -> f64 { + if v.is_nan() { f64::INFINITY } else { v } +} + +/// What is alive where a node is drawn. +#[derive(Clone, Copy)] +struct Cx { + /// Bytes held by the target and the layers of the ancestors. + live: f64, + /// Pixels of the pixmap being drawn on. + layer: f64, + /// Pixels of the largest group layer resvg makes in it (5 × 5 times its first target). + cap: f64, + /// Nested layers (isolated groups, patterns, embedded images, filter images). + depth: usize, + patterns: usize, + images: usize, + /// Nested groups of any kind: resvg and this walk recurse once per level. + nest: usize, +} + +/// What a subtree needs, relative to what is alive where it starts. A shared definition is walked +/// once per transform and size, and every later reference re-checks its profile against where it +/// is used, so nothing is skipped and the walk stays linear. +#[derive(Clone, Copy, Default)] +struct Profile { + /// Most bytes it holds beyond the incoming live bytes. + extra: f64, + /// Most extra levels of nested layers, patterns, embedded images and groups below it. + depth: usize, + patterns: usize, + images: usize, + nest: usize, +} + +impl Profile { + fn max(self, o: Self) -> Self { + Self { + extra: self.extra.max(o.extra), + depth: self.depth.max(o.depth), + patterns: self.patterns.max(o.patterns), + images: self.images.max(o.images), + nest: self.nest.max(o.nest), + } + } + + /// This profile seen from a parent that adds `bytes` and one level of each kind in `levels` + /// (layers, patterns, images, groups). + fn under(self, bytes: f64, levels: [usize; 4]) -> Self { + Self { + extra: self.extra + bytes, + depth: self.depth + levels[0], + patterns: self.patterns + levels[1], + images: self.images + levels[2], + nest: self.nest + levels[3], + } + } +} + +/// A shared definition at one linear transform, layer size and clamp. +type Key = (usize, [u32; 4], u64, u64); + +fn key(ptr: usize, ts: Transform, cx: Cx) -> Key { + (ptr, [ts.sx.to_bits(), ts.kx.to_bits(), ts.ky.to_bits(), ts.sy.to_bits()], cx.layer.to_bits(), cx.cap.to_bits()) +} + +struct Walk { + /// Bytes of RGBA8 buffers alive at once that the drawing may need. + budget: f64, + visited: usize, + shared: HashMap, + clips: HashMap, + masks: HashMap, +} + +/// Check that drawing `nodes` at `ts` onto a `width × height` pixmap keeps the RGBA8 buffers alive +/// at once inside `budget` bytes, given `held` bytes already in use (anything the caller keeps; +/// the target itself is counted here). +pub fn check(nodes: &[usvg::Node], ts: Transform, width: u32, height: u32, held: u64, budget: u64) -> Result<(), Refusal> { + let target = f64::from(width) * f64::from(height); + let cx = Cx { live: held as f64 + target * 4.0, layer: target, cap: 25.0 * target, depth: 0, patterns: 0, images: 0, nest: 0 }; + let mut walk = Walk { budget: budget as f64, visited: 0, shared: HashMap::new(), clips: HashMap::new(), masks: HashMap::new() }; + walk.nodes(nodes, ts, cx).map(|_| ()) +} + +impl Walk { + fn need(&self, live: f64, what: impl FnOnce() -> String) -> Result<(), Refusal> { + if live > self.budget { Err(memory(&what(), live, self.budget)) } else { Ok(()) } + } +} + +impl Walk { + fn nodes(&mut self, nodes: &[usvg::Node], ts: Transform, cx: Cx) -> Result { + let mut profile = Profile::default(); + for n in nodes { + profile = profile.max(self.node(n, ts, cx)?); + } + Ok(profile) + } + + // The recursion through plain groups is the deep one (usvg allows 1,000+ levels), so only small + // functions sit on it; everything with many locals is kept off it with `inline(never)`. + fn node(&mut self, node: &usvg::Node, ts: Transform, cx: Cx) -> Result { + self.visited += 1; + if self.visited > MAX_NODES { + return Err(Refusal { complex: true, memory: false, message: format!("the drawing has more than {MAX_NODES} elements to check") }); + } + match node { + usvg::Node::Group(g) if !g.should_isolate() => self.plain(g, ts, cx), + usvg::Node::Group(g) => self.group(g, ts, cx), + usvg::Node::Text(t) => self.group(t.flattened(), ts, cx), + usvg::Node::Path(p) => self.path(p, ts, cx), + usvg::Node::Image(image) => self.image(image, ts, cx), + } + } + + /// A plain group is no layer. + fn plain(&mut self, g: &usvg::Group, ts: Transform, cx: Cx) -> Result { + if cx.nest >= MAX_NEST { + return Err(limit("groups are nested too deeply", MAX_NEST)); + } + let inside = self.nodes(g.children(), ts.pre_concat(g.transform()), Cx { nest: cx.nest + 1, ..cx })?; + Ok(inside.under(0.0, [0, 0, 0, 1])) + } + + #[inline(never)] + fn path(&mut self, p: &usvg::Path, ts: Transform, cx: Cx) -> Result { + let mut profile = Profile::default(); + if !p.is_visible() { + return Ok(profile); + } + for paint in p.fill().map(|f| f.paint()).into_iter().chain(p.stroke().map(|s| s.paint())) { + if let usvg::Paint::Pattern(pattern) = paint { + profile = profile.max(self.pattern(pattern, ts, cx)?); + } + } + Ok(profile) + } + + /// resvg draws an embedded SVG on a pixmap the size of the current one, with its own 5 × 5 clamp. + #[inline(never)] + fn image(&mut self, image: &usvg::Image, ts: Transform, cx: Cx) -> Result { + let usvg::ImageKind::SVG(tree) = image.kind() else { return Ok(Profile::default()) }; + if cx.images >= MAX_IMAGE_DEPTH { + return Err(limit("embedded SVG images are nested too deeply", MAX_IMAGE_DEPTH)); + } + let bytes = cx.layer * 4.0; + self.need(cx.live + bytes, || "an embedded SVG image".to_string())?; + let nested = Cx { live: cx.live + bytes, cap: 25.0 * cx.layer, depth: cx.depth + 1, images: cx.images + 1, nest: cx.nest + 1, ..cx }; + deeper(nested)?; + let inside = self.nodes(tree.root().children(), ts, nested)?; + Ok(Profile { extra: bytes, ..Profile::default() }.max(inside.under(bytes, [1, 0, 1, 1]))) + } + + /// Walk a definition shared by reference (pattern, mask, filter image) once per transform and + /// size; a repeat only re-checks its profile where it is used now. + fn shared(&mut self, key: Key, cx: Cx, walk: impl FnOnce(&mut Self) -> Result) -> Result { + let Some(p) = self.shared.get(&key).copied() else { + let p = walk(self)?; + self.shared.insert(key, p); + return Ok(p); + }; + self.need(cx.live + p.extra, || "a shared definition used here".to_string())?; + if cx.depth + p.depth > MAX_DEPTH { + return Err(limit("layers are nested too deeply", MAX_DEPTH)); + } + if cx.patterns + p.patterns > MAX_PATTERN_DEPTH { + return Err(limit("patterns are nested too deeply", MAX_PATTERN_DEPTH)); + } + if cx.images + p.images > MAX_IMAGE_DEPTH { + return Err(limit("embedded SVG images are nested too deeply", MAX_IMAGE_DEPTH)); + } + if cx.nest + p.nest > MAX_NEST { + return Err(limit("groups are nested too deeply", MAX_NEST)); + } + Ok(p) + } + + #[inline(never)] + fn group(&mut self, g: &usvg::Group, ts: Transform, cx: Cx) -> Result { + if !g.should_isolate() { + return self.plain(g, ts, cx); + } + let ts = ts.pre_concat(g.transform()); + if cx.nest >= MAX_NEST { + return Err(limit("groups are nested too deeply", MAX_NEST)); + } + let Some(bb) = g.layer_bounding_box().transform(ts) else { return Ok(Profile::default()) }; + let own = px((f64::from(bb.width()).ceil() + 4.0) * (f64::from(bb.height()).ceil() + 4.0)).min(cx.cap); + let inner = Cx { live: cx.live + own * 4.0, layer: own, depth: cx.depth + 1, nest: cx.nest + 1, ..cx }; + deeper(inner)?; + // Filter, clip and mask are applied one after another; each needs its own extra buffers + // beside the group's layer. A clip or mask link is a 4 B/px pixmap and a 1 B/px mask. + let clip = g.clip_path().map_or(Ok(0), |c| self.clip_links(c, 0))?; + let mask = g.mask().map_or(Ok(0), |m| self.mask_links(m, 0))?; + let links = clip.max(mask); + let layers = own * 4.0 + own * 5.0 * links as f64; + self.need(cx.live + layers, || format!("a group with {links} clip or mask layers of {} px", own as u64))?; + let mut profile = Profile { extra: layers, ..Profile::default() }; + for filter in g.filters() { + profile = profile.max(self.filter(filter, ts, cx, own)?); + } + let mut mask_ref = g.mask(); + let mut link = 1.0; + while let Some(mask) = mask_ref { + // The mask content is drawn while this and every earlier link's pixmap and mask are alive. + let bytes = own * 4.0 + own * 5.0 * link; + let content = Cx { live: cx.live + bytes, ..inner }; + let inside = + self.shared(key(std::ptr::from_ref(mask) as usize, ts, content), content, |w| w.nodes(mask.root().children(), ts, content))?; + profile = profile.max(inside.under(bytes, [1, 0, 0, 1])); + mask_ref = mask.mask(); + link += 1.0; + } + let inside = self.nodes(g.children(), ts, inner)?; + Ok(profile.max(inside.under(own * 4.0, [1, 0, 0, 1]))) + } + + /// Layers a clip-path keeps alive: its own, the deepest clip child with a clip-path of its own, + /// or the next link of the chain, whichever is deepest. Nothing is walked twice. + fn clip_links(&mut self, clip: &usvg::ClipPath, depth: usize) -> Result { + let key = std::ptr::from_ref(clip) as usize; + if let Some(n) = self.clips.get(&key) { + return Ok(*n); + } + let too_long = || limit("clip-paths are chained or nested too deeply", MAX_LINKS); + if depth >= MAX_LINKS { + return Err(too_long()); + } + let inside = self.clip_children(clip.root(), depth + 1)?; + let next = clip.clip_path().map_or(Ok(0), |c| self.clip_links(c, depth + 1))?; + let n = 1 + inside.max(next); + if n > MAX_LINKS { + return Err(too_long()); + } + self.clips.insert(key, n); + Ok(n) + } + + /// resvg's `clip_group`: a clip child with its own clip-path is drawn on a layer of its own, + /// kept while its descendants are drawn and its clip is applied. + fn clip_children(&mut self, group: &usvg::Group, depth: usize) -> Result { + let mut deepest = 0; + for child in group.children() { + let inner = match child { + usvg::Node::Group(g) => { + let inside = self.clip_children(g, depth + usize::from(g.clip_path().is_some()))?; + match g.clip_path() { + Some(c) => 1 + inside.max(self.clip_links(c, depth + 1)?), + None => inside, + } + } + usvg::Node::Text(t) => self.clip_children(t.flattened(), depth)?, + _ => 0, + }; + if inner + depth > MAX_LINKS { + return Err(limit("clip-paths are chained or nested too deeply", MAX_LINKS)); + } + deepest = deepest.max(inner); + } + Ok(deepest) + } + + /// Layers a mask chain keeps alive: a mask pixmap per link while the next is applied. + fn mask_links(&mut self, mask: &usvg::Mask, depth: usize) -> Result { + let key = std::ptr::from_ref(mask) as usize; + if let Some(n) = self.masks.get(&key) { + return Ok(*n); + } + if depth >= MAX_LINKS { + return Err(limit("masks are chained too deeply", MAX_LINKS)); + } + let n = 1 + mask.mask().map_or(Ok(0), |m| self.mask_links(m, depth + 1))?; + // A cached tail can make the chain longer than `depth` shows; the cap is on the whole chain. + if n > MAX_LINKS { + return Err(limit("masks are chained too deeply", MAX_LINKS)); + } + self.masks.insert(key, n); + Ok(n) + } + + /// A filter keeps a result per primitive, each as large as the filter region or the layer it + /// filters, whichever is larger (usvg makes the layer the union of all of a group's filter + /// regions, and `SourceGraphic` clones the whole layer). `own` is the size of that layer. + fn filter(&mut self, f: &usvg::filter::Filter, ts: Transform, cx: Cx, own: f64) -> Result { + use usvg::filter::Kind; + let prims = f.primitives().len(); + if prims > MAX_PRIMITIVES { + return Err(limit("a filter has too many primitives", MAX_PRIMITIVES)); + } + let Some(region) = f.rect().transform(ts) else { return Ok(Profile::default()) }; + let region = px(f64::from(region.width()).ceil() * f64::from(region.height()).ceil()); + let slot = region.max(own) * 4.0; + // Buffers a primitive allocates beside its result (resvg 0.45.1 `filter/`): a blur works + // on one copy, an IIR blur (sigma under 2 after scaling) also on f64 scratch; blend, + // composite and displacement map convert two inputs; a drop shadow holds the input, its + // blur, the shadow and the result. + let (sx, sy) = ts.get_scale(); + let transient = |kind: &Kind| match kind { + Kind::GaussianBlur(b) if b.std_dev_x().get() * sx >= 2.0 || b.std_dev_y().get() * sy >= 2.0 => 1.0, + Kind::GaussianBlur(_) => 2.0, + Kind::Blend(_) | Kind::Composite(_) | Kind::DisplacementMap(_) => 2.0, + // A tile of the source graphic converts and tiles two copies. + Kind::Tile(t) if matches!(t.input(), usvg::filter::Input::SourceGraphic) => 2.0, + Kind::DropShadow(_) => 4.0, + _ => 1.0, + }; + let most = f.primitives().iter().map(|p| transient(p.kind())).fold(0.0, f64::max); + let extra = own * 4.0 + slot * (prims as f64 + most); + self.need(cx.live + extra, || format!("a filter with {prims} primitives over a region of {} px", region.min(1e18) as u64))?; + let mut profile = Profile { extra, ..Profile::default() }; + for (i, primitive) in f.primitives().iter().enumerate() { + if let Kind::Image(fe) = primitive.kind() { + // What an feImage draws sees the results kept so far. + let bytes = own * 4.0 + slot * (i + 1) as f64; + let (sx, sy) = ts.get_scale(); + let scaled = Transform::from_scale(sx, sy); + let nested = Cx { live: cx.live + bytes, layer: region, cap: region, depth: cx.depth + 1, nest: cx.nest + 1, ..cx }; + deeper(nested)?; + let inside = + self.shared(key(std::ptr::from_ref(fe) as usize, scaled, nested), nested, |w| w.nodes(fe.root().children(), scaled, nested))?; + profile = profile.max(inside.under(bytes, [1, 0, 0, 1])); + } + } + Ok(profile) + } + + /// A pattern tile is `width × height × scale` pixels, whatever the target. + fn pattern(&mut self, pattern: &usvg::Pattern, ts: Transform, cx: Cx) -> Result { + let (sx, sy) = ts.pre_concat(pattern.transform()).get_scale(); + let rect = pattern.rect(); + let (tw, th) = (f64::from(rect.width() * sx).round(), f64::from(rect.height() * sy).round()); + // resvg draws no tile smaller than a pixel (or not a number). + if !(tw >= 1.0 && th >= 1.0) { + return Ok(Profile::default()); + } + let tile = tw * th; + let bytes = tile * 4.0; + self.need(cx.live + bytes, || format!("a pattern tile of {tw} × {th} px"))?; + if cx.patterns >= MAX_PATTERN_DEPTH { + return Err(limit("patterns are nested too deeply", MAX_PATTERN_DEPTH)); + } + let nested = Cx { live: cx.live + bytes, layer: tile, depth: cx.depth + 1, patterns: cx.patterns + 1, nest: cx.nest + 1, ..cx }; + deeper(nested)?; + let inner_ts = Transform::from_scale(sx, sy); + let inside = self + .shared(key(std::ptr::from_ref(pattern) as usize, inner_ts, nested), nested, |w| w.nodes(pattern.root().children(), inner_ts, nested))?; + Ok(Profile { extra: bytes, ..Profile::default() }.max(inside.under(bytes, [1, 1, 0, 1]))) + } +} + +fn deeper(cx: Cx) -> Result<(), Refusal> { + if cx.depth > MAX_DEPTH { + return Err(limit("layers are nested too deeply", MAX_DEPTH)); + } + Ok(()) +} diff --git a/crates/svg/src/tree/tests.rs b/crates/svg/src/tree/tests.rs new file mode 100644 index 000000000..751e7726b --- /dev/null +++ b/crates/svg/src/tree/tests.rs @@ -0,0 +1,304 @@ +#![allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] + +use super::*; + +const DEPTH: usize = 256; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +/// `n` groups in one another around a rectangle. +fn groups(n: usize) -> String { + format!("{}{}", "".repeat(n), "".repeat(n)) +} + +fn deep(svg: &str) -> bool { + height(&roxmltree::Document::parse(svg).unwrap(), DEPTH).unwrap().is_none() +} + +fn options() -> usvg::Options<'static> { + usvg::Options { image_href_resolver: image_resolver(Limits::default()), ..Default::default() } +} + +/// A limit small enough for usvg to convert on a test thread's stack. +fn shallow() -> Limits { + Limits { depth: 24, ..Limits::default() } +} + +fn try_parse(svg: &str, limits: &Limits) -> Result { + parse(svg.as_bytes(), limits, &options()) +} + +/// Every image in the tree, as whether it is an SVG document, nested ones after their parent. +fn images(group: &usvg::Group, out: &mut Vec) { + for node in group.children() { + match node { + usvg::Node::Group(g) => images(g, out), + usvg::Node::Image(image) => match image.kind() { + usvg::ImageKind::SVG(tree) => { + out.push(true); + images(tree.root(), out); + } + _ => out.push(false), + }, + _ => {} + } + } +} + +/// `` showing the SVG text `inner` as a data URL. +fn embed(inner: &str) -> String { + let encoded: String = inner.bytes().map(|b| if b.is_ascii_alphanumeric() { char::from(b).to_string() } else { format!("%{b:02X}") }).collect(); + format!(r#""#) +} + +#[test] +fn an_svg_and_an_svgz_parse_to_the_same_tree() { + let text = svg(""); + let tree = try_parse(&text, &shallow()).unwrap(); + assert_eq!((tree.size().width(), tree.size().height()), (10.0, 10.0)); + assert_eq!(tree.root().children().len(), 1); + let packed = parse(&svgz::compress(text.as_bytes()), &shallow(), &options()).unwrap(); + assert_eq!(packed.root().children().len(), 1); + assert_eq!(super::text(text.as_bytes(), &shallow()).unwrap(), text); + assert_eq!(parse_text(&text, &shallow(), &options()).unwrap().root().children().len(), 1); +} + +#[test] +fn every_limit_refuses_the_file_past_it() { + let text = svg(&groups(4)); + // Bytes, plain and unpacked. + let small = Limits { bytes: text.len() - 1, ..shallow() }; + assert_eq!(try_parse(&text, &small).err(), Some(Error::Text(svgz::Error::TooLarge(text.len() - 1)))); + assert_eq!(parse(&svgz::compress(text.as_bytes()), &small, &options()).err(), Some(Error::Text(svgz::Error::TooLarge(text.len() - 1)))); + assert!(matches!(parse_text(&text, &small, &options()), Err(Error::Text(_)))); + assert!(try_parse(&text, &Limits { bytes: text.len(), ..shallow() }).is_ok()); + // Depth in the text: the root, four groups and the rectangle are six levels. + assert!(try_parse(&text, &Limits { depth: 6, ..shallow() }).is_ok()); + assert_eq!(try_parse(&text, &Limits { depth: 5, ..shallow() }).err(), Some(Error::TooDeep(5))); + // Depth by reference: no element is nested, each holds the next. + let chain: String = (0..20).map(|i| format!("", i + 1)).collect(); + let uses = svg(&format!("{chain}")); + assert!(!guard::deeper_than(&uses, 24)); + assert_eq!(try_parse(&uses, &shallow()).err(), Some(Error::TooDeep(24))); + // Attributes on one element. + let wide = svg(&format!("", (0..40).map(|i| format!(" a{i}='1'")).collect::())); + assert!(try_parse(&wide, &Limits { attributes: 40, ..shallow() }).is_ok()); + assert_eq!(try_parse(&wide, &Limits { attributes: 39, ..shallow() }).err(), Some(Error::TooWide(39))); + assert_eq!(try_parse(&wide, &Limits { attribute_work: 1000, ..shallow() }).err(), Some(Error::TooWide(4096))); + // Entities. + let bomb = format!("]>{}", "x".repeat(1000), svg(&format!("{}", "&a;".repeat(10)))); + assert_eq!(try_parse(&bomb, &Limits { entity_bytes: 9_999, ..shallow() }).err(), Some(Error::Entities)); + assert!(try_parse(&bomb, &Limits { entity_bytes: 10_000, ..shallow() }).is_ok()); + // XML nodes. + assert!(matches!(try_parse(&svg(&"".repeat(100)), &Limits { xml_nodes: 50, ..shallow() }), Err(Error::Xml(_)))); + // Not XML, and XML that is not an SVG. + assert!(matches!(try_parse("", &shallow()), Err(Error::Parse(_)))); + assert!(matches!(parse(&[0xfe, 0xff, 0xd8], &shallow(), &options()), Err(Error::Text(svgz::Error::Encoding)))); +} + +#[test] +fn an_entity_holding_markup_is_refused_only_when_asked() { + let text = format!("\">]>{}", svg("&a;")); + assert_eq!(try_parse(&text, &Limits { refuse_entity_markup: true, ..shallow() }).err(), Some(Error::EntityMarkup)); + let tree = try_parse(&text, &shallow()).unwrap(); + assert_eq!(tree.root().children().len(), 1, "the entity's rectangle is drawn"); + // A namespace entity, as drawing apps write, is not markup. + let names = "]>"; + assert!(try_parse(names, &Limits { refuse_entity_markup: true, ..shallow() }).is_ok()); +} + +#[test] +fn elements_nest_up_to_the_cap() { + let (at, past) = (svg(&groups(DEPTH - 2)), svg(&groups(DEPTH - 1))); + assert!(!deep(&at)); + assert!(deep(&past)); + let ten = svg(&groups(8)); + let xml = roxmltree::Document::parse(&ten).unwrap(); + assert_eq!((height(&xml, 10), height(&xml, 9)), (Ok(Some(10)), Ok(None))); + assert_eq!(height(&roxmltree::Document::parse("").unwrap(), 1), Ok(Some(1))); +} + +#[test] +fn a_chain_of_uses_is_as_deep_as_what_it_copies() { + let chain = |n: usize| { + let defs: String = (0..n).map(|i| format!("", i + 1)).collect(); + svg(&format!("{defs}")) + }; + assert!(!deep(&chain(10))); + assert!(deep(&chain(DEPTH / 2)), "two elements a link"); + assert!(deep(&chain(5000))); +} + +#[test] +fn chains_of_clips_masks_and_patterns_count_by_attribute_style_and_style_sheet() { + let by_attribute = |n: usize| -> String { + (0..n).map(|i| format!("", i + 1)).collect() + }; + let by_style = |n: usize| -> String { + (0..n).map(|i| format!("", i + 1)).collect() + }; + let by_sheet = |n: usize| -> String { + let rules: String = (0..n).map(|i| format!(".p{i} {{ fill: url(#c{}) }}", i + 1)).collect(); + let patterns: String = + (0..n).map(|i| format!("")).collect(); + format!("{patterns}") + }; + let by_marker = + |n: usize| -> String { (0..n).map(|i| format!("", i + 1)).collect() }; + for chain in [&by_attribute as &dyn Fn(usize) -> String, &by_style, &by_sheet, &by_marker] { + let file = |n| { + svg(&format!( + "{}", + chain(n) + )) + }; + assert!(!deep(&file(5))); + assert!(deep(&file(DEPTH))); + } +} + +#[test] +fn many_paint_servers_used_side_by_side_are_not_a_chain() { + // As drawing apps write gradients: a class each, used by objects that aren't inside one another. + let rules: String = (0..300).map(|i| format!(".c{i}{{fill:url(#g{i})}}")).collect(); + let defs: String = (0..300).map(|i| format!("")).collect(); + let art: String = (0..300).map(|i| format!("")).collect(); + assert!(!deep(&svg(&format!("{defs}{art}")))); +} + +#[test] +fn a_reference_follows_the_first_element_of_its_id() { + // A chain of ``s as deep as the cap, each link's id given again later to an empty group: + // usvg follows the first, so the chain is deep. + let chain: String = (1..DEPTH).map(|i| format!("", i - 1)).collect(); + let shallow: String = (1..DEPTH).map(|i| format!("")).collect(); + let top = format!("", DEPTH - 1); + assert!(deep(&svg(&format!("{chain}{shallow}{top}")))); +} + +#[test] +fn a_loop_of_references_is_not_deep() { + assert!(!deep(&svg(""))); +} + +#[test] +fn an_inherited_paint_is_converted_under_everything_between() { + // Neither the pattern nor the art is too deep; the pattern under the art's last object is. + let half = DEPTH / 2; + let pattern = format!("{}", groups(half)); + assert!(!deep(&svg(&format!("{pattern}{}", groups(half / 2))))); + assert!(deep(&svg(&format!("{pattern}{}", groups(half))))); +} + +#[test] +fn a_style_sheet_that_names_elements_and_cannot_be_read_is_refused() { + let sheet = |css: &str, media: &str| { + svg(&format!("{css}")) + }; + let check = |text: &str| height(&roxmltree::Document::parse(text).unwrap(), DEPTH); + // The root, the rectangle, and under it the pattern its fill names. + assert_eq!(check(&sheet(".a{fill:url(#p)}", "")), Ok(Some(3))); + // An at-rule block, a sheet for another medium and a broken rule: read here they would add + // nothing, read by usvg they might. + assert!(matches!(check(&sheet("@media print{.a{fill:url(#p)}}", "")), Err(Error::Stylesheet(_)))); + assert!(matches!(check(&sheet(".a{fill:url(#p)}", " media='print'")), Err(Error::Stylesheet(_)))); + assert!(matches!(check(&sheet(".a{fill:url(#p)", "")), Err(Error::Stylesheet(_)))); + assert!(matches!(try_parse(&sheet("@media print{.a{fill:url(#p)}}", ""), &shallow()), Err(Error::Stylesheet(_)))); + // The same sheets naming no element add no nesting and are usvg's to read. + assert_eq!(check(&sheet("@media print{.a{fill:red}}", "")), Ok(Some(2))); + assert_eq!(check(&sheet(".a{fill:red", "")), Ok(Some(2))); + assert!(try_parse(&sheet(".a{fill:red}", " media='print'"), &shallow()).is_ok()); +} + +#[test] +fn embedded_svgs_are_read_within_their_budgets() { + let inner = svg(""); + let found = |text: &str, limits: Limits| { + let options = usvg::Options { image_href_resolver: image_resolver(limits), ..Default::default() }; + let mut out = vec![]; + images(parse(text.as_bytes(), &limits, &options).unwrap().root(), &mut out); + out + }; + let one = svg(&embed(&inner)); + assert_eq!(found(&one, shallow()), [true]); + // Bytes: one fits, a second does not; what a refused one would have used is spent. + let two = svg(&format!("{}{}", embed(&inner), embed(&inner))); + assert_eq!(found(&two, shallow()), [true, true]); + assert_eq!(found(&two, Limits { embedded_bytes: inner.len() * 2 - 1, ..shallow() }), [true]); + assert_eq!(found(&one, Limits { embedded_bytes: inner.len() - 1, ..shallow() }), [false; 0]); + // Depth: a document in a document in a document. + let three = svg(&embed(&svg(&embed(&inner)))); + assert_eq!(found(&three, shallow()), [true, true]); + assert_eq!(found(&three, Limits { embedded_depth: 1, ..shallow() }), [true]); + assert_eq!(found(&three, Limits { embedded_depth: 0, ..shallow() }), [false; 0]); + // An embedded document goes through the same checks: too deep, it is left out. + let nested = svg(&embed(&svg(&groups(30)))); + assert_eq!(found(&nested, shallow()), [false; 0]); + let wide = svg(&embed(&svg(&format!("", (0..40).map(|i| format!(" a{i}='1'")).collect::())))); + assert_eq!(found(&wide, shallow()), [true]); + assert_eq!(found(&wide, Limits { attributes: 39, ..shallow() }), [false; 0]); + // A linked file is never read. + assert_eq!(found(&svg(r#""#), shallow()), [false; 0]); +} + +mod draw_checks { + use super::*; + + const GIB: u64 = 1 << 30; + + fn check(body: &str, budget: u64) -> Result<(), draw::Refusal> { + let tree = try_parse(&svg(body), &Limits { depth: 40, ..Limits::default() }).unwrap(); + draw::check(tree.root().children(), usvg::Transform::identity(), 100, 100, 0, budget) + } + + #[test] + fn an_ordinary_drawing_fits() { + let body = " + + + "; + assert_eq!(check(body, GIB), Ok(())); + // The target alone is 100 × 100 × 4 bytes. + assert!(check("", 40_000).is_ok()); + assert!(check("", 39_999).is_ok(), "nothing is charged where nothing draws on a layer"); + } + + #[test] + fn a_filter_region_the_file_sets_is_charged_whatever_the_target() { + let body = " + "; + let refusal = check(body, GIB).unwrap_err(); + assert!(refusal.memory && !refusal.complex && refusal.message.contains("filter"), "{refusal:?}"); + } + + #[test] + fn a_pattern_tile_is_charged_at_its_own_size() { + let body = " + "; + let refusal = check(body, GIB).unwrap_err(); + assert!(refusal.memory && refusal.message.contains("pattern tile"), "{refusal:?}"); + let small = " + "; + assert_eq!(check(small, GIB), Ok(())); + } + + #[test] + fn counts_are_capped_apart_from_memory() { + let primitives = + |n: usize| format!("{}", "".repeat(n)); + assert_eq!(check(&primitives(64), GIB), Ok(())); + let refusal = check(&primitives(65), GIB).unwrap_err(); + assert!(!refusal.memory && refusal.message.contains("primitives"), "{refusal:?}"); + let clips = |n: usize| { + let chain: String = + (0..n).map(|i| format!("", i + 1)).collect(); + format!("{chain}") + }; + assert_eq!(check(&clips(15), GIB), Ok(())); + let refusal = check(&clips(16), GIB).unwrap_err(); + assert!(!refusal.memory && refusal.message.contains("clip-paths"), "{refusal:?}"); + } +}