• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MyIntervals / PHP-CSS-Parser / 20213423322

14 Dec 2025 08:05PM UTC coverage: 69.027% (-0.06%) from 69.082%
20213423322

push

github

web-flow
[TASK] Guard against infinite loop in `parseList` (#1427)

This can't be tested because there are currently no cases that would fail the
test without the change.

It is there as a preventative double-lock measure to make sure any future code
changes do not introduce an infinite loop.

It's a slight unoptimization,
as it adds what should be a redundant runtime check,
but at miniscule performance cost versus the value of preventing a waste of CPU
power and memory in the case that something goes wrong.

2 of 3 new or added lines in 1 file covered. (66.67%)

1 existing line in 1 file now uncovered.

1326 of 1921 relevant lines covered (69.03%)

31.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.45
/src/CSSList/CSSList.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\CSSList;
6

7
use Sabberworm\CSS\Comment\CommentContainer;
8
use Sabberworm\CSS\CSSElement;
9
use Sabberworm\CSS\OutputFormat;
10
use Sabberworm\CSS\Parsing\ParserState;
11
use Sabberworm\CSS\Parsing\SourceException;
12
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
13
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
14
use Sabberworm\CSS\Position\Position;
15
use Sabberworm\CSS\Position\Positionable;
16
use Sabberworm\CSS\Property\AtRule;
17
use Sabberworm\CSS\Property\Charset;
18
use Sabberworm\CSS\Property\CSSNamespace;
19
use Sabberworm\CSS\Property\Import;
20
use Sabberworm\CSS\Property\Selector;
21
use Sabberworm\CSS\RuleSet\AtRuleSet;
22
use Sabberworm\CSS\RuleSet\DeclarationBlock;
23
use Sabberworm\CSS\RuleSet\RuleSet;
24
use Sabberworm\CSS\Value\CSSString;
25
use Sabberworm\CSS\Value\URL;
26
use Sabberworm\CSS\Value\Value;
27

28
use function Safe\preg_match;
29

30
/**
31
 * This is the most generic container available. It can contain `DeclarationBlock`s (rule sets with a selector),
32
 * `RuleSet`s as well as other `CSSList` objects.
33
 *
34
 * It can also contain `Import` and `Charset` objects stemming from at-rules.
35
 *
36
 * Note that `CSSListItem` extends both `Commentable` and `Renderable`,
37
 * so those interfaces must also be implemented by concrete subclasses.
38
 */
39
abstract class CSSList implements CSSElement, CSSListItem, Positionable
40
{
41
    use CommentContainer;
42
    use Position;
43

44
    /**
45
     * @var array<int<0, max>, CSSListItem>
46
     *
47
     * @internal since 8.8.0
48
     */
49
    protected $contents = [];
50

51
    /**
52
     * @param int<1, max>|null $lineNumber
53
     */
54
    public function __construct(?int $lineNumber = null)
87✔
55
    {
56
        $this->setPosition($lineNumber);
87✔
57
    }
87✔
58

59
    /**
60
     * @throws UnexpectedTokenException
61
     * @throws SourceException
62
     *
63
     * @internal since V8.8.0
64
     */
65
    public static function parseList(ParserState $parserState, CSSList $list): void
12✔
66
    {
67
        $isRoot = $list instanceof Document;
12✔
68
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
12✔
69
        $comments = [];
12✔
70
        while (!$parserState->isEnd()) {
12✔
71
            $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
12✔
72
            $listItem = null;
12✔
73
            if ($usesLenientParsing) {
12✔
74
                try {
75
                    $positionBeforeParse = $parserState->currentColumn();
6✔
76
                    $listItem = self::parseListItem($parserState, $list);
6✔
77
                } catch (UnexpectedTokenException $e) {
×
UNCOV
78
                    $listItem = false;
×
79
                    // If the failed parsing did not consume anything that was to come ...
NEW
80
                    if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) {
×
81
                        // ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop.
82
                        $parserState->consume(1);
6✔
83
                    }
84
                }
85
            } else {
86
                $listItem = self::parseListItem($parserState, $list);
6✔
87
            }
88
            if ($listItem === null) {
12✔
89
                // List parsing finished
90
                return;
10✔
91
            }
92
            if ($listItem) {
12✔
93
                $listItem->addComments($comments);
12✔
94
                $list->append($listItem);
12✔
95
            }
96
            $comments = $parserState->consumeWhiteSpace();
12✔
97
        }
98
        $list->addComments($comments);
12✔
99
        if (!$isRoot && !$usesLenientParsing) {
12✔
100
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
101
        }
102
    }
12✔
103

104
    /**
105
     * @return CSSListItem|false|null
106
     *         If `null` is returned, it means the end of the list has been reached.
107
     *         If `false` is returned, it means an invalid item has been encountered,
108
     *         but parsing of the next item should proceed.
109
     *
110
     * @throws SourceException
111
     * @throws UnexpectedEOFException
112
     * @throws UnexpectedTokenException
113
     */
114
    private static function parseListItem(ParserState $parserState, CSSList $list)
12✔
115
    {
116
        $isRoot = $list instanceof Document;
12✔
117
        if ($parserState->comes('@')) {
12✔
118
            $atRule = self::parseAtRule($parserState);
11✔
119
            if ($atRule instanceof Charset) {
11✔
120
                if (!$isRoot) {
×
121
                    throw new UnexpectedTokenException(
×
122
                        '@charset may only occur in root document',
×
123
                        '',
×
124
                        'custom',
×
125
                        $parserState->currentLine()
×
126
                    );
127
                }
128
                if (\count($list->getContents()) > 0) {
×
129
                    throw new UnexpectedTokenException(
×
130
                        '@charset must be the first parseable token in a document',
×
131
                        '',
×
132
                        'custom',
×
133
                        $parserState->currentLine()
×
134
                    );
135
                }
136
                $parserState->setCharset($atRule->getCharset());
×
137
            }
138
            return $atRule;
11✔
139
        } elseif ($parserState->comes('}')) {
12✔
140
            if ($isRoot) {
12✔
141
                if ($parserState->getSettings()->usesLenientParsing()) {
2✔
142
                    $parserState->consume(1);
2✔
143
                    return self::parseListItem($parserState, $list);
2✔
144
                } else {
145
                    throw new SourceException('Unopened {', $parserState->currentLine());
×
146
                }
147
            } else {
148
                // End of list
149
                return null;
10✔
150
            }
151
        } else {
152
            return DeclarationBlock::parse($parserState, $list) ?? false;
11✔
153
        }
154
    }
155

156
    /**
157
     * @throws SourceException
158
     * @throws UnexpectedTokenException
159
     * @throws UnexpectedEOFException
160
     */
161
    private static function parseAtRule(ParserState $parserState): ?CSSListItem
11✔
162
    {
163
        $parserState->consume('@');
11✔
164
        $identifier = $parserState->parseIdentifier();
11✔
165
        $identifierLineNumber = $parserState->currentLine();
11✔
166
        $parserState->consumeWhiteSpace();
11✔
167
        if ($identifier === 'import') {
11✔
168
            $location = URL::parse($parserState);
1✔
169
            $parserState->consumeWhiteSpace();
1✔
170
            $mediaQuery = null;
1✔
171
            if (!$parserState->comes(';')) {
1✔
172
                $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
×
173
                if ($mediaQuery === '') {
×
174
                    $mediaQuery = null;
×
175
                }
176
            }
177
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
1✔
178
            return new Import($location, $mediaQuery, $identifierLineNumber);
1✔
179
        } elseif ($identifier === 'charset') {
10✔
180
            $charsetString = CSSString::parse($parserState);
×
181
            $parserState->consumeWhiteSpace();
×
182
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
×
183
            return new Charset($charsetString, $identifierLineNumber);
×
184
        } elseif (self::identifierIs($identifier, 'keyframes')) {
10✔
185
            $result = new KeyFrame($identifierLineNumber);
1✔
186
            $result->setVendorKeyFrame($identifier);
1✔
187
            $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
1✔
188
            CSSList::parseList($parserState, $result);
1✔
189
            if ($parserState->comes('}')) {
1✔
190
                $parserState->consume('}');
1✔
191
            }
192
            return $result;
1✔
193
        } elseif ($identifier === 'namespace') {
9✔
194
            $prefix = null;
×
195
            $url = Value::parsePrimitiveValue($parserState);
×
196
            if (!$parserState->comes(';')) {
×
197
                $prefix = $url;
×
198
                $url = Value::parsePrimitiveValue($parserState);
×
199
            }
200
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
×
201
            if ($prefix !== null && !\is_string($prefix)) {
×
202
                throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
×
203
            }
204
            if (!($url instanceof CSSString || $url instanceof URL)) {
×
205
                throw new UnexpectedTokenException(
×
206
                    'Wrong namespace url of invalid type',
×
207
                    $url,
208
                    'custom',
×
209
                    $identifierLineNumber
210
                );
211
            }
212
            return new CSSNamespace($url, $prefix, $identifierLineNumber);
×
213
        } else {
214
            // Unknown other at rule (font-face or such)
215
            $arguments = \trim($parserState->consumeUntil('{', false, true));
9✔
216
            if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
9✔
217
                if ($parserState->getSettings()->usesLenientParsing()) {
×
218
                    return null;
×
219
                } else {
220
                    throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
×
221
                }
222
            }
223
            $useRuleSet = true;
9✔
224
            foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
9✔
225
                if (self::identifierIs($identifier, $blockRuleName)) {
9✔
226
                    $useRuleSet = false;
9✔
227
                    break;
9✔
228
                }
229
            }
230
            if ($useRuleSet) {
9✔
231
                $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
×
232
                RuleSet::parseRuleSet($parserState, $atRule);
×
233
            } else {
234
                $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
9✔
235
                CSSList::parseList($parserState, $atRule);
9✔
236
                if ($parserState->comes('}')) {
9✔
237
                    $parserState->consume('}');
9✔
238
                }
239
            }
240
            return $atRule;
9✔
241
        }
