Skip to content

Commit 0700dac

Browse files
authored
Merge pull request #2088 from rootvector2/add-oss-fuzz-harness
Add OSS-Fuzz fuzzing harness under test/fuzzing/
2 parents 9a0d24d + b6a6094 commit 0700dac

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

test/fuzzing/fuzz_parse.dict

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Dictionary of common CSS tokens for libFuzzer.
2+
# Reference: https://www.w3.org/TR/css-syntax-3/
3+
4+
# At-rules
5+
"@charset "
6+
"@import "
7+
"@media "
8+
"@supports "
9+
"@font-face "
10+
"@keyframes "
11+
"@page "
12+
"@namespace "
13+
"@document "
14+
"@layer "
15+
"@container "
16+
"@property "
17+
"@scope "
18+
"@counter-style "
19+
"@font-feature-values "
20+
21+
# Structural punctuation
22+
"{"
23+
"}"
24+
";"
25+
":"
26+
","
27+
"("
28+
")"
29+
"["
30+
"]"
31+
"/*"
32+
"*/"
33+
"!important"
34+
"--"
35+
36+
# Selectors
37+
"*"
38+
">"
39+
"+"
40+
"~"
41+
"::"
42+
"&"
43+
44+
# Common pseudo-classes / pseudo-elements
45+
":hover"
46+
":focus"
47+
":active"
48+
":root"
49+
":not("
50+
":is("
51+
":where("
52+
":has("
53+
"::before"
54+
"::after"
55+
56+
# Common properties
57+
"color:"
58+
"background:"
59+
"background-color:"
60+
"width:"
61+
"height:"
62+
"margin:"
63+
"padding:"
64+
"border:"
65+
"display:"
66+
"position:"
67+
"font-size:"
68+
"font-family:"
69+
"transform:"
70+
"transition:"
71+
"animation:"
72+
"grid-template-columns:"
73+
"flex:"
74+
75+
# Values / units
76+
"px"
77+
"em"
78+
"rem"
79+
"%"
80+
"vh"
81+
"vw"
82+
"deg"
83+
"rgb("
84+
"rgba("
85+
"hsl("
86+
"hsla("
87+
"calc("
88+
"var("
89+
"url("
90+
"linear-gradient("
91+
"none"
92+
"auto"
93+
"inherit"
94+
"initial"
95+
"unset"
96+
"revert"
97+
98+
# Strings / escapes
99+
"\""
100+
"'"
101+
"\\"
102+
103+
# Source map annotations
104+
# Reaching the source-map parsing branch quickly has historically uncovered
105+
# bugs; postcss matches /\*\s*# sourceMappingURL=/ and decodes inline base64
106+
# payloads via the `data:application/json;base64,` prefix.
107+
"/*# sourceMappingURL="
108+
"# sourceMappingURL="
109+
"data:application/json;base64,"

test/fuzzing/fuzz_parse.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
////////////////////////////////////////////////////////////////////////////////
16+
17+
const { FuzzedDataProvider } = require('@jazzer.js/core')
18+
const postcss = require('../../lib/postcss')
19+
20+
module.exports.fuzz = function (data) {
21+
const provider = new FuzzedDataProvider(data)
22+
23+
// The CSS input itself is randomized: every byte the fuzzer produces (or
24+
// mutates from the seed corpus) flows directly into `cssString` via
25+
// consumeRemainingAsString(). The option flags below are read from the
26+
// *back* of the buffer (jazzer.js consumes integrals/booleans from the
27+
// tail), so seed CSS files from postcss-parser-tests are fed into the
28+
// parser nearly verbatim, with only their last few bytes nibbled off as
29+
// option control.
30+
const useMap = provider.consumeBoolean()
31+
const useFrom = provider.consumeBoolean()
32+
const useProcessor = provider.consumeBoolean()
33+
const splitMode = provider.consumeIntegralInRange(0, 2)
34+
const cssString = provider.consumeRemainingAsString()
35+
36+
const parseOptions = {}
37+
if (useFrom) parseOptions.from = 'fuzz.css'
38+
if (useMap) parseOptions.map = { inline: false, annotation: false }
39+
40+
let root
41+
try {
42+
root = postcss.parse(cssString, parseOptions)
43+
} catch (e) {
44+
if (e instanceof postcss.CssSyntaxError) return
45+
throw e
46+
}
47+
48+
// Walk the AST and exercise common node accessors. This also stresses
49+
// raws/source bookkeeping for any node returned by the parser.
50+
try {
51+
root.walk(node => {
52+
void node.type
53+
void node.toString()
54+
if (typeof node.error === 'function') {
55+
// Generating an error message touches input/source-map machinery.
56+
node.error('fuzz').message
57+
}
58+
})
59+
} catch (e) {
60+
if (!isExpected(e, postcss)) throw e
61+
}
62+
63+
// Round-trip via stringify and re-parse. Output should itself be parseable.
64+
let serialized
65+
try {
66+
serialized = root.toString()
67+
} catch (e) {
68+
if (!isExpected(e, postcss)) throw e
69+
return
70+
}
71+
72+
try {
73+
postcss.parse(serialized)
74+
} catch (e) {
75+
if (!(e instanceof postcss.CssSyntaxError)) throw e
76+
}
77+
78+
// Exercise the JSON serialization round-trip.
79+
try {
80+
const json = root.toJSON()
81+
postcss.fromJSON(json)
82+
} catch (e) {
83+
if (!isExpected(e, postcss)) throw e
84+
}
85+
86+
// Exercise the main public entry point: postcss().process(). This drives
87+
// the LazyResult / NoWorkResult pipeline that real plugin chains use.
88+
if (useProcessor) {
89+
try {
90+
const result = postcss().process(cssString, parseOptions)
91+
void result.css
92+
void result.warnings()
93+
} catch (e) {
94+
if (!isExpected(e, postcss)) throw e
95+
}
96+
}
97+
98+
// Exercise the list helpers, which have their own quoting/escape logic.
99+
try {
100+
if (splitMode === 0) {
101+
postcss.list.comma(cssString)
102+
} else if (splitMode === 1) {
103+
postcss.list.space(cssString)
104+
} else {
105+
postcss.list.split(cssString, [',', ' '], false)
106+
}
107+
} catch (e) {
108+
if (!isExpected(e, postcss)) throw e
109+
}
110+
}
111+
112+
function isExpected(error, postcss) {
113+
if (error instanceof postcss.CssSyntaxError) return true
114+
if (!error || typeof error.message !== 'string') return false
115+
// Some legitimate inputs reach known-shaped TypeErrors during stringify or
116+
// walk because the CSS allows constructs whose textual form is ambiguous.
117+
// Suppress only those well-defined cases so real bugs still surface.
118+
const benign = ['Unknown node type', 'Unknown word']
119+
return benign.some(msg => error.message.indexOf(msg) !== -1)
120+
}

0 commit comments

Comments
 (0)