Skip to main content

merman_render/svg/pipeline/builtin/
css_sanitize.rs

1use crate::Result;
2use cssparser::{
3    AtRuleParser, BasicParseErrorKind, CowRcStr, ParseError, Parser, ParserState,
4    QualifiedRuleParser, SourcePosition, StyleSheetParser, ToCss, Token,
5};
6use std::borrow::Cow;
7use std::fmt;
8
9use super::attr_sanitize::is_unsafe_render_resource_url_value;
10use super::util::{checkpoint_loop, find_tag_end_with_checkpoints, find_with_checkpoints};
11use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
12
13const CSS_NESTING_HARD_LIMIT: u8 = 64;
14
15#[derive(Debug, Clone, Copy, Default)]
16pub struct SanitizeCssPostprocessor;
17
18impl SvgPostprocessor for SanitizeCssPostprocessor {
19    fn name(&self) -> &'static str {
20        "sanitize-css"
21    }
22
23    fn process<'a>(
24        &self,
25        svg: Cow<'a, str>,
26        ctx: &SvgPostprocessContext<'_>,
27    ) -> Result<Cow<'a, str>> {
28        apply_sanitize_style_elements(svg, || ctx.checkpoint())
29    }
30}
31
32pub(crate) fn apply_sanitize_style_elements<'a>(
33    svg: Cow<'a, str>,
34    mut checkpoint: impl FnMut() -> Result<()>,
35) -> Result<Cow<'a, str>> {
36    checkpoint()?;
37    if find_with_checkpoints(&svg, "<style", &mut checkpoint)?.is_none() {
38        return Ok(svg);
39    }
40    sanitize_style_elements_with_checkpoints(&svg, &mut checkpoint).map(Cow::Owned)
41}
42
43pub(crate) fn sanitize_style_elements_with_checkpoints<E>(
44    svg: &str,
45    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
46) -> std::result::Result<String, E> {
47    if find_with_checkpoints(svg, "<style", checkpoint)?.is_none() {
48        return Ok(svg.to_string());
49    }
50    let mut out = String::with_capacity(svg.len());
51    let mut cursor = 0;
52
53    while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], "<style", checkpoint)? {
54        let start = cursor + rel_start;
55        out.push_str(&svg[cursor..start]);
56
57        let Some(open_end) = find_tag_end_with_checkpoints(svg, start, checkpoint)? else {
58            out.push_str(&svg[start..]);
59            return Ok(out);
60        };
61
62        let content_start = open_end + 1;
63        let Some(rel_close_start) =
64            find_with_checkpoints(&svg[content_start..], "</style", checkpoint)?
65        else {
66            out.push_str(&svg[start..]);
67            return Ok(out);
68        };
69        let close_start = content_start + rel_close_start;
70        let Some(close_end) = find_tag_end_with_checkpoints(svg, close_start, checkpoint)? else {
71            out.push_str(&svg[start..]);
72            return Ok(out);
73        };
74
75        out.push_str(&svg[start..=open_end]);
76        out.push_str(&sanitize_css_with_checkpoints(
77            &svg[content_start..close_start],
78            checkpoint,
79        )?);
80        out.push_str(&svg[close_start..=close_end]);
81        cursor = close_end + 1;
82    }
83
84    out.push_str(&svg[cursor..]);
85    checkpoint()?;
86    Ok(out)
87}
88
89#[cfg(test)]
90pub(crate) fn sanitize_css(css: &str) -> String {
91    process_stylesheet(css, CssProcessingMode::Sanitize).unwrap_or_default()
92}
93
94pub(super) fn sanitize_css_with_checkpoints<E>(
95    css: &str,
96    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
97) -> std::result::Result<String, E> {
98    Ok(run_with_css_control(checkpoint, |control| {
99        process_stylesheet_with_control(css, CssProcessingMode::Sanitize, control)
100    })?
101    .unwrap_or_default())
102}
103
104#[cfg(test)]
105pub(super) fn sanitize_css_value(value: &str) -> Option<String> {
106    let mut parser = Parser::new(value);
107    let mut probe = || true;
108    let mut control = CssParseControl::new(&mut probe);
109    rewrite_component_values(
110        &mut parser,
111        CssProcessingMode::Sanitize,
112        CssNestingDepth::default(),
113        &mut control,
114    )
115    .ok()
116}
117
118pub(super) fn sanitize_css_value_with_checkpoints<E>(
119    value: &str,
120    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
121) -> std::result::Result<Option<String>, E> {
122    Ok(run_with_css_control(checkpoint, |control| {
123        let mut parser = Parser::new(value);
124        rewrite_component_values(
125            &mut parser,
126            CssProcessingMode::Sanitize,
127            CssNestingDepth::default(),
128            control,
129        )
130    })?
131    .ok())
132}
133
134#[cfg(test)]
135pub(in crate::svg::pipeline) fn validate_resvg_css_stylesheet(
136    css: &str,
137) -> std::result::Result<(), String> {
138    process_stylesheet(css, CssProcessingMode::Validate).map(|_| ())
139}
140
141pub(in crate::svg::pipeline) fn validate_resvg_css_stylesheet_with_checkpoints<E>(
142    css: &str,
143    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
144) -> std::result::Result<(), CssValidationError<E>> {
145    run_with_css_control(checkpoint, |control| {
146        process_stylesheet_with_control(css, CssProcessingMode::Validate, control).map(|_| ())
147    })
148    .map_err(CssValidationError::Checkpoint)?
149    .map_err(CssValidationError::Invalid)
150}
151
152#[cfg(test)]
153pub(in crate::svg::pipeline) fn validate_resvg_css_declaration_list(
154    css: &str,
155) -> std::result::Result<(), String> {
156    let mut parser = Parser::new(css);
157    let mut probe = || true;
158    let mut control = CssParseControl::new(&mut probe);
159    rewrite_declaration_list(
160        &mut parser,
161        CssProcessingMode::Validate,
162        CssNestingDepth::default(),
163        &mut control,
164    )
165    .map(|_| ())
166    .map_err(format_parse_error)
167}
168
169pub(in crate::svg::pipeline) fn validate_resvg_css_declaration_list_with_checkpoints<E>(
170    css: &str,
171    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
172) -> std::result::Result<(), CssValidationError<E>> {
173    run_with_css_control(checkpoint, |control| {
174        let mut parser = Parser::new(css);
175        rewrite_declaration_list(
176            &mut parser,
177            CssProcessingMode::Validate,
178            CssNestingDepth::default(),
179            control,
180        )
181        .map(|_| ())
182        .map_err(format_parse_error)
183    })
184    .map_err(CssValidationError::Checkpoint)?
185    .map_err(CssValidationError::Invalid)
186}
187
188pub(in crate::svg::pipeline) enum CssValidationError<E> {
189    Invalid(String),
190    Checkpoint(E),
191}
192
193fn run_with_css_control<T, I, E>(
194    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
195    run: impl FnOnce(&mut CssParseControl<'_>) -> std::result::Result<T, I>,
196) -> std::result::Result<std::result::Result<T, I>, E> {
197    checkpoint()?;
198    let mut checkpoint_error = None;
199    let result = {
200        let mut probe = || match checkpoint() {
201            Ok(()) => true,
202            Err(error) => {
203                checkpoint_error = Some(error);
204                false
205            }
206        };
207        let mut control = CssParseControl::new(&mut probe);
208        run(&mut control)
209    };
210    if let Some(error) = checkpoint_error {
211        return Err(error);
212    }
213    checkpoint()?;
214    Ok(result)
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum CssProcessingMode {
219    Sanitize,
220    Validate,
221}
222
223struct CssParseControl<'a> {
224    checkpoint: &'a mut dyn FnMut() -> bool,
225    iterations: usize,
226    cancelled: bool,
227}
228
229impl<'a> CssParseControl<'a> {
230    fn new(checkpoint: &'a mut dyn FnMut() -> bool) -> Self {
231        Self {
232            checkpoint,
233            iterations: 0,
234            cancelled: false,
235        }
236    }
237
238    fn step(&mut self) -> std::result::Result<(), ParseError<CssViolation>> {
239        self.observe().map_err(ParseError::custom)
240    }
241
242    fn observe(&mut self) -> std::result::Result<(), CssViolation> {
243        if self.cancelled {
244            return Err(CssViolation::Cancelled);
245        }
246
247        self.iterations = self.iterations.saturating_add(1);
248        let checkpoint_result = checkpoint_loop(self.iterations, &mut || {
249            if (self.checkpoint)() { Ok(()) } else { Err(()) }
250        });
251        if checkpoint_result.is_err() {
252            self.cancelled = true;
253            return Err(CssViolation::Cancelled);
254        }
255        Ok(())
256    }
257}
258
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260struct CssNestingDepth(u8);
261
262impl CssNestingDepth {
263    fn descend(self) -> std::result::Result<Self, ParseError<CssViolation>> {
264        if self.0 >= CSS_NESTING_HARD_LIMIT {
265            return Err(ParseError::custom(CssViolation::NestingLimit));
266        }
267        Ok(Self(self.0 + 1))
268    }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272enum CssViolation {
273    Animation,
274    BadToken,
275    Cancelled,
276    Degrees,
277    EmptyDeclaration,
278    ExternalImageFunction,
279    MarkerReference,
280    NestingLimit,
281    RootSelector,
282    UnclosedBlock,
283    UnsafeUrl,
284    UnsupportedAtRule(String),
285}
286
287impl fmt::Display for CssViolation {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        match self {
290            Self::Animation => f.write_str("CSS animation is not part of the resvg-safe contract"),
291            Self::BadToken => f.write_str("malformed CSS token"),
292            Self::Cancelled => f.write_str("CSS processing was cancelled"),
293            Self::Degrees => {
294                f.write_str("CSS angle units are not accepted by the resvg-safe contract")
295            }
296            Self::EmptyDeclaration => f.write_str("empty CSS declaration"),
297            Self::ExternalImageFunction => {
298                f.write_str("CSS image-set functions can reference external resources")
299            }
300            Self::MarkerReference => {
301                f.write_str("CSS marker references are not part of the resvg-safe contract")
302            }
303            Self::NestingLimit => write!(
304                f,
305                "CSS nesting exceeds the resvg-safe hard limit of {CSS_NESTING_HARD_LIMIT}"
306            ),
307            Self::RootSelector => {
308                f.write_str(":root rules are not part of the resvg-safe contract")
309            }
310            Self::UnclosedBlock => f.write_str("unclosed CSS block or function"),
311            Self::UnsafeUrl => f.write_str("unsafe CSS URL"),
312            Self::UnsupportedAtRule(name) => {
313                write!(f, "@{name} is not part of the resvg-safe contract")
314            }
315        }
316    }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum AtRuleBody {
321    Declarations,
322    RuleList,
323}
324
325#[derive(Debug)]
326enum AtRulePrelude {
327    Drop,
328    Keep {
329        name: String,
330        prelude: String,
331        body: AtRuleBody,
332    },
333}
334
335struct ResvgCssRuleParser<'control, 'checkpoint> {
336    mode: CssProcessingMode,
337    depth: CssNestingDepth,
338    control: &'control mut CssParseControl<'checkpoint>,
339}
340
341impl<'i> AtRuleParser<'i> for ResvgCssRuleParser<'_, '_> {
342    type Prelude = AtRulePrelude;
343    type AtRule = String;
344    type Error = CssViolation;
345
346    fn parse_prelude(
347        &mut self,
348        name: CowRcStr<'i>,
349        input: &mut Parser<'i>,
350    ) -> std::result::Result<Self::Prelude, ParseError<Self::Error>> {
351        let prelude = rewrite_component_values(input, self.mode, self.depth, self.control)?;
352        let normalized_name = name.to_ascii_lowercase();
353        let body = match normalized_name.as_str() {
354            "font-face" | "page" => Some(AtRuleBody::Declarations),
355            "container" | "document" | "layer" | "media" | "scope" | "supports" => {
356                Some(AtRuleBody::RuleList)
357            }
358            _ => None,
359        };
360
361        let explicitly_removed = matches!(
362            normalized_name.as_str(),
363            "import" | "keyframes" | "-webkit-keyframes"
364        );
365        if explicitly_removed || body.is_none() {
366            if self.mode == CssProcessingMode::Validate {
367                return Err(ParseError::custom(CssViolation::UnsupportedAtRule(
368                    normalized_name,
369                )));
370            }
371            return Ok(AtRulePrelude::Drop);
372        }
373
374        Ok(AtRulePrelude::Keep {
375            name: name.to_string(),
376            prelude,
377            body: body.expect("checked above"),
378        })
379    }
380
381    fn rule_without_block(
382        &mut self,
383        prelude: Self::Prelude,
384        _start: &ParserState,
385    ) -> std::result::Result<Self::AtRule, ()> {
386        match prelude {
387            AtRulePrelude::Drop => Ok(String::new()),
388            AtRulePrelude::Keep { .. } => Err(()),
389        }
390    }
391
392    fn parse_block(
393        &mut self,
394        prelude: Self::Prelude,
395        _start: &ParserState,
396        input: &mut Parser<'i>,
397    ) -> std::result::Result<Self::AtRule, ParseError<Self::Error>> {
398        let depth = self.depth.descend()?;
399        let AtRulePrelude::Keep {
400            name,
401            prelude,
402            body,
403        } = prelude
404        else {
405            consume_component_values(input, depth, self.control)?;
406            return Ok(String::new());
407        };
408
409        let body = match body {
410            AtRuleBody::Declarations => {
411                rewrite_declaration_list(input, self.mode, depth, self.control)?
412            }
413            AtRuleBody::RuleList => rewrite_rule_list(input, self.mode, depth, self.control)?,
414        };
415        Ok(format!("@{name}{prelude}{{{body}}}"))
416    }
417}
418
419impl<'i> QualifiedRuleParser<'i> for ResvgCssRuleParser<'_, '_> {
420    type Prelude = Option<String>;
421    type QualifiedRule = String;
422    type Error = CssViolation;
423
424    fn parse_prelude(
425        &mut self,
426        input: &mut Parser<'i>,
427    ) -> std::result::Result<Self::Prelude, ParseError<Self::Error>> {
428        let prelude = rewrite_component_values(input, self.mode, self.depth, self.control)?;
429        if selector_contains_root(&prelude, self.depth, self.control).map_err(ParseError::custom)? {
430            if self.mode == CssProcessingMode::Validate {
431                return Err(ParseError::custom(CssViolation::RootSelector));
432            }
433            return Ok(None);
434        }
435        Ok(Some(prelude))
436    }
437
438    fn parse_block(
439        &mut self,
440        prelude: Self::Prelude,
441        _start: &ParserState,
442        input: &mut Parser<'i>,
443    ) -> std::result::Result<Self::QualifiedRule, ParseError<Self::Error>> {
444        let depth = self.depth.descend()?;
445        let Some(prelude) = prelude else {
446            consume_component_values(input, depth, self.control)?;
447            return Ok(String::new());
448        };
449        let declarations = rewrite_declaration_list(input, self.mode, depth, self.control)?;
450        Ok(format!("{prelude}{{{declarations}}}"))
451    }
452}
453
454#[cfg(test)]
455fn process_stylesheet(css: &str, mode: CssProcessingMode) -> std::result::Result<String, String> {
456    let mut probe = || true;
457    let mut control = CssParseControl::new(&mut probe);
458    process_stylesheet_with_control(css, mode, &mut control)
459}
460
461fn process_stylesheet_with_control(
462    css: &str,
463    mode: CssProcessingMode,
464    control: &mut CssParseControl<'_>,
465) -> std::result::Result<String, String> {
466    let mut input = Parser::new(css);
467    rewrite_rule_list(&mut input, mode, CssNestingDepth::default(), control)
468        .map_err(format_parse_error)
469}
470
471fn rewrite_rule_list(
472    input: &mut Parser<'_>,
473    mode: CssProcessingMode,
474    depth: CssNestingDepth,
475    control: &mut CssParseControl<'_>,
476) -> std::result::Result<String, ParseError<CssViolation>> {
477    let mut parser = ResvgCssRuleParser {
478        mode,
479        depth,
480        control,
481    };
482    let mut output = String::new();
483
484    for rule in StyleSheetParser::new(input, &mut parser) {
485        match rule {
486            Ok(rule) => output.push_str(&rule),
487            Err((error, _, _)) if is_cancelled_parse_error(&error) => return Err(error),
488            Err((error, _, _)) if mode == CssProcessingMode::Validate => return Err(error),
489            Err(_) => {}
490        }
491    }
492
493    Ok(output)
494}
495
496fn rewrite_declaration_list(
497    input: &mut Parser<'_>,
498    mode: CssProcessingMode,
499    depth: CssNestingDepth,
500    control: &mut CssParseControl<'_>,
501) -> std::result::Result<String, ParseError<CssViolation>> {
502    let mut output = String::new();
503
504    loop {
505        control.step()?;
506        let declaration_start = input.position();
507        if input.is_exhausted() {
508            output.push_str(input.slice_from(declaration_start));
509            return Ok(output);
510        }
511
512        let declaration = input.parse_until_after(cssparser::Delimiter::Semicolon, |declaration| {
513            let prefix_start = declaration.position();
514            let property = declaration.expect_ident_cloned()?;
515            declaration.expect_colon()?;
516            let value_start = declaration.position();
517            let prefix = declaration.slice(prefix_start..value_start).to_string();
518
519            if is_animation_property(&property) {
520                consume_component_values(declaration, depth, control)?;
521                if mode == CssProcessingMode::Validate {
522                    return Err(ParseError::custom(CssViolation::Animation));
523                }
524                return Ok(None);
525            }
526
527            if is_marker_reference_property(&property) {
528                consume_component_values(declaration, depth, control)?;
529                if mode == CssProcessingMode::Validate {
530                    return Err(ParseError::custom(CssViolation::MarkerReference));
531                }
532                return Ok(None);
533            }
534
535            let value = rewrite_component_values(declaration, mode, depth, control)?;
536            if value.trim().is_empty() {
537                return Err(ParseError::custom(CssViolation::EmptyDeclaration));
538            }
539            Ok(Some((prefix, value)))
540        });
541        let declaration_end = input.position();
542        let had_semicolon = input
543            .slice(declaration_start..declaration_end)
544            .trim_end()
545            .ends_with(';');
546
547        match declaration {
548            Ok(Some((prefix, value))) => {
549                output.push_str(&prefix);
550                output.push_str(&value);
551                if had_semicolon {
552                    output.push(';');
553                }
554            }
555            Ok(None) => {}
556            Err(error) if is_cancelled_parse_error(&error) => return Err(error),
557            Err(error) if mode == CssProcessingMode::Validate => return Err(error),
558            Err(_) => {}
559        }
560    }
561}
562
563fn rewrite_component_values(
564    input: &mut Parser<'_>,
565    mode: CssProcessingMode,
566    depth: CssNestingDepth,
567    control: &mut CssParseControl<'_>,
568) -> std::result::Result<String, ParseError<CssViolation>> {
569    let mut output = String::new();
570
571    loop {
572        control.step()?;
573        let token_start = input.position();
574        let token = match input.next_including_whitespace() {
575            Ok(token) => token.clone(),
576            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => {
577                output.push_str(input.slice_from(token_start));
578                return Ok(output);
579            }
580            Err(error) => return Err(error.into()),
581        };
582        let token_end = input.position();
583
584        match token {
585            Token::Dimension {
586                value,
587                int_value,
588                has_sign,
589                unit,
590            } if unit.eq_ignore_ascii_case("deg") => {
591                if mode == CssProcessingMode::Validate {
592                    return Err(ParseError::custom(CssViolation::Degrees));
593                }
594                output.push_str(
595                    &Token::Number {
596                        value,
597                        int_value,
598                        has_sign,
599                    }
600                    .to_css_string(),
601                );
602            }
603            Token::UnquotedUrl(url) => {
604                if is_unsafe_render_resource_url_value(&url) {
605                    return Err(ParseError::custom(CssViolation::UnsafeUrl));
606                }
607                output.push_str(input.slice(token_start..token_end));
608            }
609            Token::Function(name) => {
610                if matches_external_image_function(&name) {
611                    return Err(ParseError::custom(CssViolation::ExternalImageFunction));
612                }
613                let nested_depth = depth.descend()?;
614                output.push_str(input.slice(token_start..token_end));
615                let nested = input.parse_nested_block(|nested| {
616                    if name.eq_ignore_ascii_case("url") {
617                        rewrite_quoted_url(nested, mode, nested_depth, control)
618                    } else {
619                        rewrite_component_values(nested, mode, nested_depth, control)
620                    }
621                })?;
622                ensure_source_closed_block(input, token_start, ')', control)?;
623                output.push_str(&nested);
624                output.push(')');
625            }
626            Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => {
627                let nested_depth = depth.descend()?;
628                output.push_str(input.slice(token_start..token_end));
629                let nested = input.parse_nested_block(|nested| {
630                    rewrite_component_values(nested, mode, nested_depth, control)
631                })?;
632                let close = match token {
633                    Token::ParenthesisBlock => ')',
634                    Token::SquareBracketBlock => ']',
635                    Token::CurlyBracketBlock => '}',
636                    _ => unreachable!(),
637                };
638                ensure_source_closed_block(input, token_start, close, control)?;
639                output.push_str(&nested);
640                output.push(close);
641            }
642            Token::BadUrl(_) | Token::BadString(_) => {
643                return Err(ParseError::custom(CssViolation::BadToken));
644            }
645            _ => output.push_str(input.slice(token_start..token_end)),
646        }
647    }
648}
649
650pub(in crate::svg::pipeline) fn matches_external_image_function(name: &str) -> bool {
651    name.eq_ignore_ascii_case("image-set") || name.eq_ignore_ascii_case("-webkit-image-set")
652}
653
654fn ensure_source_closed_block(
655    input: &Parser<'_>,
656    token_start: SourcePosition,
657    close: char,
658    control: &mut CssParseControl<'_>,
659) -> std::result::Result<(), ParseError<CssViolation>> {
660    let raw_block = input.slice(token_start..input.position());
661    if source_ends_with_close(raw_block, close, control).map_err(ParseError::custom)?
662        && source_closes_initial_block(raw_block, close, control).map_err(ParseError::custom)?
663    {
664        return Ok(());
665    }
666
667    Err(ParseError::custom(CssViolation::UnclosedBlock))
668}
669
670fn source_ends_with_close(
671    raw_block: &str,
672    close: char,
673    control: &mut CssParseControl<'_>,
674) -> std::result::Result<bool, CssViolation> {
675    for character in raw_block.chars().rev() {
676        control.observe()?;
677        if character.is_whitespace() {
678            continue;
679        }
680        return Ok(character == close);
681    }
682    Ok(false)
683}
684
685fn source_closes_initial_block(
686    raw_block: &str,
687    close: char,
688    control: &mut CssParseControl<'_>,
689) -> std::result::Result<bool, CssViolation> {
690    const SENTINEL: &str = "__merman_css_closed_block_sentinel__";
691
692    control.observe()?;
693    let mut probe = String::with_capacity(raw_block.len() + SENTINEL.len() + 1);
694    probe.push_str(raw_block);
695    probe.push(' ');
696    probe.push_str(SENTINEL);
697
698    let mut parser = Parser::new(&probe);
699    let Ok(token) = parser.next_including_whitespace().cloned() else {
700        return Ok(false);
701    };
702    if !opening_token_matches_close(&token, close) {
703        return Ok(false);
704    }
705
706    let nested_result = parser.parse_nested_block(|nested| {
707        loop {
708            control.step()?;
709            if nested.next_including_whitespace().is_err() {
710                break;
711            }
712        }
713        Ok::<_, ParseError<CssViolation>>(())
714    });
715    if let Err(error) = nested_result {
716        return if is_cancelled_parse_error(&error) {
717            Err(CssViolation::Cancelled)
718        } else {
719            Ok(false)
720        };
721    }
722
723    control.observe()?;
724    Ok(matches!(
725        parser.next(),
726        Ok(Token::Ident(name)) if name.as_ref() == SENTINEL
727    ))
728}
729
730fn opening_token_matches_close(token: &Token<'_>, close: char) -> bool {
731    match token {
732        Token::Function(_) | Token::ParenthesisBlock => close == ')',
733        Token::SquareBracketBlock => close == ']',
734        Token::CurlyBracketBlock => close == '}',
735        _ => false,
736    }
737}
738
739fn rewrite_quoted_url(
740    input: &mut Parser<'_>,
741    mode: CssProcessingMode,
742    depth: CssNestingDepth,
743    control: &mut CssParseControl<'_>,
744) -> std::result::Result<String, ParseError<CssViolation>> {
745    control.step()?;
746    let url_start = input.position();
747    let url = input.expect_string_cloned()?;
748    input.expect_exhausted()?;
749    if is_unsafe_render_resource_url_value(&url) {
750        return Err(ParseError::custom(CssViolation::UnsafeUrl));
751    }
752
753    let raw = input.slice_from(url_start);
754    let mut raw_parser = Parser::new(raw);
755    rewrite_component_values(&mut raw_parser, mode, depth, control)
756}
757
758fn consume_component_values(
759    input: &mut Parser<'_>,
760    depth: CssNestingDepth,
761    control: &mut CssParseControl<'_>,
762) -> std::result::Result<(), ParseError<CssViolation>> {
763    loop {
764        control.step()?;
765        let token = match input.next_including_whitespace() {
766            Ok(token) => token.clone(),
767            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => return Ok(()),
768            Err(error) => return Err(error.into()),
769        };
770        if matches!(
771            token,
772            Token::Function(_)
773                | Token::ParenthesisBlock
774                | Token::SquareBracketBlock
775                | Token::CurlyBracketBlock
776        ) {
777            let nested_depth = depth.descend()?;
778            input.parse_nested_block(|nested| {
779                consume_component_values(nested, nested_depth, control)
780            })?;
781        }
782    }
783}
784
785fn is_animation_property(property: &str) -> bool {
786    let property = property.to_ascii_lowercase();
787    property == "animation" || property.starts_with("animation-")
788}
789
790fn is_marker_reference_property(property: &str) -> bool {
791    matches!(
792        property.to_ascii_lowercase().as_str(),
793        "marker" | "marker-start" | "marker-mid" | "marker-end"
794    )
795}
796
797fn selector_contains_root(
798    selector: &str,
799    depth: CssNestingDepth,
800    control: &mut CssParseControl<'_>,
801) -> std::result::Result<bool, CssViolation> {
802    let mut parser = Parser::new(selector);
803    parser_contains_root_selector(&mut parser, depth, control).map_err(|error| match error.kind {
804        cssparser::ParseErrorKind::Custom(violation) => violation,
805        cssparser::ParseErrorKind::Basic(_) => CssViolation::BadToken,
806    })
807}
808
809fn parser_contains_root_selector(
810    input: &mut Parser<'_>,
811    depth: CssNestingDepth,
812    control: &mut CssParseControl<'_>,
813) -> std::result::Result<bool, ParseError<CssViolation>> {
814    let mut after_colon = false;
815    loop {
816        control.step()?;
817        let token = match input.next_including_whitespace() {
818            Ok(token) => token.clone(),
819            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => {
820                return Ok(false);
821            }
822            Err(error) => return Err(error.into()),
823        };
824        match token {
825            Token::Colon => after_colon = true,
826            Token::Ident(name) if after_colon && name.eq_ignore_ascii_case("root") => {
827                return Ok(true);
828            }
829            Token::Function(name) => {
830                if after_colon && name.eq_ignore_ascii_case("root") {
831                    return Ok(true);
832                }
833                let nested_depth = depth.descend()?;
834                if input.parse_nested_block(|nested| {
835                    parser_contains_root_selector(nested, nested_depth, control)
836                })? {
837                    return Ok(true);
838                }
839                after_colon = false;
840            }
841            Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => {
842                let nested_depth = depth.descend()?;
843                if input.parse_nested_block(|nested| {
844                    parser_contains_root_selector(nested, nested_depth, control)
845                })? {
846                    return Ok(true);
847                }
848                after_colon = false;
849            }
850            Token::WhiteSpace(_) => {}
851            _ => after_colon = false,
852        }
853    }
854}
855
856fn is_cancelled_parse_error(error: &ParseError<CssViolation>) -> bool {
857    matches!(
858        &error.kind,
859        cssparser::ParseErrorKind::Custom(CssViolation::Cancelled)
860    )
861}
862
863fn format_parse_error(error: ParseError<CssViolation>) -> String {
864    error.to_string()
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    fn nested_function(depth: usize, leaf: &str) -> String {
872        format!("{}{}{}", "f(".repeat(depth), leaf, ")".repeat(depth))
873    }
874
875    fn nested_media(depth: usize, rule: &str) -> String {
876        format!(
877            "{}{}{}",
878            "@media all{".repeat(depth),
879            rule,
880            "}".repeat(depth)
881        )
882    }
883
884    #[test]
885    fn css_sanitize_removes_active_rules_and_declarations_after_tokenization() {
886        let css = r#"@\69mport url(\"https://example.com/a.css\");@k\65yframes spin{to{opacity:1}}:r\6fot{--x:red}.a{a\6eimation:spin 1s;fill:red}"#;
887        let out = sanitize_css(css);
888
889        assert_eq!(out, ".a{fill:red}");
890    }
891
892    #[test]
893    fn css_sanitize_rewrites_only_dimension_tokens() {
894        let css = r##".a{transform:rotate(45deg) rotate(-10.5DEG);content:\"45deg\";background:url(#a45deg);--name:foo45deg;--near:90deg-foo}"##;
895        let out = sanitize_css(css);
896
897        assert!(out.contains("rotate(45) rotate(-10.5)"), "{out}");
898        assert!(out.contains(r#"content:\"45deg\""#), "{out}");
899        assert!(out.contains("url(#a45deg)"), "{out}");
900        assert!(out.contains("--name:foo45deg"), "{out}");
901        assert!(out.contains("--near:90deg-foo"), "{out}");
902    }
903
904    #[test]
905    fn css_sanitize_drops_unsafe_urls_and_preserves_safe_image_sources() {
906        let css = r##".bad{fill:u\72l(\"j\61vascript:alert(1)\");stroke:red}.fragment{fill:url(#paint)}.image{background:url(data:image/png;base64,AAAA)}"##;
907        let out = sanitize_css(css);
908
909        assert_eq!(
910            out,
911            r##".bad{stroke:red}.fragment{fill:url(#paint)}.image{background:url(data:image/png;base64,AAAA)}"##
912        );
913    }
914
915    #[test]
916    fn css_sanitize_drops_escaped_external_image_functions() {
917        let css = r#".bad{background-image:im\61ge-set(\"https://example.test/x.png\" 1x);stroke:red}.also-bad{content:-webkit-image-set(\"https://example.test/y.png\" 1x);fill:blue}"#;
918        let out = sanitize_css(css);
919
920        assert_eq!(out, ".bad{stroke:red}.also-bad{fill:blue}");
921        assert!(validate_resvg_css_stylesheet(css).is_err());
922    }
923
924    #[test]
925    fn css_sanitize_removes_marker_references_that_cannot_be_preflighted() {
926        let css = r##".edge{marker:url(#all);marker-start:url(#start);marker-mid:url(#mid);marker-end:url(#end);stroke:red}"##;
927
928        assert_eq!(sanitize_css(css), ".edge{stroke:red}");
929        assert!(validate_resvg_css_stylesheet(css).is_err());
930        assert!(validate_resvg_css_declaration_list("marker-end:url(#end);stroke:red").is_err());
931    }
932
933    #[test]
934    fn css_sanitize_drops_external_render_resources() {
935        let css = r##".local{background:url(../image.png);stroke:red}.root{cursor:url("/tmp/cursor.svg"),auto;fill:blue}.remote{filter:url(https://example.com/filter.svg#blur);color:black}.fragment{fill:url(#paint)}.embedded{background:url(data:image/png;base64,AAAA)}.missing-comma{background:url(data:image/png);opacity:.5}.spaced{background:url("d a t a:image/png;base64,BBBB");opacity:.75}"##;
936
937        let out = sanitize_css(css);
938
939        assert!(!out.contains("../image.png"), "{out}");
940        assert!(!out.contains("/tmp/cursor.svg"), "{out}");
941        assert!(!out.contains("https://example.com"), "{out}");
942        assert!(out.contains(".local{stroke:red}"), "{out}");
943        assert!(out.contains(".root{fill:blue}"), "{out}");
944        assert!(out.contains(".remote{color:black}"), "{out}");
945        assert!(out.contains(".fragment{fill:url(#paint)}"), "{out}");
946        assert!(
947            out.contains(".embedded{background:url(data:image/png;base64,AAAA)}"),
948            "{out}"
949        );
950        assert!(out.contains(".missing-comma{opacity:.5}"), "{out}");
951        assert!(out.contains(".spaced{opacity:.75}"), "{out}");
952        assert!(!out.contains("url(data:image/png)"), "{out}");
953        assert!(!out.contains("d a t a:"), "{out}");
954    }
955
956    #[test]
957    fn css_validation_rejects_non_terminal_constructs() {
958        assert!(validate_resvg_css_stylesheet("@import 'a.css';").is_err());
959        assert!(validate_resvg_css_stylesheet(".a{animation:spin 1s}").is_err());
960        assert!(validate_resvg_css_stylesheet(".a{transform:rotate(45deg)}").is_err());
961        assert!(validate_resvg_css_stylesheet(".a{fill:url(javascript:x)}").is_err());
962        assert!(validate_resvg_css_stylesheet(".a{content:\"45deg\"}").is_ok());
963    }
964
965    #[test]
966    fn deg_component_rewrite_does_not_touch_strings_or_urls() {
967        assert_eq!(
968            sanitize_css_value(r##"rotate(.5deg) \"45deg\" url(#a45deg) foo45deg 90deg-foo"##)
969                .as_deref(),
970            Some(r##"rotate(0.5) \"45deg\" url(#a45deg) foo45deg 90deg-foo"##)
971        );
972    }
973
974    #[test]
975    fn css_sanitize_rejects_unclosed_component_value_blocks() {
976        assert_eq!(sanitize_css_value("5rl('file:///{animatiEtroke:#333"), None);
977        assert_eq!(sanitize_css_value("rotate(45deg"), None);
978        assert_eq!(sanitize_css_value("outer((red)"), None);
979    }
980
981    #[test]
982    fn controlled_css_sanitize_stops_during_declaration_traversal() {
983        let css = format!(".a{{{}}}", "fill:red;".repeat(512));
984        let mut checkpoints = 0usize;
985        let result = sanitize_css_with_checkpoints(&css, &mut || {
986            checkpoints += 1;
987            if checkpoints == 3 {
988                Err("cancelled")
989            } else {
990                Ok(())
991            }
992        });
993
994        assert_eq!(result, Err("cancelled"));
995        assert_eq!(checkpoints, 3);
996    }
997
998    #[test]
999    fn css_nesting_limit_is_inclusive_and_validation_reports_the_limit() {
1000        let exact = nested_function(CSS_NESTING_HARD_LIMIT.into(), "red");
1001        let over = nested_function(usize::from(CSS_NESTING_HARD_LIMIT) + 1, "red");
1002
1003        assert_eq!(sanitize_css_value(&exact).as_deref(), Some(exact.as_str()));
1004        assert_eq!(sanitize_css_value(&over), None);
1005        assert!(validate_resvg_css_declaration_list(&format!("fill:{exact}")).is_ok());
1006
1007        let error = validate_resvg_css_declaration_list(&format!("fill:{over}"))
1008            .expect_err("one nesting level past the hard limit must be rejected");
1009        assert!(error.contains("CSS nesting exceeds"), "{error}");
1010        assert!(
1011            error.contains(&CSS_NESTING_HARD_LIMIT.to_string()),
1012            "{error}"
1013        );
1014    }
1015
1016    #[test]
1017    fn css_nesting_limit_bounds_rules_selectors_and_drop_traversal() {
1018        let exact_rule_value = nested_function(usize::from(CSS_NESTING_HARD_LIMIT) - 1, "red");
1019        let over_rule_value = nested_function(CSS_NESTING_HARD_LIMIT.into(), "red");
1020        let declarations =
1021            format!(".exact{{fill:{exact_rule_value}}}.over{{fill:{over_rule_value};stroke:blue}}");
1022        assert_eq!(
1023            sanitize_css(&declarations),
1024            format!(".exact{{fill:{exact_rule_value}}}.over{{stroke:blue}}")
1025        );
1026
1027        let selector_exact = format!(
1028            "{}.exact-selector{}{{fill:red}}",
1029            ":is(".repeat(CSS_NESTING_HARD_LIMIT.into()),
1030            ")".repeat(CSS_NESTING_HARD_LIMIT.into())
1031        );
1032        let selector_over = format!(
1033            "{}.over-selector{}{{fill:red}}",
1034            ":is(".repeat(usize::from(CSS_NESTING_HARD_LIMIT) + 1),
1035            ")".repeat(usize::from(CSS_NESTING_HARD_LIMIT) + 1)
1036        );
1037        assert!(validate_resvg_css_stylesheet(&selector_exact).is_ok());
1038        let selector_error = validate_resvg_css_stylesheet(&selector_over)
1039            .expect_err("an over-limit selector function must be rejected");
1040        assert!(selector_error.contains("CSS nesting exceeds"));
1041        assert!(sanitize_css(&selector_over).is_empty());
1042
1043        let media_exact = nested_media(CSS_NESTING_HARD_LIMIT.into(), "");
1044        let media_over = nested_media(usize::from(CSS_NESTING_HARD_LIMIT) + 1, "");
1045        assert!(validate_resvg_css_stylesheet(&media_exact).is_ok());
1046        let media_error = validate_resvg_css_stylesheet(&media_over)
1047            .expect_err("an over-limit grouping rule must be rejected");
1048        assert!(media_error.contains("CSS nesting exceeds"));
1049        assert_eq!(
1050            sanitize_css(&media_over).matches("@media").count(),
1051            usize::from(CSS_NESTING_HARD_LIMIT)
1052        );
1053
1054        let deeply_nested = nested_function(4_096, "spin");
1055        let dropped = format!(
1056            ".animation{{animation:{deeply_nested};stroke:blue}}:root{{--x:{deeply_nested}}}@unknown x{{value:{deeply_nested}}}.safe{{fill:red}}"
1057        );
1058        assert_eq!(
1059            sanitize_css(&dropped),
1060            ".animation{stroke:blue}.safe{fill:red}"
1061        );
1062    }
1063}