242
    }
243

244
    /**
245
     * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
246
     * We need to check for these versions too.
247
     */
248
    private static function identifierIs(string $identifier, string $match): bool
10✔
249
    {
250
        if (\strcasecmp($identifier, $match) === 0) {
10✔
251
            return true;
10✔
252
        }
253

254
        return preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
9✔
255
    }
256

257
    /**
258
     * Prepends an item to the list of contents.
259
     */
260
    public function prepend(CSSListItem $item): void
×
261
    {
262
        \array_unshift($this->contents, $item);
×
263
    }
×
264

265
    /**
266
     * Appends an item to the list of contents.
267
     */
268
    public function append(CSSListItem $item): void
47✔
269
    {
270
        $this->contents[] = $item;
47✔
271
    }
47✔
272

273
    /**
274
     * Splices the list of contents.
275
     *
276
     * @param array<int, CSSListItem> $replacement
277
     */
278
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
×
279
    {
280
        \array_splice($this->contents, $offset, $length, $replacement);
×
281
    }
×
282

283
    /**
284
     * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
285
     * the item is appended at the end.
286
     */
287
    public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
2✔
288
    {
289
        if (\in_array($sibling, $this->contents, true)) {
2✔
290
            $this->replace($sibling, [$item, $sibling]);
1✔
291
        } else {
292
            $this->append($item);
1✔
293
        }
294
    }
