From 3afade51630fcdb02662c761e0805bc6147bb0ac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:13:31 -0500 Subject: [PATCH] Expose renderer-neutral SVG preflight --- crates/svg/src/lib.rs | 1 + crates/svg/src/preflight.rs | 91 +++++++++++++++++++++++++++++++++++++ crates/svg/src/tree.rs | 34 +++++++------- 3 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 crates/svg/src/preflight.rs diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index fd6ca685e..982e4f82b 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -20,6 +20,7 @@ pub mod color; pub mod guard; pub mod paths; +pub mod preflight; pub mod sniff; pub mod svgz; #[cfg(feature = "tree")] diff --git a/crates/svg/src/preflight.rs b/crates/svg/src/preflight.rs new file mode 100644 index 000000000..3ab381ff3 --- /dev/null +++ b/crates/svg/src/preflight.rs @@ -0,0 +1,91 @@ +//! Decode and validate SVG/SVGZ text before a caller hands it to a renderer. +//! +//! This boundary has no dependency on an SVG renderer. It decodes the file's text encoding and +//! bounded gzip container, then checks entity expansion, attribute work, XML structure, node +//! count and nesting depth. + +use std::borrow::Cow; + +/// Limits for decoding and validating one SVG document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Bytes of source text, also the largest unpacked SVGZ. + pub bytes: usize, + /// Deepest XML element nesting (the root alone is 1). + pub depth: usize, + /// Total bytes entities may expand to. + pub entity_bytes: usize, + /// Refuse a doctype entity containing markup. + pub refuse_entity_markup: bool, + /// Attributes on one element. + pub attributes: usize, + /// Sum of squared attribute counts across the document. + pub attribute_work: usize, + /// Maximum XML nodes after entity expansion. + pub xml_nodes: u32, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + bytes: 64 << 20, + depth: 256, + entity_bytes: crate::guard::MAX_ENTITY_EXPANSION, + refuse_entity_markup: false, + attributes: crate::guard::MAX_ATTRIBUTES, + attribute_work: crate::guard::MAX_ATTRIBUTE_WORK, + xml_nodes: 4_000_000, + } + } +} + +/// Why an SVG's text or XML could not be read within `Limits`. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are too large, corrupt SVGZ, nested SVGZ, or invalid text encoding. + #[error(transparent)] + Text(#[from] crate::svgz::Error), + /// Entity references expand past the configured limit. + #[error("the SVG's entities expand to too much text")] + Entities, + /// A doctype entity contains markup and is refused by the caller's policy. + #[error("the SVG's document type declares an entity holding markup, which isn't supported")] + EntityMarkup, + /// XML element nesting exceeds the configured limit. + #[error("the SVG's elements are nested more than {0} levels deep")] + TooDeep(usize), + /// Attribute count or total attribute work exceeds the configured limit. + #[error("an element of the SVG has more than {0} attributes, or its elements have too many in all")] + TooWide(usize), + /// The XML is malformed or exceeds the node limit. + #[error("invalid SVG XML: {0}")] + Xml(String), +} + +/// Decodes SVG or SVGZ text and checks the XML against `limits`. +pub fn text<'a>(bytes: &'a [u8], limits: &Limits) -> Result, Error> { + let text = crate::svgz::text(bytes, limits.bytes)?; + parse_text(&text, limits)?; + Ok(text) +} + +/// Parses already-decoded SVG text and checks it against `limits`. +pub fn parse_text<'a>(svg: &'a str, limits: &Limits) -> Result, Error> { + if svg.len() > limits.bytes { + return Err(Error::Text(crate::svgz::Error::TooLarge(limits.bytes))); + } + if limits.refuse_entity_markup && crate::guard::entity_holds_markup(svg) { + return Err(Error::EntityMarkup); + } + if crate::guard::entities_expand_past(svg, limits.entity_bytes) { + return Err(Error::Entities); + } + if crate::guard::too_wide(svg, limits.attributes, limits.attribute_work) { + return Err(Error::TooWide(limits.attributes)); + } + if crate::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() }; + roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string())) +} diff --git a/crates/svg/src/tree.rs b/crates/svg/src/tree.rs index 6c79c33a7..dd14be619 100644 --- a/crates/svg/src/tree.rs +++ b/crates/svg/src/tree.rs @@ -125,23 +125,23 @@ pub fn parse_text(svg: &str, limits: &Limits, options: &usvg::Options<'_>) -> Re /// 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()))?; + let preflight_limits = crate::preflight::Limits { + bytes: limits.bytes, + depth: limits.depth, + entity_bytes: limits.entity_bytes, + refuse_entity_markup: limits.refuse_entity_markup, + attributes: limits.attributes, + attribute_work: limits.attribute_work, + xml_nodes: limits.xml_nodes, + }; + let xml = crate::preflight::parse_text(svg, &preflight_limits).map_err(|e| match e { + crate::preflight::Error::Text(e) => Error::Text(e), + crate::preflight::Error::Entities => Error::Entities, + crate::preflight::Error::EntityMarkup => Error::EntityMarkup, + crate::preflight::Error::TooDeep(depth) => Error::TooDeep(depth), + crate::preflight::Error::TooWide(attributes) => Error::TooWide(attributes), + crate::preflight::Error::Xml(msg) => Error::Xml(msg), + })?; if height(&xml, limits.depth)?.is_none() { return Err(Error::TooDeep(limits.depth)); }