mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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 <g inkscape:groupmode="layer"> 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.
This commit is contained in:
1 parent
78800c2349
commit
43f65a9210
9 files changed
+1320
-9
No files matched your search
Generated
+74
-3
@@ -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"
|
||||
|
||||
@@ -236,7 +236,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
element nesting and entity expansion before any XML reader sees the text; `paths` reads geometry
|
||||
(shapes, transforms, `<use>`/`<symbol>` 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
|
||||
|
||||
@@ -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"]
|
||||
+115
-1
@@ -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"<!--") {
|
||||
i = find(i + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
i = find(i + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<!DOCTYPE") {
|
||||
// To the `]>` 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"<?") || rest.starts_with(b"<!") || rest.starts_with(b"</") {
|
||||
i = find(i + 2, 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("<!DOCTYPE svg [<!ENTITY a \"<g><rect/></g>\">]><svg>&a;</svg>"));
|
||||
assert!(entity_holds_markup("<!DOCTYPE svg [<!ENTITY ns 'http://x'><!ENTITY b '<g/>'>]><svg/>"));
|
||||
assert!(!entity_holds_markup("<!DOCTYPE svg [<!ENTITY ns_svg \"http://www.w3.org/2000/svg\">]><svg xmlns=\"&ns_svg;\"/>"));
|
||||
assert!(!entity_holds_markup("<!DOCTYPE svg [<!ENTITY lt \"<\">]><svg/>"));
|
||||
assert!(!entity_holds_markup("<svg><g/></svg>"));
|
||||
}
|
||||
|
||||
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::<String>();
|
||||
assert!(!wide(&format!("<svg{}/>", many(4096))));
|
||||
assert!(wide(&format!("<svg><g{}/></svg>", many(4097))));
|
||||
assert!(!wide(&format!("<svg>{}</svg>", "<g a='1' b='2'/>".repeat(10_000))), "per element, not per file");
|
||||
assert!(too_wide("<svg a='1' b='2' c='3'/>", 2, usize::MAX) && !too_wide("<svg a='1' b='2' c='3'/>", 3, usize::MAX));
|
||||
assert!(!wide("<svg><!-- ' \" ' \" --><![CDATA[ '' \"\" ]]></svg>"), "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::<String>();
|
||||
let tag = format!("<g{}/>", many(4000));
|
||||
assert!(!wide(&format!("<svg>{}</svg>", tag.repeat(10))));
|
||||
assert!(wide(&format!("<svg>{}</svg>", tag.repeat(40))), "each element is within the limit, all together are not");
|
||||
let dtd = format!("<!DOCTYPE svg [<!ENTITY e '<g{}/>'>]><svg>&e;</svg>", many(5000));
|
||||
assert!(wide(&dtd), "entities can hold elements");
|
||||
assert!(!wide("<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY ns 'http://www.w3.org/2000/svg'>]><svg/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_in_comments_and_long_names_cannot_hide_a_large_entity() {
|
||||
let big = "x".repeat(1 << 20);
|
||||
|
||||
@@ -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;
|
||||
+50
-4
@@ -72,8 +72,16 @@ pub struct Shape<K> {
|
||||
pub stroke: Option<String>,
|
||||
/// The regions the shape is clipped by, as indices into [`Imported::clips`], outermost first.
|
||||
pub clips: Vec<usize>,
|
||||
/// The name of the innermost Inkscape layer the element is in: a `<g>` 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<String>,
|
||||
}
|
||||
|
||||
/// 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<K> {
|
||||
@@ -102,6 +110,8 @@ struct ImportState<K> {
|
||||
inherited_style: std::collections::HashMap<String, String>,
|
||||
/// Active `clip-path` regions (indices into the context's list), outermost first.
|
||||
clips: Vec<usize>,
|
||||
/// The innermost Inkscape layer's name ([`Shape::layer`]).
|
||||
layer: Option<std::rc::Rc<str>>,
|
||||
}
|
||||
|
||||
impl<K: Kind> Default for ImportState<K> {
|
||||
@@ -113,6 +123,7 @@ impl<K: Kind> Default for ImportState<K> {
|
||||
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::<String>().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<String, (CssPriority, String)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CssRule {
|
||||
pub(crate) struct CssRule {
|
||||
selectors: Vec<SimpleSelector>,
|
||||
declarations: StyleMap,
|
||||
important: std::collections::HashSet<String>,
|
||||
@@ -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<String>) -> Result<Vec<CssRule>, Error> {
|
||||
pub(crate) fn parse_stylesheets(
|
||||
root: roxmltree::Node<'_, '_>,
|
||||
extra: &[&str],
|
||||
path_name: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<Vec<CssRule>, 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#"<svg><style>a ~ path{data-kind:engrave}</style><a/><path d="M0 0L1 1"/></svg>"#);
|
||||
assert_eq!(got.shapes[0].kind, Tag::Engrave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inkscape_layers_name_the_shapes_inside_them() {
|
||||
let read = read(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
|
||||
<path d="M0 0L1 1"/>
|
||||
<g inkscape:groupmode="layer" inkscape:label="Cut & score" id="layer1">
|
||||
<path d="M0 0L1 1"/>
|
||||
<g><path d="M0 0L1 1"/></g>
|
||||
<g inkscape:groupmode="layer" id="layer2"><path d="M0 0L1 1"/></g>
|
||||
<g inkscape:groupmode="layer"><path d="M0 0L1 1"/></g>
|
||||
</g>
|
||||
<g inkscape:label="not a layer"><path d="M0 0L1 1"/></g>
|
||||
<g groupmode="layer" label="no namespace"><path d="M0 0L1 1"/></g>
|
||||
</svg>"#,
|
||||
);
|
||||
let layers: Vec<Option<&str>> = 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#"<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"><g inkscape:groupmode="layer" inkscape:label="{}"><path d="M0 0L1 1"/></g></svg>"#,
|
||||
"é".repeat(MAX_LAYER_NAME + 50)
|
||||
);
|
||||
assert_eq!(import::<Tag>(&long, "test.svg").unwrap().shapes[0].layer.as_ref().map(|n| n.chars().count()), Some(MAX_LAYER_NAME));
|
||||
}
|
||||
}
|
||||
@@ -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 `<image>` 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 `<image>` 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<Cow<'a, str>, 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<usvg::Tree, Error> {
|
||||
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<usvg::Tree, Error> {
|
||||
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<roxmltree::Document<'s>, 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<Item = &str> {
|
||||
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 `<use>` 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<Option<usize>, 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<roxmltree::NodeId, usize> = 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<roxmltree::NodeId> = 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<usvg::ImageKind> {
|
||||
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 `<image>` 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;
|
||||
@@ -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 `<use>` 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<Key, Profile>,
|
||||
clips: HashMap<usize, usize>,
|
||||
masks: HashMap<usize, usize>,
|
||||
}
|
||||
|
||||
/// 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<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<Profile, Refusal>) -> Result<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<usize, Refusal> {
|
||||
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<usize, Refusal> {
|
||||
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<usize, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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<Profile, Refusal> {
|
||||
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(())
|
||||
}
|
||||
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10" height="10">{body}</svg>"#)
|
||||
}
|
||||
|
||||
/// `n` groups in one another around a rectangle.
|
||||
fn groups(n: usize) -> String {
|
||||
format!("{}<rect width='1' height='1'/>{}", "<g>".repeat(n), "</g>".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<usvg::Tree, Error> {
|
||||
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<bool>) {
|
||||
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),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<image>` 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#"<image width="5" height="5" xlink:href="data:image/svg+xml,{encoded}"/>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_and_an_svgz_parse_to_the_same_tree() {
|
||||
let text = svg("<g><rect width='4' height='2' fill='red'/></g>");
|
||||
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 <use> holds the next.
|
||||
let chain: String = (0..20).map(|i| format!("<g id='g{i}'><use xlink:href='#g{}'/></g>", i + 1)).collect();
|
||||
let uses = svg(&format!("<defs>{chain}<rect id='g20' width='1' height='1'/></defs><use xlink:href='#g0'/>"));
|
||||
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!("<g{}/>", (0..40).map(|i| format!(" a{i}='1'")).collect::<String>()));
|
||||
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!("<!DOCTYPE svg [<!ENTITY a \"{}\">]>{}", "x".repeat(1000), svg(&format!("<title>{}</title>", "&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(&"<g/>".repeat(100)), &Limits { xml_nodes: 50, ..shallow() }), Err(Error::Xml(_))));
|
||||
// Not XML, and XML that is not an SVG.
|
||||
assert!(matches!(try_parse("<svg", &shallow()), Err(Error::Xml(_))));
|
||||
assert!(matches!(try_parse("<html/>", &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!("<!DOCTYPE svg [<!ENTITY a \"<rect width='1' height='1'/>\">]>{}", svg("<g>&a;</g>"));
|
||||
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 = "<!DOCTYPE svg [<!ENTITY ns \"http://www.w3.org/2000/svg\">]><svg xmlns=\"&ns;\" width=\"1\" height=\"1\"/>";
|
||||
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("<svg/>").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!("<g id='g{i}'><use xlink:href='#g{}'/></g>", i + 1)).collect();
|
||||
svg(&format!("<defs>{defs}<rect id='g{n}' width='1' height='1'/></defs><use href='#g0'/>"))
|
||||
};
|
||||
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!("<clipPath id='c{i}' clip-path='url(#c{})'><rect width='1' height='1'/></clipPath>", i + 1)).collect()
|
||||
};
|
||||
let by_style = |n: usize| -> String {
|
||||
(0..n).map(|i| format!("<mask id='c{i}' style='mask: url( \"#c{}\" )'><rect width='1' height='1'/></mask>", 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!("<pattern id='c{i}' width='1' height='1'><g class='p{i}'><rect width='1' height='1'/></g></pattern>")).collect();
|
||||
format!("<style>{rules}</style>{patterns}")
|
||||
};
|
||||
let by_marker =
|
||||
|n: usize| -> String { (0..n).map(|i| format!("<marker id='c{i}'><path d='M0 0L1 1' marker-end='url(#c{})'/></marker>", i + 1)).collect() };
|
||||
for chain in [&by_attribute as &dyn Fn(usize) -> String, &by_style, &by_sheet, &by_marker] {
|
||||
let file = |n| {
|
||||
svg(&format!(
|
||||
"<defs>{}</defs><rect width='5' height='5' fill='url(#c0)' clip-path='url(#c0)' mask='url(#c0)' marker-start='url(#c0)'/>",
|
||||
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!("<linearGradient id='g{i}'><stop class='c{i}' offset='0'/></linearGradient>")).collect();
|
||||
let art: String = (0..300).map(|i| format!("<g class='c{i}'><rect class='c{i}' width='1' height='1'/></g>")).collect();
|
||||
assert!(!deep(&svg(&format!("<style>{rules}</style><defs>{defs}</defs>{art}"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_follows_the_first_element_of_its_id() {
|
||||
// A chain of `<use>`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!("<g id='a{i}'><use href='#a{}'/></g>", i - 1)).collect();
|
||||
let shallow: String = (1..DEPTH).map(|i| format!("<g id='a{i}'/>")).collect();
|
||||
let top = format!("<use href='#a{}'/>", DEPTH - 1);
|
||||
assert!(deep(&svg(&format!("<defs><g id='a0'><rect width='1' height='1'/></g>{chain}{shallow}</defs>{top}"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_references_is_not_deep() {
|
||||
assert!(!deep(&svg("<g id='a'><use href='#b'/></g><g id='b'><use href='#a'/></g><clipPath id='c' clip-path='url(#c)'/>")));
|
||||
}
|
||||
|
||||
#[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!("<pattern id='p' width='1' height='1'>{}</pattern>", groups(half));
|
||||
assert!(!deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half / 2)))));
|
||||
assert!(deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_sheet_that_names_elements_and_cannot_be_read_is_refused() {
|
||||
let sheet = |css: &str, media: &str| {
|
||||
svg(&format!("<style{media}>{css}</style><pattern id='p' width='1' height='1'/><rect class='a' width='1' height='1'/>"))
|
||||
};
|
||||
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("<rect width='1' height='1'/>");
|
||||
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!("<g{}/>", (0..40).map(|i| format!(" a{i}='1'")).collect::<String>()))));
|
||||
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#"<image width="5" height="5" xlink:href="/etc/passwd.svg"/>"#), 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 = "<defs><clipPath id='c'><rect width='5' height='5'/></clipPath>
|
||||
<filter id='f'><feGaussianBlur stdDeviation='1'/></filter>
|
||||
<pattern id='p' width='2' height='2' patternUnits='userSpaceOnUse'><rect width='1' height='1'/></pattern></defs>
|
||||
<g opacity='0.5' clip-path='url(#c)' filter='url(#f)'><rect width='8' height='8' fill='url(#p)'/></g>";
|
||||
assert_eq!(check(body, GIB), Ok(()));
|
||||
// The target alone is 100 × 100 × 4 bytes.
|
||||
assert!(check("<rect width='1' height='1'/>", 40_000).is_ok());
|
||||
assert!(check("<rect width='1' height='1'/>", 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 = "<filter id='f' filterUnits='userSpaceOnUse' x='0' y='0' width='40000' height='40000'><feGaussianBlur stdDeviation='1'/></filter>
|
||||
<rect width='8' height='8' filter='url(#f)'/>";
|
||||
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 = "<pattern id='p' width='40000' height='40000' patternUnits='userSpaceOnUse'><rect width='1' height='1'/></pattern>
|
||||
<rect width='8' height='8' fill='url(#p)'/>";
|
||||
let refusal = check(body, GIB).unwrap_err();
|
||||
assert!(refusal.memory && refusal.message.contains("pattern tile"), "{refusal:?}");
|
||||
let small = "<pattern id='p' width='40' height='40' patternUnits='userSpaceOnUse'><rect width='1' height='1'/></pattern>
|
||||
<rect width='8' height='8' fill='url(#p)'/>";
|
||||
assert_eq!(check(small, GIB), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_are_capped_apart_from_memory() {
|
||||
let primitives =
|
||||
|n: usize| format!("<filter id='f'>{}</filter><rect width='8' height='8' filter='url(#f)'/>", "<feOffset dx='1'/>".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!("<clipPath id='c{i}' clip-path='url(#c{})'><rect width='5' height='5'/></clipPath>", i + 1)).collect();
|
||||
format!("{chain}<clipPath id='c{n}'><rect width='5' height='5'/></clipPath><rect width='8' height='8' clip-path='url(#c0)'/>")
|
||||
};
|
||||
assert_eq!(check(&clips(15), GIB), Ok(()));
|
||||
let refusal = check(&clips(16), GIB).unwrap_err();
|
||||
assert!(!refusal.memory && refusal.message.contains("clip-paths"), "{refusal:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user