Skip to main content

merman_render/svg/pipeline/builtin/
attr_sanitize.rs

1use crate::Result;
2use std::borrow::Cow;
3
4use super::css_sanitize::strip_css_deg_units;
5use super::util::find_tag_end;
6use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
7
8#[derive(Debug, Clone, Copy, Default)]
9pub struct SanitizeSvgAttributesPostprocessor;
10
11impl SvgPostprocessor for SanitizeSvgAttributesPostprocessor {
12    fn name(&self) -> &'static str {
13        "sanitize-svg-attributes"
14    }
15
16    fn process<'a>(
17        &self,
18        svg: Cow<'a, str>,
19        _ctx: &SvgPostprocessContext<'_>,
20    ) -> Result<Cow<'a, str>> {
21        Ok(Cow::Owned(sanitize_element_attributes(&svg)))
22    }
23}
24
25pub(crate) fn sanitize_element_attributes(svg: &str) -> String {
26    let mut out = String::with_capacity(svg.len());
27    let mut cursor = 0;
28
29    while let Some(rel_start) = svg[cursor..].find('<') {
30        let start = cursor + rel_start;
31        out.push_str(&svg[cursor..start]);
32
33        let Some(end) = find_tag_end(svg, start) else {
34            out.push_str(&svg[start..]);
35            return out;
36        };
37
38        let tag = &svg[start..=end];
39        if is_bad_rect_tag(tag) {
40            cursor = if tag.trim_end().ends_with("/>") {
41                end + 1
42            } else {
43                svg[end + 1..]
44                    .find("</rect>")
45                    .map(|rel_close| end + 1 + rel_close + "</rect>".len())
46                    .unwrap_or(end + 1)
47            };
48            continue;
49        }
50        out.push_str(&sanitize_tag_attributes(tag));
51        cursor = end + 1;
52    }
53
54    out.push_str(&svg[cursor..]);
55    out
56}
57
58fn sanitize_tag_attributes(tag: &str) -> Cow<'_, str> {
59    if tag.starts_with("</")
60        || tag.starts_with("<!--")
61        || tag.starts_with("<!")
62        || tag.starts_with("<?")
63    {
64        return Cow::Borrowed(tag);
65    }
66
67    let mut changed = false;
68    let mut out = String::new();
69    let mut copied_until = 0usize;
70    let mut cursor = 0usize;
71
72    while let Some(attr) = next_svg_double_quoted_attr(tag, cursor) {
73        let name = &tag[attr.name_start..attr.name_end];
74        let value = &tag[attr.value_start..attr.value_end];
75
76        let replacement = sanitized_attr_replacement(name, value);
77        if let AttrReplacement::Unchanged = replacement {
78            cursor = attr.full_end;
79            continue;
80        }
81
82        if !changed {
83            out = String::with_capacity(tag.len());
84            changed = true;
85        }
86        out.push_str(&tag[copied_until..attr.full_start]);
87        match replacement {
88            AttrReplacement::Unchanged => {}
89            AttrReplacement::Drop => {}
90            AttrReplacement::Replace(replacement) => out.push_str(&replacement),
91        }
92        copied_until = attr.full_end;
93        cursor = attr.full_end;
94    }
95
96    if changed {
97        out.push_str(&tag[copied_until..]);
98        Cow::Owned(out)
99    } else {
100        Cow::Borrowed(tag)
101    }
102}
103
104enum AttrReplacement {
105    Unchanged,
106    Drop,
107    Replace(String),
108}
109
110fn sanitized_attr_replacement(name: &str, value: &str) -> AttrReplacement {
111    if should_drop_attribute(name, value) {
112        return AttrReplacement::Drop;
113    }
114
115    if let Some(value) = normalize_px_attribute(name, value) {
116        return AttrReplacement::Replace(format!(r#" {name}="{value}""#));
117    }
118
119    if name.eq_ignore_ascii_case("style") {
120        let sanitized = sanitize_style_attribute(value);
121        if sanitized.trim().is_empty() {
122            return AttrReplacement::Drop;
123        }
124        if sanitized != value {
125            return AttrReplacement::Replace(format!(r#" style="{sanitized}""#));
126        }
127    }
128
129    AttrReplacement::Unchanged
130}
131
132fn should_drop_attribute(name: &str, value: &str) -> bool {
133    if name.eq_ignore_ascii_case("style") {
134        return false;
135    }
136
137    let normalized = name.to_ascii_lowercase();
138    let guarded = matches!(
139        normalized.as_str(),
140        "fill"
141            | "stroke"
142            | "width"
143            | "height"
144            | "x"
145            | "y"
146            | "x1"
147            | "x2"
148            | "y1"
149            | "y2"
150            | "r"
151            | "cx"
152            | "cy"
153            | "rx"
154            | "ry"
155            | "stroke-width"
156            | "transform"
157            | "d"
158            | "points"
159    );
160
161    guarded && is_invalid_svg_value(value)
162}
163
164fn normalize_px_attribute(name: &str, value: &str) -> Option<String> {
165    let normalized = name.to_ascii_lowercase();
166    let guarded = matches!(
167        normalized.as_str(),
168        "width"
169            | "height"
170            | "x"
171            | "y"
172            | "x1"
173            | "x2"
174            | "y1"
175            | "y2"
176            | "r"
177            | "cx"
178            | "cy"
179            | "rx"
180            | "ry"
181            | "stroke-width"
182    );
183    if !guarded {
184        return None;
185    }
186
187    let trimmed = value.trim();
188    let number = trimmed.strip_suffix("px")?.trim();
189    if number.parse::<f64>().is_ok_and(f64::is_finite) {
190        Some(number.to_string())
191    } else {
192        None
193    }
194}
195
196fn is_start_or_empty_tag(tag: &str, expected: &str) -> bool {
197    let tag = tag.trim_start();
198    if !tag.starts_with('<') || tag.starts_with("</") || tag.starts_with("<!--") {
199        return false;
200    }
201
202    let name = tag[1..]
203        .chars()
204        .take_while(|ch| !ch.is_whitespace() && *ch != '/' && *ch != '>')
205        .collect::<String>();
206    name.eq_ignore_ascii_case(expected)
207}
208
209fn attr_value(tag: &str, name: &str) -> Option<String> {
210    let mut cursor = 0usize;
211    while let Some(attr) = next_svg_double_quoted_attr(tag, cursor) {
212        if tag[attr.name_start..attr.name_end].eq_ignore_ascii_case(name) {
213            return Some(tag[attr.value_start..attr.value_end].to_string());
214        }
215        cursor = attr.full_end;
216    }
217    None
218}
219
220#[derive(Debug, Clone, Copy)]
221struct SvgAttrMatch {
222    full_start: usize,
223    full_end: usize,
224    name_start: usize,
225    name_end: usize,
226    value_start: usize,
227    value_end: usize,
228}
229
230fn next_svg_double_quoted_attr(tag: &str, from: usize) -> Option<SvgAttrMatch> {
231    let mut cursor = from;
232    while cursor < tag.len() {
233        let ch = tag.get(cursor..)?.chars().next()?;
234        if ch.is_whitespace() {
235            let full_start = cursor;
236            let name_start = skip_svg_attr_regex_whitespace(tag, cursor);
237            if let Some(attr_match) = svg_double_quoted_attr_at(tag, full_start, name_start) {
238                return Some(attr_match);
239            }
240            cursor = name_start;
241        } else {
242            cursor += ch.len_utf8();
243        }
244    }
245    None
246}
247
248fn svg_double_quoted_attr_at(
249    tag: &str,
250    full_start: usize,
251    name_start: usize,
252) -> Option<SvgAttrMatch> {
253    let first = *tag.as_bytes().get(name_start)?;
254    if !is_svg_attr_name_start_byte(first) {
255        return None;
256    }
257
258    let name_end = consume_svg_attr_name(tag, name_start);
259    let mut cursor = skip_svg_attr_regex_whitespace(tag, name_end);
260    if !tag.get(cursor..)?.starts_with('=') {
261        return None;
262    }
263    cursor += 1;
264    cursor = skip_svg_attr_regex_whitespace(tag, cursor);
265    if !tag.get(cursor..)?.starts_with('"') {
266        return None;
267    }
268
269    let value_start = cursor + 1;
270    let value_end = value_start + tag.get(value_start..)?.find('"')?;
271    Some(SvgAttrMatch {
272        full_start,
273        full_end: value_end + 1,
274        name_start,
275        name_end,
276        value_start,
277        value_end,
278    })
279}
280
281fn skip_svg_attr_regex_whitespace(tag: &str, mut cursor: usize) -> usize {
282    while let Some(ch) = tag.get(cursor..).and_then(|tail| tail.chars().next()) {
283        if !ch.is_whitespace() {
284            break;
285        }
286        cursor += ch.len_utf8();
287    }
288    cursor
289}
290
291fn consume_svg_attr_name(tag: &str, mut cursor: usize) -> usize {
292    while let Some(b) = tag.as_bytes().get(cursor) {
293        if !is_svg_attr_name_continue_byte(*b) {
294            break;
295        }
296        cursor += 1;
297    }
298    cursor
299}
300
301fn is_svg_attr_name_start_byte(b: u8) -> bool {
302    b.is_ascii_alphabetic() || matches!(b, b'_' | b':')
303}
304
305fn is_svg_attr_name_continue_byte(b: u8) -> bool {
306    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.')
307}
308
309fn is_missing_or_invalid_rect_dimension(value: Option<&str>) -> bool {
310    let Some(value) = value.map(str::trim) else {
311        return true;
312    };
313    if value.is_empty() {
314        return true;
315    }
316    if let Ok(n) = value.parse::<f64>() {
317        return !n.is_finite() || n <= 0.0;
318    }
319    false
320}
321
322fn is_bad_rect_tag(tag: &str) -> bool {
323    if !is_start_or_empty_tag(tag, "rect") {
324        return false;
325    }
326
327    let width = attr_value(tag, "width");
328    let height = attr_value(tag, "height");
329    is_missing_or_invalid_rect_dimension(width.as_deref())
330        || is_missing_or_invalid_rect_dimension(height.as_deref())
331}
332
333fn sanitize_style_attribute(value: &str) -> String {
334    let mut out = Vec::new();
335
336    for decl in value.split(';') {
337        let trimmed = decl.trim();
338        if trimmed.is_empty() {
339            continue;
340        }
341
342        let Some((property, raw_value)) = trimmed.split_once(':') else {
343            if is_invalid_svg_value(trimmed) {
344                continue;
345            }
346            out.push(strip_css_deg_units(trimmed));
347            continue;
348        };
349
350        let property = property.trim();
351        let value = raw_value.trim();
352        if value.is_empty() || is_invalid_svg_value(value) {
353            continue;
354        }
355        if property
356            .trim()
357            .to_ascii_lowercase()
358            .starts_with("animation")
359        {
360            continue;
361        }
362
363        out.push(format!("{property}:{}", strip_css_deg_units(value)));
364    }
365
366    out.join(";")
367}
368
369fn is_invalid_svg_value(value: &str) -> bool {
370    let value = value.trim();
371    if value.is_empty() {
372        return true;
373    }
374
375    let lower = value.to_ascii_lowercase();
376    lower.contains("nan") || lower.contains("undefined") || lower.contains("infinity")
377}
378
379#[cfg(test)]
380mod tests {
381    use super::sanitize_element_attributes;
382
383    #[test]
384    fn sanitize_style_attribute_drops_invalid_bare_declarations() {
385        let svg = r#"<svg><path style="undefined; stroke: #333; undefined"/></svg>"#;
386        let out = sanitize_element_attributes(svg);
387
388        assert!(!out.contains("undefined"), "got: {out}");
389        assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
390    }
391
392    #[test]
393    fn sanitize_element_attributes_drops_rects_without_positive_dimensions() {
394        let svg = r#"<svg><rect/><rect width="0" height="10"/><rect width="12" height="8"/><g><rect width="NaN" height="10"><title>bad</title></rect></g></svg>"#;
395        let out = sanitize_element_attributes(svg);
396
397        assert!(!out.contains("<rect/>"), "got: {out}");
398        assert!(!out.contains(r#"width="0""#), "got: {out}");
399        assert!(!out.contains("NaN"), "got: {out}");
400        assert!(!out.contains("<title>bad</title>"), "got: {out}");
401        assert!(
402            out.contains(r#"<rect width="12" height="8"/>"#),
403            "got: {out}"
404        );
405    }
406
407    #[test]
408    fn sanitize_element_attributes_scans_double_quoted_attrs_without_regex() {
409        let svg = r#"<svg><path data-keep = "ok" x = "10px" stroke="" style="transform: rotate(45deg); animation: dash 1s; stroke: #333;"/></svg>"#;
410        let out = sanitize_element_attributes(svg);
411
412        assert!(out.contains(r#"data-keep = "ok""#), "got: {out}");
413        assert!(out.contains(r#" x="10""#), "got: {out}");
414        assert!(!out.contains(r#"stroke="""#), "got: {out}");
415        assert!(
416            out.contains(r#"style="transform:rotate(45);stroke:#333""#),
417            "got: {out}"
418        );
419        assert!(!out.contains("animation"), "got: {out}");
420    }
421
422    #[test]
423    fn sanitize_element_attributes_uses_scanned_attrs_for_bad_rect_detection() {
424        let svg = r#"<svg><rect WIDTH = "12" HEIGHT = "8"/><rect width = "NaN" height = "8"><title>bad</title></rect></svg>"#;
425        let out = sanitize_element_attributes(svg);
426
427        assert!(
428            out.contains(r#"<rect WIDTH = "12" HEIGHT = "8"/>"#),
429            "got: {out}"
430        );
431        assert!(!out.contains("NaN"), "got: {out}");
432        assert!(!out.contains("<title>bad</title>"), "got: {out}");
433    }
434}