2✔
295

296
    /**
297
     * Removes an item from the CSS list.
298
     *
299
     * @param CSSListItem $itemToRemove
300
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
301
     *        a `Charset` or another `CSSList` (most likely a `MediaQuery`)
302
     *
303
     * @return bool whether the item was removed
304
     */
305
    public function remove(CSSListItem $itemToRemove): bool
×
306
    {
307
        $key = \array_search($itemToRemove, $this->contents, true);
×
308
        if ($key !== false) {
×
309
            unset($this->contents[$key]);
×
310
            return true;
×
311
        }
312

313
        return false;
×
314
    }
315

316
    /**
317
     * Replaces an item from the CSS list.
318
     *
319
     * @param CSSListItem $oldItem
320
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
321
     *        or another `CSSList` (most likely a `MediaQuery`)
322
     * @param CSSListItem|array<CSSListItem> $newItem
323
     */
324
    public function replace(CSSListItem $oldItem, $newItem): bool
1✔
325
    {
326
        $key = \array_search($oldItem, $this->contents, true);
1✔
327
        if ($key !== false) {
1✔
328
            if (\is_array($newItem)) {
1✔
329
                \array_splice($this->contents, $key, 1, $newItem);
1✔
330
            } else {
331
                \array_splice($this->contents, $key, 1, [$newItem]);
×
332
            }
333
            return true;
1✔
334
        }
335

336
        return false;
×
337
    }
