Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
StyleRuleSanitizer
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
3 / 3
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
5
 handlesRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doSanitize
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
12
1<?php
2/**
3 * @file
4 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
5 */
6
7namespace Wikimedia\CSS\Sanitizer;
8
9use InvalidArgumentException;
10use Wikimedia\CSS\Grammar\Juxtaposition;
11use Wikimedia\CSS\Grammar\Matcher;
12use Wikimedia\CSS\Grammar\MatcherFactory;
13use Wikimedia\CSS\Grammar\Quantifier;
14use Wikimedia\CSS\Objects\ComponentValue;
15use Wikimedia\CSS\Objects\ComponentValueList;
16use Wikimedia\CSS\Objects\CSSObject;
17use Wikimedia\CSS\Objects\QualifiedRule;
18use Wikimedia\CSS\Objects\Rule;
19use Wikimedia\CSS\Objects\Token;
20use Wikimedia\CSS\Util;
21
22/**
23 * Sanitizes a CSS style rule
24 * @see https://www.w3.org/TR/2019/CR-css-syntax-3-20190716/#style-rules
25 */
26class StyleRuleSanitizer extends RuleSanitizer {
27
28    /** @var Matcher */
29    protected $selectorMatcher;
30
31    /** @var ComponentValue[] */
32    protected $prependSelectors;
33
34    /** @var Matcher|null */
35    protected $hoistableMatcher;
36
37    /** @var PropertySanitizer */
38    protected $propertySanitizer;
39
40    /**
41     * @param Matcher $selectorMatcher Matcher for valid selectors.
42     *  Probably from MatcherFactory::cssSelectorList().
43     * @param PropertySanitizer $propertySanitizer Sanitizer to test property declarations.
44     *  Probably an instance of StylePropertySanitizer.
45     * @param array $options Additional options
46     *  - prependSelectors: (ComponentValue[]) Prepend this (and a whitespace) to all selectors.
47     *    Note: $selectorMatcher must capture each selector with the name 'selector'.
48     *  - hoistableComponentMatcher: (Matcher) Component groups (simple selector sequences,
49     *    in CSS3 Selectors terminology) matched by this will be hoisted before the prepended
50     *    selector sequence. (To be more precise: the hoisted part is the longest prefix of
51     *    the selector that only contains matching simple selector sequences and descendant
52     *    combinators, and is not followed by a non-descendant combinator.)
53     *    This can be used to allow filtering by top-level conditional classes/IDs emitted by
54     *    some framework (e.g. html.no-js) while still jailing selectors into some subsection
55     *    of the content. For example, if prependSelectors is equivalent to '#content' and
56     *    hoistableComponentMatcher to [html|body]<simple selector>*  will turn
57     *    'html.no-js body.ltr div.list' into 'html.no-js body.ltr #content div.list'.
58     *    Note: $selectorMatcher must capture each simple selector group with the name 'simple'
59     *    and the combinators with 'combinator'.
60     */
61    public function __construct(
62        Matcher $selectorMatcher, PropertySanitizer $propertySanitizer, array $options = []
63    ) {
64        $options += [
65            'prependSelectors' => [],
66            'hoistableComponentMatcher' => null,
67        ];
68        Util::assertAllInstanceOf(
69            $options['prependSelectors'], ComponentValue::class, 'prependSelectors'
70        );
71        if ( $options['hoistableComponentMatcher'] !== null &&
72             !$options['hoistableComponentMatcher'] instanceof Matcher
73        ) {
74            throw new InvalidArgumentException( 'hoistableComponentMatcher must be a Matcher' );
75        }
76
77        $matcherFactory = MatcherFactory::singleton();
78
79        // Add optional whitespace around the selector-matcher, because
80        // selector-matchers don't usually have it.
81        if ( !$selectorMatcher->getDefaultOptions()['skip-whitespace'] ) {
82            $ows = $matcherFactory->optionalWhitespace();
83            $this->selectorMatcher = new Juxtaposition( [
84                $ows,
85                $selectorMatcher,
86                $ows->capture( 'trailingWS' ),
87            ] );
88            $this->selectorMatcher->setDefaultOptions( $selectorMatcher->getDefaultOptions() );
89        } else {
90            $this->selectorMatcher = $selectorMatcher;
91        }
92
93        $this->propertySanitizer = $propertySanitizer;
94        $this->prependSelectors = $options['prependSelectors'];
95        if ( $options['hoistableComponentMatcher'] ) {
96            $hoistablePrefixMatcher = new Juxtaposition( [
97                $options['hoistableComponentMatcher'],
98                Quantifier::star( new Juxtaposition( [
99                    $matcherFactory->significantWhitespace(),
100                    $options['hoistableComponentMatcher'],
101                ] ) )
102            ] );
103            $this->hoistableMatcher = new Juxtaposition( [
104                $hoistablePrefixMatcher->capture( 'prefix' ),
105                $matcherFactory->significantWhitespace()->capture( 'ws' ),
106                $matcherFactory->cssSelector()->capture( 'postfix' ),
107            ] );
108            $this->hoistableMatcher->setDefaultOptions( [ 'skip-whitespace' => false ] );
109        }
110    }
111
112    /** @inheritDoc */
113    public function handlesRule( Rule $rule ) {
114        return $rule instanceof QualifiedRule;
115    }
116
117    /** @inheritDoc */
118    protected function doSanitize( CSSObject $object ) {
119        if ( !$object instanceof QualifiedRule ) {
120            $this->sanitizationError( 'expected-qualified-rule', $object );
121            return null;
122        }
123
124        // Test that the prelude is a valid selector list
125        $match = $this->selectorMatcher->matchAgainst( $object->getPrelude(), [ 'mark-significance' => true ] );
126        if ( !$match ) {
127            $cv = Util::findFirstNonWhitespace( $object->getPrelude() );
128            if ( $cv ) {
129                $this->sanitizationError( 'invalid-selector-list', $cv );
130            } else {
131                $this->sanitizationError( 'missing-selector-list', $object );
132            }
133            return null;
134        }
135
136        $ret = clone $object;
137
138        if ( $this->prependSelectors ) {
139            $prelude = $ret->getPrelude();
140            $comma = [
141                new Token( Token::T_COMMA ),
142                new Token( Token::T_WHITESPACE, [ 'significant' => false ] )
143            ];
144            $space = [
145                new Token( Token::T_WHITESPACE, [ 'significant' => true ] )
146            ];
147            $prelude->clear();
148            foreach ( $match->getCapturedMatches() as $selectorOrWs ) {
149                if ( $selectorOrWs->getName() === 'selector' ) {
150                    if ( $prelude->count() ) {
151                        $prelude->add( $comma );
152                    }
153
154                    $valueList = new ComponentValueList( $selectorOrWs->getValues() );
155                    $hoistMatch = $this->hoistableMatcher ? $this->hoistableMatcher->matchAgainst( $valueList ) : null;
156                    if ( $hoistMatch ) {
157                        [ $prefix, , $postfix ] = $hoistMatch->getCapturedMatches();
158                        $prelude->add( $prefix->getValues() );
159                        $prelude->add( $space );
160                        $prelude->add( $this->prependSelectors );
161                        $prelude->add( $space );
162                        $prelude->add( $postfix->getValues() );
163                    } else {
164                        $prelude->add( $this->prependSelectors );
165                        $prelude->add( $space );
166                        $prelude->add( $valueList );
167                    }
168                } elseif ( $selectorOrWs->getName() === 'trailingWS' && $selectorOrWs->getLength() > 0 ) {
169                    $prelude->add( $selectorOrWs->getValues() );
170                }
171            }
172        }
173
174        $this->sanitizeDeclarationBlock( $ret->getBlock(), $this->propertySanitizer );
175
176        return $ret;
177    }
178
179}