338

339
    /**
340
     * @param array<int, CSSListItem> $contents
341
     */
342
    public function setContents(array $contents): void
36✔
343
    {
344
        $this->contents = [];
36✔
345
        foreach ($contents as $content) {
36✔
346
            $this->append($content);
35✔
347
        }
348
    }
36✔
349

350
    /**
351
     * Removes a declaration block from the CSS list if it matches all given selectors.
352
     *
353
     * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
354
     * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
355
     */
356
    public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
9✔
357
    {
358
        if ($selectors instanceof DeclarationBlock) {
9✔
359
            $selectors = $selectors->getSelectors();
2✔
360
        }
361
        if (!\is_array($selectors)) {
9✔
362
            $selectors = \explode(',', $selectors);
×
363
        }
364
        foreach ($selectors as $key => &$selector) {
9✔
365
            if (!($selector instanceof Selector)) {
9✔
366
                if (!Selector::isValid($selector)) {
2✔
367
                    throw new UnexpectedTokenException(
×
368
                        "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
×
369
                        $selector,
370
                        'custom'
×
371
                    );
372
                }
373
                $selector = new Selector($selector);
2✔
374
            }
375
        }
376
        foreach ($this->contents as $key => $item) {
9✔
377
            if (!($item instanceof DeclarationBlock)) {
9✔
378
                continue;
×
379
            }
380
            if (self::selectorsMatch($item->getSelectors(), $selectors)) {
9✔
381
                unset($this->contents[$key]);
9✔
382
                if (!$removeAll) {
9✔
383
                    return;
5✔
384
                }
385
            }
386
        }
387
    }
4✔
388

389
    protected function renderListContents(OutputFormat $outputFormat): string
2✔
390
    {
391
        $result = '';
2✔
392
        $isFirst = true;
2✔
393
        $nextLevelFormat = $outputFormat;
2✔
394
        if (!$this->isRootList()) {
2✔
395
            $nextLevelFormat = $outputFormat->nextLevel();
×
396
        }
397
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
2✔
398
        $formatter = $outputFormat->getFormatter();
2✔
399
        foreach ($this->contents as $listItem) {
2✔
400
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
401
                return $listItem->render($nextLevelFormat);
2✔
402
            });
2✔
403
            if ($renderedCss === null) {
2✔
404
                continue;
×
405
            }
406
            if ($isFirst) {
2✔
407
                $isFirst = false;
2✔
408
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
2✔
409
            } else {
410
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
×
411
            }
412
            $result .= $renderedCss;
2✔
413
        }
414

415
        if (!$isFirst) {
2✔
416
            // Had some output
417
            $result .= $formatter->spaceAfterBlocks();
2✔
418
        }
419

420
        return $result;
2✔
421
    }
422

423
    /**
424
     * Return true if the list can not be further outdented. Only important when rendering.
425
     */
426
    abstract public function isRootList(): bool;
427

428
    /**
429
     * Returns the stored items.
430
     *
431
     * @return array<int<0, max>, CSSListItem>
432
     */
433
    public function getContents(): array
34✔
434
    {
435
        return $this->contents;
34✔
436
    }
437

438
    /**
439
     * @param list<Selector> $selectors1
440
     * @param list<Selector> $selectors2
441
     */
442
    private static function selectorsMatch(array $selectors1, array $selectors2): bool
9✔
443
    {
444
        $selectorStrings1 = self::getSelectorStrings($selectors1);
9✔
445
        $selectorStrings2 = self::getSelectorStrings($selectors2);
9✔
446

447
        \sort($selectorStrings1);
9✔
448
        \sort($selectorStrings2);
9✔
449

450
        return $selectorStrings1 === $selectorStrings2;
9✔
451
    }
452

453
    /**
454
     * @param list<Selector> $selectors
455
     *
456
     * @return list<string>
457
     */
458
    private static function getSelectorStrings(array $selectors): array
9✔
459
    {
460
        return \array_map(
9✔
461
            static function (Selector $selector): string {
462
                return $selector->getSelector();
9✔
463
            },
9✔
464
            $selectors
9✔
465
        );
466
    }
467
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc