cataract 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci-manual-rubies.yml +27 -0
- data/.overcommit.yml +1 -1
- data/.rubocop.yml +62 -0
- data/.rubocop_todo.yml +186 -0
- data/BENCHMARKS.md +60 -139
- data/CHANGELOG.md +14 -0
- data/README.md +30 -2
- data/Rakefile +49 -22
- data/cataract.gemspec +4 -1
- data/ext/cataract/cataract.c +47 -47
- data/ext/cataract/css_parser.c +17 -33
- data/ext/cataract/merge.c +58 -2
- data/lib/cataract/at_rule.rb +8 -9
- data/lib/cataract/declaration.rb +18 -0
- data/lib/cataract/import_resolver.rb +3 -4
- data/lib/cataract/pure/byte_constants.rb +69 -0
- data/lib/cataract/pure/helpers.rb +35 -0
- data/lib/cataract/pure/imports.rb +255 -0
- data/lib/cataract/pure/merge.rb +1146 -0
- data/lib/cataract/pure/parser.rb +1236 -0
- data/lib/cataract/pure/serializer.rb +590 -0
- data/lib/cataract/pure/specificity.rb +206 -0
- data/lib/cataract/pure.rb +130 -0
- data/lib/cataract/rule.rb +22 -13
- data/lib/cataract/stylesheet.rb +14 -9
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +18 -5
- metadata +12 -25
- data/benchmarks/benchmark_harness.rb +0 -193
- data/benchmarks/benchmark_merging.rb +0 -121
- data/benchmarks/benchmark_optimization_comparison.rb +0 -168
- data/benchmarks/benchmark_parsing.rb +0 -153
- data/benchmarks/benchmark_ragel_removal.rb +0 -56
- data/benchmarks/benchmark_runner.rb +0 -70
- data/benchmarks/benchmark_serialization.rb +0 -180
- data/benchmarks/benchmark_shorthand.rb +0 -109
- data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
- data/benchmarks/benchmark_specificity.rb +0 -124
- data/benchmarks/benchmark_string_allocation.rb +0 -151
- data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
- data/benchmarks/benchmark_to_s_cached.rb +0 -55
- data/benchmarks/benchmark_value_splitter.rb +0 -54
- data/benchmarks/benchmark_yjit.rb +0 -158
- data/benchmarks/benchmark_yjit_workers.rb +0 -61
- data/benchmarks/profile_to_s.rb +0 -23
- data/benchmarks/speedup_calculator.rb +0 -83
- data/benchmarks/system_metadata.rb +0 -81
- data/benchmarks/templates/benchmarks.md.erb +0 -221
- data/benchmarks/yjit_tests.rb +0 -141
- data/scripts/fuzzer/run.rb +0 -828
- data/scripts/fuzzer/worker.rb +0 -99
- data/scripts/generate_benchmarks_md.rb +0 -155
data/scripts/fuzzer/run.rb
DELETED
|
@@ -1,828 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Simple CSS parser fuzzer
|
|
5
|
-
# Usage: ruby scripts/fuzz_css_parser.rb [iterations] [rng_seed]
|
|
6
|
-
# iterations: number of fuzzing iterations (default: 10,000)
|
|
7
|
-
# rng_seed: random number generator seed for reproducibility (default: random)
|
|
8
|
-
|
|
9
|
-
require 'timeout'
|
|
10
|
-
require 'open3'
|
|
11
|
-
require 'rbconfig'
|
|
12
|
-
require_relative '../../lib/cataract'
|
|
13
|
-
require_relative '../../lib/cataract/color_conversion' # Load color extension for fuzzing
|
|
14
|
-
|
|
15
|
-
# Check if Cataract is compiled in debug mode (would overwhelm pipe buffer)
|
|
16
|
-
if Cataract::COMPILE_FLAGS[:debug]
|
|
17
|
-
abort <<~ERROR
|
|
18
|
-
#{'=' * 80}
|
|
19
|
-
ERROR: Cataract compiled with DEBUG mode enabled
|
|
20
|
-
#{'=' * 80}
|
|
21
|
-
Debug output will overwhelm the pipe buffer and freeze the fuzzer.
|
|
22
|
-
|
|
23
|
-
To disable debug mode:
|
|
24
|
-
1. Edit ext/cataract/cataract.h
|
|
25
|
-
2. Comment out the line: #define CATARACT_DEBUG 1
|
|
26
|
-
3. Recompile: rake compile
|
|
27
|
-
#{'=' * 80}
|
|
28
|
-
ERROR
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Check if Ruby is compiled with AddressSanitizer (ASAN)
|
|
32
|
-
# ASAN provides detailed heap-buffer-overflow and use-after-free reports
|
|
33
|
-
def check_asan_enabled
|
|
34
|
-
ruby_bin = RbConfig.ruby
|
|
35
|
-
|
|
36
|
-
# Check for ASAN library linkage (cross-platform)
|
|
37
|
-
case RbConfig::CONFIG['host_os']
|
|
38
|
-
when /darwin|mac os/
|
|
39
|
-
# macOS: use otool to check dynamic libraries
|
|
40
|
-
output = `otool -L "#{ruby_bin}" 2>&1`
|
|
41
|
-
output.include?('asan')
|
|
42
|
-
when /linux/
|
|
43
|
-
# Linux: use ldd to check dynamic libraries
|
|
44
|
-
output = `ldd "#{ruby_bin}" 2>&1`
|
|
45
|
-
output.include?('asan')
|
|
46
|
-
else
|
|
47
|
-
# Unknown platform - assume not enabled
|
|
48
|
-
false
|
|
49
|
-
end
|
|
50
|
-
rescue StandardError
|
|
51
|
-
# If check fails, assume not enabled
|
|
52
|
-
false
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
unless check_asan_enabled
|
|
56
|
-
warn '=' * 80
|
|
57
|
-
warn 'WARNING: Ruby not compiled with AddressSanitizer (ASAN)'
|
|
58
|
-
warn '=' * 80
|
|
59
|
-
warn 'Crash reports will have limited utility for debugging memory errors.'
|
|
60
|
-
warn ''
|
|
61
|
-
warn 'To enable ASAN, recompile Ruby with these flags:'
|
|
62
|
-
warn ' CFLAGS="-fsanitize=address -g -O1" LDFLAGS="-fsanitize=address"'
|
|
63
|
-
warn ''
|
|
64
|
-
warn 'Example with mise:'
|
|
65
|
-
warn " CFLAGS=\"-fsanitize=address -g -O1\" LDFLAGS=\"-fsanitize=address\" mise install ruby@#{RUBY_VERSION} --force"
|
|
66
|
-
warn ''
|
|
67
|
-
warn 'Example with rbenv/ruby-build:'
|
|
68
|
-
warn " CFLAGS=\"-fsanitize=address -g -O1\" LDFLAGS=\"-fsanitize=address\" rbenv install #{RUBY_VERSION}"
|
|
69
|
-
warn ''
|
|
70
|
-
warn 'ASAN provides detailed reports for:'
|
|
71
|
-
warn ' - Heap buffer overflows'
|
|
72
|
-
warn ' - Use-after-free bugs'
|
|
73
|
-
warn ' - Stack buffer overflows'
|
|
74
|
-
warn ' - Memory leaks'
|
|
75
|
-
warn '=' * 80
|
|
76
|
-
warn ''
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
ITERATIONS = (ARGV[0] || 10_000).to_i
|
|
80
|
-
RNG_SEED = (ARGV[1] || Random.new_seed).to_i
|
|
81
|
-
|
|
82
|
-
# Set the random seed for reproducibility
|
|
83
|
-
srand(RNG_SEED)
|
|
84
|
-
|
|
85
|
-
# Load bootstrap.css as main seed
|
|
86
|
-
BOOTSTRAP_CSS = File.read(File.join(__dir__, '../../test/fixtures/bootstrap.css'))
|
|
87
|
-
|
|
88
|
-
# Clean CSS samples for regex-based mutations (guaranteed valid UTF-8)
|
|
89
|
-
CLEAN_CORPUS = [
|
|
90
|
-
'body { margin: 0; padding: 0; }',
|
|
91
|
-
'div { color: red; background: blue; }',
|
|
92
|
-
'.class { font-size: 14px; }',
|
|
93
|
-
'#id { display: flex; }',
|
|
94
|
-
'a:hover { text-decoration: underline; }',
|
|
95
|
-
'@media screen { body { font-size: 16px; } }',
|
|
96
|
-
'.button { color: blue; &:hover { color: red; } }',
|
|
97
|
-
'.parent { margin: 0; .child { padding: 10px; } }',
|
|
98
|
-
'@supports (display: flex) { div { display: flex; } }',
|
|
99
|
-
"@font-face { font-family: 'Custom'; src: url('font.woff'); }",
|
|
100
|
-
'@keyframes fade { from { opacity: 0; } to { opacity: 1; } }',
|
|
101
|
-
'h1 + *[rel=up] { border: 1px solid red; }',
|
|
102
|
-
BOOTSTRAP_CSS[0..1000] # Clean bootstrap snippet
|
|
103
|
-
].freeze
|
|
104
|
-
|
|
105
|
-
# CSS corpus - real CSS snippets to mutate (includes garbage samples with binary data)
|
|
106
|
-
CORPUS = [
|
|
107
|
-
BOOTSTRAP_CSS, # Full bootstrap.css
|
|
108
|
-
# Interesting subsections of bootstrap
|
|
109
|
-
BOOTSTRAP_CSS[0..5000],
|
|
110
|
-
BOOTSTRAP_CSS[10_000..20_000],
|
|
111
|
-
BOOTSTRAP_CSS[-5000..],
|
|
112
|
-
# Small focused examples
|
|
113
|
-
'body { margin: 0; }',
|
|
114
|
-
"div.class { color: red; background: url('data:image/png;base64,ABC'); }",
|
|
115
|
-
"#id > p:hover::before { content: 'test'; }",
|
|
116
|
-
"a[href^='https'] { color: blue !important; }",
|
|
117
|
-
'@keyframes fade { from { opacity: 0; } to { opacity: 1; } }',
|
|
118
|
-
"@font-face { font-family: 'Custom'; src: url('font.woff'); }",
|
|
119
|
-
'h1 + *[rel=up] { margin: 10px 20px; }',
|
|
120
|
-
'li.red.level { border: 1px solid red; }',
|
|
121
|
-
'/* comment */ .test { padding: 0; }',
|
|
122
|
-
|
|
123
|
-
# Media query parsing - test parse_media_query() function
|
|
124
|
-
'@media screen { .nav { display: flex; } }',
|
|
125
|
-
'@media print { body { margin: 1in; } }',
|
|
126
|
-
'@media screen, print { .dual { color: black; } }',
|
|
127
|
-
'@media screen and (min-width: 768px) { .responsive { width: 100%; } }',
|
|
128
|
-
'@media (prefers-color-scheme: dark) { body { background: black; } }',
|
|
129
|
-
'@media only screen and (max-width: 600px) { .mobile { font-size: 12px; } }',
|
|
130
|
-
'@media not print { .no-print { display: none; } }',
|
|
131
|
-
'@media screen and (min-width: 768px) and (max-width: 1024px) { .tablet { padding: 20px; } }',
|
|
132
|
-
'@media (orientation: landscape) { .landscape { width: 100vw; } }',
|
|
133
|
-
'@media screen and (color) { .color { background: red; } }',
|
|
134
|
-
"@media (min-resolution: 2dppx) { .retina { background-image: url('hi-res.png'); } }",
|
|
135
|
-
"@media (-webkit-min-device-pixel-ratio: 2) { .webkit { content: 'vendor'; } }",
|
|
136
|
-
|
|
137
|
-
# Color conversion test cases - try to trigger segfaults
|
|
138
|
-
'.test { color: #ff0000; }',
|
|
139
|
-
'.test { color: rgb(255, 0, 0); }',
|
|
140
|
-
'.test { color: rgb(255 0 0); }',
|
|
141
|
-
'.test { color: rgba(255, 0, 0, 0.5); }',
|
|
142
|
-
'.test { color: hsl(0, 100%, 50%); }',
|
|
143
|
-
'.test { color: hsla(0, 100%, 50%, 0.5); }',
|
|
144
|
-
'.test { color: hwb(0 0% 0%); }',
|
|
145
|
-
'.test { color: oklab(0.628 0.225 0.126); }',
|
|
146
|
-
'.test { color: oklch(0.628 0.258 29.2); }',
|
|
147
|
-
'.test { color: lab(53.2% 80.1 67.2); }',
|
|
148
|
-
'.test { color: lch(53.2% 104.5 40); }',
|
|
149
|
-
'.test { color: red; }',
|
|
150
|
-
# Invalid/malformed color values for fuzzing
|
|
151
|
-
'.test { color: #gg0000; }',
|
|
152
|
-
'.test { color: rgb(999, -100, 300); }',
|
|
153
|
-
'.test { color: hsl(999deg, 200%, -50%); }',
|
|
154
|
-
'.test { color: oklab(99 99 99); }',
|
|
155
|
-
'.test { color: lab(200% 999 999); }',
|
|
156
|
-
|
|
157
|
-
# Deep nesting - close to MAX_PARSE_DEPTH (10)
|
|
158
|
-
# Depth 8 - mutations can push it over the limit
|
|
159
|
-
'@supports (a) { @media (b) { @supports (c) { @layer d { @container (e) { @scope (f) { @media (g) { @supports (h) { body { margin: 0; } } } } } } } }',
|
|
160
|
-
|
|
161
|
-
# Long property names - close to MAX_PROPERTY_NAME_LENGTH (256)
|
|
162
|
-
"body { #{'a' * 200}-property: value; }",
|
|
163
|
-
|
|
164
|
-
# Long property values - close to MAX_PROPERTY_VALUE_LENGTH (32KB)
|
|
165
|
-
"body { background: url('data:image/svg+xml,#{'A' * 30_000}'); }",
|
|
166
|
-
"div { content: '#{'x' * 31_000}'; }",
|
|
167
|
-
|
|
168
|
-
# Multiple nested @supports to stress recursion
|
|
169
|
-
'@supports (display: flex) { @supports (gap: 1rem) { div { display: flex; } } }',
|
|
170
|
-
|
|
171
|
-
# CSS Nesting - Valid cases
|
|
172
|
-
'.button { color: blue; &:hover { color: red; } }',
|
|
173
|
-
'.parent { margin: 0; .child { padding: 10px; } }',
|
|
174
|
-
'.card { & .title { font-size: 20px; } & .body { margin: 10px; } }',
|
|
175
|
-
'.a, .b { color: black; & .child { color: white; } }',
|
|
176
|
-
'.foo { color: red; @media (min-width: 768px) { color: blue; } }',
|
|
177
|
-
'.parent { color: red; & > .child { color: blue; } }',
|
|
178
|
-
'.nav { display: flex; &.active { background: red; } }',
|
|
179
|
-
'.outer { .middle { .inner { color: red; } } }',
|
|
180
|
-
'.button { &:hover, &:focus { outline: 2px solid blue; } }',
|
|
181
|
-
'.list { & > li { & > a { text-decoration: none; } } }',
|
|
182
|
-
|
|
183
|
-
# CSS Nesting - Garbage/malformed cases to stress parser
|
|
184
|
-
'.parent { & { } }', # Empty nested rule
|
|
185
|
-
'.a { & }', # Incomplete nested rule
|
|
186
|
-
'.b { &', # Missing closing brace
|
|
187
|
-
'.c { & .child { }', # Missing outer closing brace
|
|
188
|
-
'.d { & .child { color: red; }', # Missing outer closing brace
|
|
189
|
-
'.e { & & & & & { color: red; } }', # Multiple ampersands
|
|
190
|
-
'.f { &&&&& { } }', # Continuous ampersands
|
|
191
|
-
'.g { &..child { } }', # Invalid selector after &
|
|
192
|
-
'.h { &#invalid { } }', # Invalid combinator
|
|
193
|
-
'.i { &::: { } }', # Too many colons
|
|
194
|
-
'.j { & .a { & .b { & .c { & .d { & .e { & .f { & .g { & .h { & .i { & .j { } } } } } } } } } } }', # Deep nesting
|
|
195
|
-
'.k { & .child { color: red; & }', # Incomplete nested block
|
|
196
|
-
'.l { .child & { } }', # & in wrong position
|
|
197
|
-
'.m { .a { .b { .c }', # Missing closing braces in chain
|
|
198
|
-
'.n { & { & { & { } } }', # Nested empty blocks
|
|
199
|
-
'.o { color: red &:hover { } }', # Missing semicolon before nesting
|
|
200
|
-
'.p { &, { } }', # Comma with nothing after
|
|
201
|
-
'.q { &, , , .child { } }', # Multiple commas
|
|
202
|
-
'.r { & .child { @media { } } }', # Incomplete @media in nested
|
|
203
|
-
'.s { @media { .child { } }', # Missing @media query
|
|
204
|
-
'.t { @media screen & .child { } }', # Invalid @media syntax with nesting
|
|
205
|
-
'.u { & .a, & .b, & .c, }', # Trailing comma in nested selector
|
|
206
|
-
'.v { & { color: &; } }', # & as value
|
|
207
|
-
'.w { & .child { & .grandchild { & .great { color: red } } }', # Missing braces
|
|
208
|
-
'.x { &&&.child { } }', # Multiple & before class
|
|
209
|
-
'.y { & + & + & { } }', # Adjacent sibling combinators
|
|
210
|
-
'.z { & ~ & ~ & { } }', # General sibling combinators
|
|
211
|
-
|
|
212
|
-
# CSS Nesting with @media - garbage
|
|
213
|
-
'.a { @media }', # Incomplete @media
|
|
214
|
-
'.b { @media screen }', # @media without block
|
|
215
|
-
'.c { @media screen { }', # Missing outer closing brace
|
|
216
|
-
'.d { @media (garbage) { color: red; } }', # Invalid media query in nested
|
|
217
|
-
'.e { @media screen { @media print { } }', # Missing closing braces
|
|
218
|
-
'.f { @media { @media { @media { } } } }', # Nested @media without queries
|
|
219
|
-
|
|
220
|
-
# Extreme nesting garbage
|
|
221
|
-
'.a { & .b { & .c { & .d { & .e { & .f { & .g { & .h { & .i { & .j { & .k { } } } } } } } } } } }', # 11 levels
|
|
222
|
-
".deep { #{'& .x { ' * 50}color: red;#{' }' * 50} }", # Very deep nesting
|
|
223
|
-
".unclosed { #{'& .x { ' * 20}", # Many unclosed nested blocks
|
|
224
|
-
'.chaos { & { & { & { & { & { & { & { & { & { & { } } } } } } } } } } }', # Deep self-nesting
|
|
225
|
-
|
|
226
|
-
# Brace chaos in nested context
|
|
227
|
-
'.parent { &:hover { color: red; }}}}}', # Extra closing braces
|
|
228
|
-
'.parent { &:hover {{{{{{ color: red; } }', # Extra opening braces
|
|
229
|
-
'.parent { &:hover { color: red; }; &:focus { color: blue; }', # Missing outer close
|
|
230
|
-
'.parent { & .child { } } } }', # Too many closing braces
|
|
231
|
-
'.parent { { & .child { } }', # Opening brace before nested
|
|
232
|
-
'.parent { & .child { { color: red; } }', # Double opening in nested
|
|
233
|
-
|
|
234
|
-
# Null bytes and binary in nested CSS
|
|
235
|
-
".parent { &\x00.child { color: red; } }", # Null in selector
|
|
236
|
-
".parent { & .child { color: \x00\xFF\xFE; } }", # Null/binary in value
|
|
237
|
-
".parent {\x00 & .child { } }", # Null after opening brace
|
|
238
|
-
".parent { & .child {\x00} }", # Null before closing brace
|
|
239
|
-
|
|
240
|
-
# Comment chaos in nested CSS
|
|
241
|
-
'.parent { /* & .child { */ color: red; }', # Commented nesting
|
|
242
|
-
'.parent { & .child { /* color: red; } */ }', # Unclosed comment in nested
|
|
243
|
-
'.parent { & /* .child */ { color: red; } }', # Comment in middle of selector
|
|
244
|
-
'.parent { & .child /* { color: red; } }', # Comment breaking structure
|
|
245
|
-
|
|
246
|
-
# Escaped characters in nested selectors
|
|
247
|
-
'.parent { &\\.child { } }', # Escaped dot
|
|
248
|
-
'.parent { &\\:hover { } }', # Escaped colon
|
|
249
|
-
'.parent { &\\&child { } }', # Escaped ampersand
|
|
250
|
-
'.parent { \\& .child { } }', # Escaped ampersand at start
|
|
251
|
-
|
|
252
|
-
# Property/value chaos in nested blocks
|
|
253
|
-
'.parent { color: red; & .child { : value; } }', # Missing property
|
|
254
|
-
'.parent { & .child { property; } }', # Missing colon and value
|
|
255
|
-
'.parent { & .child { : ; } }', # Just colon and semicolon
|
|
256
|
-
'.parent { & .child { :::; } }', # Multiple colons
|
|
257
|
-
'.parent { & .child { color red } }', # Missing colon
|
|
258
|
-
'.parent { & .child { color: } }' # Missing value
|
|
259
|
-
].freeze
|
|
260
|
-
|
|
261
|
-
# Color formats to test conversion between
|
|
262
|
-
COLOR_FORMATS = %i[hex rgb hsl hwb oklab oklch lab lch named].freeze
|
|
263
|
-
|
|
264
|
-
# Mutation strategies (binary-safe)
|
|
265
|
-
def mutate(css)
|
|
266
|
-
# Work with dup to avoid mutating original (unfreeze if needed)
|
|
267
|
-
css = css.dup.force_encoding('UTF-8')
|
|
268
|
-
css = +css # Unfreeze if frozen
|
|
269
|
-
|
|
270
|
-
mutations = [
|
|
271
|
-
# Basic mutations
|
|
272
|
-
-> { css[0..rand(css.length)] }, # Truncate
|
|
273
|
-
lambda {
|
|
274
|
-
pos = rand(css.length)
|
|
275
|
-
css.insert(pos, css[0..rand(css.length)])
|
|
276
|
-
}, # Duplicate section
|
|
277
|
-
-> { css.bytes.select { rand > 0.1 }.pack('C*').force_encoding('UTF-8') }, # Delete random bytes
|
|
278
|
-
lambda {
|
|
279
|
-
bytes = css.bytes
|
|
280
|
-
10.times do
|
|
281
|
-
a = rand(bytes.size)
|
|
282
|
-
b = rand(bytes.size)
|
|
283
|
-
bytes[a], bytes[b] = bytes[b], bytes[a]
|
|
284
|
-
end
|
|
285
|
-
bytes.pack('C*').force_encoding('UTF-8')
|
|
286
|
-
}, # Swap bytes
|
|
287
|
-
|
|
288
|
-
# Brace/bracket corruption
|
|
289
|
-
-> { css.gsub(/{/, '').gsub(/}/, '') }, # Remove braces
|
|
290
|
-
-> { css.gsub(/{/, '{{').gsub(/}/, '}}') }, # Duplicate braces
|
|
291
|
-
-> { css + ('{' * rand(5)) }, # Unmatched braces
|
|
292
|
-
-> { css.tr('{', '[').tr('}', ']') }, # Wrong bracket type
|
|
293
|
-
|
|
294
|
-
# Quote corruption
|
|
295
|
-
-> { css.gsub(/["']/, '') }, # Remove quotes
|
|
296
|
-
-> { css.tr('"', "'").tr('\'', '"') }, # Swap quote types
|
|
297
|
-
-> { css.gsub(/(['"])/, '\1\1') }, # Double quotes
|
|
298
|
-
|
|
299
|
-
# @rule mutations
|
|
300
|
-
-> { "@media print { #{css} @media screen { #{css} } }" }, # Deep nesting
|
|
301
|
-
-> { css.gsub(/@media/, '@MEDIA').gsub(/@keyframes/, '@KEYFRAMES') }, # Wrong case
|
|
302
|
-
-> { css.gsub(/@(media|keyframes|font-face)/) { "@#{rand(99_999)}" } }, # Invalid @rules
|
|
303
|
-
-> { "@supports (garbage) { #{css} }" }, # Invalid @supports
|
|
304
|
-
|
|
305
|
-
# Selector mutations
|
|
306
|
-
-> { css.gsub(/\.[\w-]+/, "..#{'x' * rand(100)}") }, # Corrupt class names
|
|
307
|
-
-> { css.gsub(/#[\w-]+/, "###{'x' * rand(100)}") }, # Corrupt IDs
|
|
308
|
-
-> { css.gsub(/::?[\w-]+/, ":::#{'x' * rand(50)}") }, # Corrupt pseudo-elements
|
|
309
|
-
-> { css.gsub(/\[[\w-]+/, "[#{'X' * rand(10)}") }, # Corrupt attributes
|
|
310
|
-
|
|
311
|
-
# Value mutations
|
|
312
|
-
-> { css.gsub(';', ' !important;') }, # Add !important everywhere
|
|
313
|
-
-> { css.gsub(/:[^;]+;/, ": #{'x' * rand(10_000)};") }, # Very long values
|
|
314
|
-
-> { css.gsub(/calc\([^)]+\)/, "calc(#{'(' * rand(10)}1+2#{')' * rand(10)}") }, # Unbalanced calc()
|
|
315
|
-
-> { css.gsub(/url\([^)]+\)/, "url(CORRUPT#{'X' * rand(100)})") }, # Corrupt URLs
|
|
316
|
-
-> { css.gsub(/rgba?\([^)]+\)/, "rgb(#{[rand(999), rand(999), rand(999)].join(',')})") }, # Invalid rgb values
|
|
317
|
-
|
|
318
|
-
# Color mutation - corrupt color values to trigger parser/conversion crashes
|
|
319
|
-
-> { css.gsub(/#[0-9a-f]{3,8}/i, "###{'X' * rand(10)}") }, # Corrupt hex colors
|
|
320
|
-
-> { css.gsub(/rgb\([^)]+\)/, "rgb(#{rand(9999)},#{rand(9999)},#{rand(9999)})") }, # Invalid RGB
|
|
321
|
-
-> { css.gsub(/hsl\([^)]+\)/, "hsl(#{rand(9999)},#{rand(9999)}%,#{rand(9999)}%)") }, # Invalid HSL
|
|
322
|
-
-> { css.gsub(/oklab\([^)]+\)/, "oklab(#{rand(99)} #{rand(99)} #{rand(99)})") }, # Invalid Oklab
|
|
323
|
-
-> { css.gsub(/lab\([^)]+\)/, "lab(#{rand(999)}% #{rand(999)} #{rand(999)})") }, # Invalid Lab
|
|
324
|
-
|
|
325
|
-
# Hex color chaos
|
|
326
|
-
-> { ".test { color: #{'#' * rand(20)}ff0000; }" }, # Multiple hash symbols
|
|
327
|
-
-> { ".test { color: #\x00\x00\x00; }" }, # Null bytes in hex
|
|
328
|
-
-> { ".test { color: ##{'f' * rand(100)}; }" }, # Extremely long hex
|
|
329
|
-
-> { '.test { color: #-ff0000; }' }, # Negative hex
|
|
330
|
-
-> { '.test { color: #ff00; }' }, # Wrong length (5 chars)
|
|
331
|
-
|
|
332
|
-
# RGB/RGBA chaos
|
|
333
|
-
-> { ".test { color: rgb(#{-rand(999)}, #{-rand(999)}, #{-rand(999)}); }" }, # Negative RGB
|
|
334
|
-
-> { '.test { color: rgb(NaN, Infinity, -Infinity); }' }, # Special float values
|
|
335
|
-
-> { '.test { color: rgb(1e999, 1e999, 1e999); }' }, # Scientific notation overflow
|
|
336
|
-
-> { ".test { color: rgba(255, 0, 0, #{rand(999)}); }" }, # Alpha > 1
|
|
337
|
-
-> { ".test { color: rgb(255 0 0 / #{-rand(10)}); }" }, # Negative alpha
|
|
338
|
-
-> { ".test { color: rgb(#{'(' * rand(10)}255, 0, 0#{')' * rand(10)}); }" }, # Paren chaos
|
|
339
|
-
-> { ".test { color: rgb(\x00, \x00, \x00); }" }, # Null bytes in RGB
|
|
340
|
-
-> { '.test { color: rgb(255,,,,,0,,,0); }' }, # Multiple commas
|
|
341
|
-
-> { '.test { color: rgb(255 255 255 255 255); }' }, # Too many values
|
|
342
|
-
|
|
343
|
-
# HSL/HSLA chaos
|
|
344
|
-
-> { ".test { color: hsl(#{rand(99_999)}deg, 100%, 50%); }" }, # Huge hue
|
|
345
|
-
-> { ".test { color: hsl(-#{rand(999)}deg, #{-rand(999)}%, #{-rand(999)}%); }" }, # All negative
|
|
346
|
-
-> { ".test { color: hsl(0, #{rand(9999)}%, #{rand(9999)}%); }" }, # Percentage overflow
|
|
347
|
-
-> { '.test { color: hsl(NaN, NaN%, NaN%); }' }, # NaN values
|
|
348
|
-
-> { ".test { color: hsla(0, 100%, 50%, \x00); }" }, # Null byte alpha
|
|
349
|
-
-> { '.test { color: hsl(0turn, 100%, 50%); }' }, # Units on saturation/lightness
|
|
350
|
-
-> { '.test { color: hsl(0 0 0); }' }, # Missing percentage signs
|
|
351
|
-
|
|
352
|
-
# HWB chaos
|
|
353
|
-
-> { ".test { color: hwb(#{rand(99_999)} #{rand(999)}% #{rand(999)}%); }" }, # Huge values
|
|
354
|
-
-> { ".test { color: hwb(0 #{-rand(999)}% #{-rand(999)}%); }" }, # Negative whiteness/blackness
|
|
355
|
-
-> { '.test { color: hwb(0 200% 200%); }' }, # Both > 100%
|
|
356
|
-
-> { ".test { color: hwb(\x00 \x00 \x00); }" }, # Null bytes
|
|
357
|
-
|
|
358
|
-
# Oklab/Oklch chaos
|
|
359
|
-
-> { ".test { color: oklab(#{rand(999)} #{rand(999)} #{rand(999)}); }" }, # Huge Oklab values
|
|
360
|
-
-> { ".test { color: oklab(#{-rand(99)} #{-rand(99)} #{-rand(99)}); }" }, # All negative
|
|
361
|
-
-> { '.test { color: oklab(L L L); }' }, # Non-numeric
|
|
362
|
-
-> { ".test { color: oklch(#{rand(999)} #{rand(999)} #{rand(99_999)}); }" }, # Huge oklch
|
|
363
|
-
-> { ".test { color: oklch(0.5 0.2 #{-rand(999)}); }" }, # Negative hue
|
|
364
|
-
-> { ".test { color: oklab(\x00 \x00 \x00); }" }, # Null bytes in oklab
|
|
365
|
-
-> { ".test { color: oklch(1 1 #{'(' * rand(10)}360#{')' * rand(10)}); }" }, # Paren chaos
|
|
366
|
-
|
|
367
|
-
# Lab/LCH chaos
|
|
368
|
-
-> { ".test { color: lab(#{rand(999)}% #{rand(9999)} #{rand(9999)}); }" }, # Huge lab values
|
|
369
|
-
-> { ".test { color: lab(-#{rand(999)}% #{-rand(999)} #{-rand(999)}); }" }, # Negative everything
|
|
370
|
-
-> { ".test { color: lch(#{rand(999)}% #{rand(999)} #{rand(99_999)}); }" }, # Huge lch
|
|
371
|
-
-> { ".test { color: lch(50% -#{rand(999)} 0); }" }, # Negative chroma
|
|
372
|
-
-> { ".test { color: lab(\x00% \x00 \x00); }" }, # Null bytes in lab
|
|
373
|
-
|
|
374
|
-
# Alpha channel chaos (all formats)
|
|
375
|
-
-> { css.gsub(%r{/\s*[\d.]+\s*\)}, "/ #{rand(999)} )") }, # Alpha > 1 everywhere
|
|
376
|
-
-> { css.gsub(%r{/\s*[\d.]+\s*\)}, "/ -#{rand(10)} )") }, # Negative alpha everywhere
|
|
377
|
-
-> { css.gsub(%r{/\s*[\d.]+\s*\)}, '/ NaN )') }, # NaN alpha
|
|
378
|
-
-> { css.gsub(%r{/\s*[\d.]+\s*\)}, "/ \x00 )") }, # Null byte alpha
|
|
379
|
-
|
|
380
|
-
# Mixed color function chaos
|
|
381
|
-
-> { '.test { color: rgb(oklab(0.5 0 0)); }' }, # Nested color functions
|
|
382
|
-
-> { '.test { color: hsl(lab(50% 0 0)); }' }, # Wrong nesting
|
|
383
|
-
-> { '.test { color: #rgb(255,0,0); }' }, # Hash + function
|
|
384
|
-
-> { '.test { color: lab rgb hsl hwb oklab; }' }, # Function names as values
|
|
385
|
-
|
|
386
|
-
# Binary corruption
|
|
387
|
-
lambda {
|
|
388
|
-
pos = rand(css.length)
|
|
389
|
-
css.insert(pos, [0, 255, 222, 173, 190, 239].pack('C*').force_encoding('UTF-8'))
|
|
390
|
-
}, # Binary injection
|
|
391
|
-
-> { css.bytes.map { |b| rand < 0.05 ? rand(256) : b }.pack('C*').force_encoding('UTF-8') }, # Bit flips
|
|
392
|
-
lambda {
|
|
393
|
-
# Null bytes everywhere
|
|
394
|
-
pos = rand(css.length)
|
|
395
|
-
css.insert(pos, "\x00" * rand(10))
|
|
396
|
-
},
|
|
397
|
-
-> { css.gsub(/.{1,10}/) { |m| rand < 0.1 ? "\x00" : m } }, # Random null byte injection
|
|
398
|
-
|
|
399
|
-
# Pure garbage
|
|
400
|
-
-> { Array.new(rand(1000)) { rand(256).chr }.join.force_encoding('UTF-8') }, # Random bytes
|
|
401
|
-
-> { "\xFF\xFE#{css}" }, # BOM corruption
|
|
402
|
-
-> { css.tr('a-z', "\x00-\x1A") }, # Control characters
|
|
403
|
-
|
|
404
|
-
# Parenthesis hell
|
|
405
|
-
-> { css + ('(' * rand(100)) }, # Unmatched open parens
|
|
406
|
-
-> { css + (')' * rand(100)) }, # Unmatched close parens
|
|
407
|
-
-> { css.gsub('(', '((((').gsub(')', '))))') }, # Paren explosion
|
|
408
|
-
-> { css.tr('({[', '(((').tr(')}]', ')))') }, # All brackets to parens
|
|
409
|
-
-> { "((((((((#{css}))))))))" }, # Deep wrapping
|
|
410
|
-
-> { css.gsub(';', '();();();') }, # Parens in weird places
|
|
411
|
-
|
|
412
|
-
# Semicolon/colon chaos
|
|
413
|
-
-> { css.gsub(':', ':::') }, # Triple colons
|
|
414
|
-
-> { css.gsub(';', ';;;;') }, # Quadruple semicolons
|
|
415
|
-
-> { css.tr(':;', ';:') }, # Swap colons and semicolons
|
|
416
|
-
-> { css.gsub(/[;:]/, '') }, # Remove all delimiters
|
|
417
|
-
|
|
418
|
-
# Whitespace extremes
|
|
419
|
-
-> { css.gsub(/\s+/, '') }, # Remove ALL whitespace
|
|
420
|
-
-> { css.gsub(/./) { |c| c + (' ' * rand(10)) } }, # Excessive spaces
|
|
421
|
-
-> { css.gsub(/\s/, "\n\n\n\n") }, # Newline explosion
|
|
422
|
-
-> { css.gsub(/\s/, "\t\t\t") }, # Tab explosion
|
|
423
|
-
-> { ("\r\n" * rand(100)) + css }, # Windows line endings spam
|
|
424
|
-
|
|
425
|
-
# Comment corruption
|
|
426
|
-
-> { css.gsub('/*', '/*' * rand(5)) }, # Nested comment starts
|
|
427
|
-
-> { "/*#{css}" }, # Unclosed comment
|
|
428
|
-
-> { css.gsub('*/', '') }, # Remove comment ends
|
|
429
|
-
-> { css.gsub(%r{[^/]}, '/**/') }, # Comment EVERYTHING
|
|
430
|
-
|
|
431
|
-
# Backslash chaos (escape sequences)
|
|
432
|
-
-> { css.gsub(/.{1,3}/) { |m| rand < 0.2 ? "\\#{m}" : m } }, # Random escapes
|
|
433
|
-
-> { ('\\' * rand(50)) + css }, # Backslash prefix
|
|
434
|
-
-> { css.gsub(/[{};:]/, '\\\\\\\\\1') }, # Escape important chars
|
|
435
|
-
|
|
436
|
-
# Unicode chaos
|
|
437
|
-
-> { css + ("\u{FEFF}" * rand(10)) }, # Zero-width no-break space
|
|
438
|
-
-> { css + ("\u{200B}" * rand(10)) }, # Zero-width space
|
|
439
|
-
-> { css.gsub(/\w/) { |c| "#{c}́" } }, # Combining diacritics
|
|
440
|
-
|
|
441
|
-
# Length extremes
|
|
442
|
-
-> { css * rand(10) }, # Repeat entire CSS
|
|
443
|
-
-> { css[0..0] * rand(10_000) }, # Repeat first char many times
|
|
444
|
-
-> { css + ('X' * rand(100_000)) } # Massive suffix
|
|
445
|
-
]
|
|
446
|
-
|
|
447
|
-
result = mutations.sample.call
|
|
448
|
-
result = +result # Unfreeze if frozen
|
|
449
|
-
begin
|
|
450
|
-
result.force_encoding('UTF-8')
|
|
451
|
-
rescue StandardError
|
|
452
|
-
result.force_encoding('ASCII-8BIT')
|
|
453
|
-
end
|
|
454
|
-
result
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Nesting-specific mutations (applied to clean CSS only, no binary corruption)
|
|
458
|
-
def mutate_nesting(css)
|
|
459
|
-
mutations = [
|
|
460
|
-
-> { css.gsub('{', '{ & { ') }, # Add nested & blocks everywhere
|
|
461
|
-
-> { css.gsub(/}/, ' } }') }, # Add extra closing braces after nested
|
|
462
|
-
-> { css.gsub('&', '& & &') }, # Multiply ampersands
|
|
463
|
-
-> { css.delete('&').gsub('.', '&.') }, # Move & to wrong positions
|
|
464
|
-
-> { css.gsub('{', '{ .nested { ') }, # Add implicit nesting everywhere
|
|
465
|
-
-> { css + (' { & .child { color: red; }' * rand(10)) }, # Add unclosed nested blocks
|
|
466
|
-
-> { ".wrapper { #{css} }" }, # Wrap entire CSS in nested block
|
|
467
|
-
-> { css.gsub(/@media/, '@media (garbage) { @media') }, # Corrupt nested @media
|
|
468
|
-
-> { css.gsub('&', '& & & & &') }, # Chain ampersands
|
|
469
|
-
-> { css.gsub(/\.[\w-]+/) { |m| "#{m} { & #{m} { " } + (' }' * css.scan(/\.[\w-]+/).size) }, # Nest all class selectors
|
|
470
|
-
-> { css.gsub(/\{[^{}]*\}/) { |m| "{ & #{m} }" } }, # Wrap blocks in & nesting
|
|
471
|
-
-> { css.gsub(';', '; & .x { color: red; } ') }, # Insert nested rules after semicolons
|
|
472
|
-
-> { ".a { .b { .c { .d { .e { #{css} } } } } }" }, # Deep wrapper nesting
|
|
473
|
-
-> { css.gsub('{', '{ /* & */ { ') } # Comment out nesting markers
|
|
474
|
-
]
|
|
475
|
-
|
|
476
|
-
mutations.sample.call
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# Stats tracking
|
|
480
|
-
stats = {
|
|
481
|
-
total: 0,
|
|
482
|
-
parsed: 0,
|
|
483
|
-
merge_tested: 0,
|
|
484
|
-
to_s_tested: 0,
|
|
485
|
-
color_converted: 0,
|
|
486
|
-
depth_errors: 0,
|
|
487
|
-
size_errors: 0,
|
|
488
|
-
other_errors: 0,
|
|
489
|
-
crashes: 0
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
# Configure timeout based on GC.stress mode
|
|
493
|
-
WORKER_TIMEOUT = ENV['FUZZ_GC_STRESS'] == '1' ? 300 : 10 # 5 minutes for GC.stress, 10 seconds normal
|
|
494
|
-
|
|
495
|
-
puts "Starting CSS parser fuzzer (#{ITERATIONS} iterations)..."
|
|
496
|
-
puts "RNG seed: #{RNG_SEED} (use this to reproduce crashes)"
|
|
497
|
-
puts "Clean corpus: #{CLEAN_CORPUS.length} samples (for mutations)"
|
|
498
|
-
puts "Full corpus: #{CORPUS.length} samples (direct testing)"
|
|
499
|
-
puts 'Strategy: 70% mutations, 15% nesting, 10% direct, 5% garbage'
|
|
500
|
-
puts "GC.stress: ENABLED (expect 100-1000x slowdown, #{WORKER_TIMEOUT}s timeout)" if ENV['FUZZ_GC_STRESS'] == '1'
|
|
501
|
-
puts ''
|
|
502
|
-
|
|
503
|
-
# Spawn a worker subprocess
|
|
504
|
-
def spawn_worker
|
|
505
|
-
# Pass environment explicitly to ensure FUZZ_GC_STRESS is inherited
|
|
506
|
-
env = ENV.to_h
|
|
507
|
-
worker_path = File.join(__dir__, 'worker.rb')
|
|
508
|
-
Open3.popen3(env, RbConfig.ruby, '-Ilib', worker_path)
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Send input to worker and check result
|
|
512
|
-
# Returns: [:success | :error | :crash, error_message, crashed_input, stderr_output]
|
|
513
|
-
def parse_in_worker(stdin, stdout, stderr, wait_thr, input, last_input)
|
|
514
|
-
# Check if worker is still alive BEFORE writing
|
|
515
|
-
unless wait_thr.alive?
|
|
516
|
-
status = wait_thr.value
|
|
517
|
-
signal = status.termsig
|
|
518
|
-
# Worker died on PREVIOUS input, not this one - collect stderr
|
|
519
|
-
stderr_output = begin
|
|
520
|
-
stderr.read_nonblock(100_000)
|
|
521
|
-
rescue StandardError
|
|
522
|
-
''
|
|
523
|
-
end
|
|
524
|
-
error_msg = signal ? "Signal #{signal} (#{Signal.signame(signal)})" : "Exit code #{status.exitstatus}"
|
|
525
|
-
return [:crash, error_msg, last_input, stderr_output, false, false, false]
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# Send length-prefixed input (non-blocking to handle large inputs)
|
|
529
|
-
# Force binary encoding to avoid encoding conflicts
|
|
530
|
-
data = [input.bytesize].pack('N') + input.b
|
|
531
|
-
total_written = 0
|
|
532
|
-
retries = 0
|
|
533
|
-
max_retries = 100
|
|
534
|
-
|
|
535
|
-
while total_written < data.bytesize
|
|
536
|
-
begin
|
|
537
|
-
written = stdin.write_nonblock(data[total_written..])
|
|
538
|
-
total_written += written
|
|
539
|
-
retries = 0 # Reset on successful write
|
|
540
|
-
rescue IO::WaitWritable
|
|
541
|
-
# Pipe buffer full - wait for reader to consume some data
|
|
542
|
-
retries += 1
|
|
543
|
-
if retries > max_retries
|
|
544
|
-
# Worker hung - treat as crash
|
|
545
|
-
return [:crash, 'Worker hung (pipe blocked)', input, '', false, false, false]
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
stdin.wait_writable(0.1) # 100ms timeout
|
|
549
|
-
retry
|
|
550
|
-
end
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
stdin.flush
|
|
554
|
-
|
|
555
|
-
# Wait for response with timeout
|
|
556
|
-
ready = stdout.wait_readable(WORKER_TIMEOUT)
|
|
557
|
-
|
|
558
|
-
if ready.nil?
|
|
559
|
-
# Timeout - worker hung, kill it
|
|
560
|
-
begin
|
|
561
|
-
Process.kill('KILL', wait_thr.pid)
|
|
562
|
-
rescue StandardError
|
|
563
|
-
nil
|
|
564
|
-
end
|
|
565
|
-
stderr_output = begin
|
|
566
|
-
stderr.read_nonblock(100_000)
|
|
567
|
-
rescue StandardError
|
|
568
|
-
''
|
|
569
|
-
end
|
|
570
|
-
[:crash, 'Timeout (infinite loop?)', input, stderr_output, false, false, false]
|
|
571
|
-
elsif !wait_thr.alive?
|
|
572
|
-
# Worker crashed DURING this input
|
|
573
|
-
status = wait_thr.value
|
|
574
|
-
signal = status.termsig
|
|
575
|
-
stderr_output = begin
|
|
576
|
-
stderr.read_nonblock(100_000)
|
|
577
|
-
rescue StandardError
|
|
578
|
-
''
|
|
579
|
-
end
|
|
580
|
-
error_msg = signal ? "Signal #{signal} (#{Signal.signame(signal)})" : "Exit code #{status.exitstatus}"
|
|
581
|
-
[:crash, error_msg, input, stderr_output, false, false, false]
|
|
582
|
-
else
|
|
583
|
-
# Read response
|
|
584
|
-
response = stdout.gets
|
|
585
|
-
return [:error, nil, nil, nil, false, false, false] if response.nil?
|
|
586
|
-
|
|
587
|
-
response = response.force_encoding('UTF-8').scrub.strip
|
|
588
|
-
|
|
589
|
-
case response
|
|
590
|
-
when /^PARSE/
|
|
591
|
-
# Extract which operations were tested
|
|
592
|
-
merge_tested = response.include?('+MERGE')
|
|
593
|
-
to_s_tested = response.include?('+TOS')
|
|
594
|
-
color_converted = response.include?('+COLOR')
|
|
595
|
-
[:success, nil, nil, nil, merge_tested, to_s_tested, color_converted]
|
|
596
|
-
when 'DEPTH'
|
|
597
|
-
[:depth_error, nil, nil, nil, false, false, false]
|
|
598
|
-
when 'SIZE'
|
|
599
|
-
[:size_error, nil, nil, nil, false, false, false]
|
|
600
|
-
else
|
|
601
|
-
[:error, nil, nil, nil, false, false, false]
|
|
602
|
-
end
|
|
603
|
-
end
|
|
604
|
-
rescue Errno::EPIPE, IOError
|
|
605
|
-
# Pipe broken - worker already dead (check if it died on previous input)
|
|
606
|
-
if wait_thr.alive?
|
|
607
|
-
[:crash, 'Broken pipe', input, '', false, false, false]
|
|
608
|
-
else
|
|
609
|
-
status = wait_thr.value
|
|
610
|
-
signal = status.termsig
|
|
611
|
-
stderr_output = begin
|
|
612
|
-
stderr.read_nonblock(100_000)
|
|
613
|
-
rescue StandardError
|
|
614
|
-
''
|
|
615
|
-
end
|
|
616
|
-
error_msg = signal ? "Signal #{signal} (#{Signal.signame(signal)})" : "Exit code #{status.exitstatus}"
|
|
617
|
-
[:crash, error_msg, last_input, stderr_output, false, false, false]
|
|
618
|
-
end
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
start_time = Time.now
|
|
622
|
-
crash_file = File.join(__dir__, 'fuzz_last_input.css')
|
|
623
|
-
|
|
624
|
-
# Track last N inputs for debugging freezes
|
|
625
|
-
RECENT_INPUTS = [] # rubocop:disable Style/MutableConstant
|
|
626
|
-
MAX_RECENT = 20
|
|
627
|
-
|
|
628
|
-
# Trap Ctrl+C to dump recent inputs
|
|
629
|
-
Signal.trap('INT') do
|
|
630
|
-
puts "\n\nInterrupted! Dumping last #{RECENT_INPUTS.length} inputs..."
|
|
631
|
-
RECENT_INPUTS.each_with_index do |input, i|
|
|
632
|
-
filename = File.join(__dir__, "fuzz_recent_#{i}.css")
|
|
633
|
-
File.binwrite(filename, input)
|
|
634
|
-
puts " #{i}: #{filename} (#{input.bytesize} bytes)"
|
|
635
|
-
end
|
|
636
|
-
exit 1
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
# Spawn initial worker subprocess
|
|
640
|
-
stdin, stdout, stderr, wait_thr = spawn_worker
|
|
641
|
-
last_input = nil
|
|
642
|
-
|
|
643
|
-
ITERATIONS.times do |i|
|
|
644
|
-
# Pick a seed and mutate it, or generate pure garbage occasionally
|
|
645
|
-
r = rand
|
|
646
|
-
input = if r < 0.70
|
|
647
|
-
# Normal mutations on clean CSS (70%) - uses regex so needs valid UTF-8
|
|
648
|
-
mutate(CLEAN_CORPUS.sample)
|
|
649
|
-
elsif r < 0.85
|
|
650
|
-
# Nesting-specific mutations on clean CSS (15%)
|
|
651
|
-
mutate_nesting(CLEAN_CORPUS.sample)
|
|
652
|
-
elsif r < 0.95
|
|
653
|
-
# Direct CORPUS samples without mutation (10%) - includes all samples
|
|
654
|
-
CORPUS.sample
|
|
655
|
-
else
|
|
656
|
-
# Pure garbage (5%)
|
|
657
|
-
Array.new(rand(1000)) { rand(256).chr }.join
|
|
658
|
-
end
|
|
659
|
-
|
|
660
|
-
stats[:total] += 1
|
|
661
|
-
|
|
662
|
-
# Track recent inputs for debugging
|
|
663
|
-
RECENT_INPUTS << input
|
|
664
|
-
RECENT_INPUTS.shift if RECENT_INPUTS.length > MAX_RECENT
|
|
665
|
-
|
|
666
|
-
# Send to worker subprocess
|
|
667
|
-
result, error, crashed_input, stderr_output, merge_tested, to_s_tested, color_converted = parse_in_worker(stdin, stdout, stderr,
|
|
668
|
-
wait_thr, input, last_input)
|
|
669
|
-
last_input = input
|
|
670
|
-
|
|
671
|
-
case result
|
|
672
|
-
when :success
|
|
673
|
-
stats[:parsed] += 1
|
|
674
|
-
stats[:merge_tested] += 1 if merge_tested
|
|
675
|
-
stats[:to_s_tested] += 1 if to_s_tested
|
|
676
|
-
stats[:color_converted] += 1 if color_converted
|
|
677
|
-
when :parse_error
|
|
678
|
-
stats[:parse_errors] += 1
|
|
679
|
-
when :depth_error
|
|
680
|
-
stats[:depth_errors] += 1
|
|
681
|
-
when :size_error
|
|
682
|
-
stats[:size_errors] += 1
|
|
683
|
-
when :error
|
|
684
|
-
stats[:other_errors] += 1
|
|
685
|
-
when :crash
|
|
686
|
-
stats[:crashes] += 1
|
|
687
|
-
|
|
688
|
-
# Use the actual crashed input (might be previous input if worker died between calls)
|
|
689
|
-
actual_crash = crashed_input || input
|
|
690
|
-
|
|
691
|
-
# Save crash files
|
|
692
|
-
crash_save = File.join(__dir__, "fuzz_crash_#{Time.now.to_i}.css")
|
|
693
|
-
crash_log = crash_save.sub(/\.css$/, '.log')
|
|
694
|
-
|
|
695
|
-
File.binwrite(crash_save, actual_crash)
|
|
696
|
-
File.binwrite(crash_file, actual_crash) # Also save as last input for easy debugging
|
|
697
|
-
|
|
698
|
-
# Determine if this is a real crash (SEGV) or just broken pipe (worker disappeared)
|
|
699
|
-
is_real_crash = stderr_output && !stderr_output.empty?
|
|
700
|
-
|
|
701
|
-
# Save stderr output (stack trace, etc.)
|
|
702
|
-
File.write(crash_log, stderr_output) if is_real_crash
|
|
703
|
-
|
|
704
|
-
# Print crash to stderr so it doesn't get overwritten by progress line
|
|
705
|
-
if is_real_crash
|
|
706
|
-
warn "\n!!! CRASH FOUND (SEGV) !!!"
|
|
707
|
-
warn "Saved crashing input to: #{crash_save}"
|
|
708
|
-
warn "Saved crash output to: #{crash_log}"
|
|
709
|
-
else
|
|
710
|
-
warn "\n!!! WORKER DIED (#{error}) !!!"
|
|
711
|
-
warn "Saved input to: #{crash_save}"
|
|
712
|
-
warn 'Note: No crash dump (worker may have been OOM-killed or died on previous input)'
|
|
713
|
-
end
|
|
714
|
-
warn "Reproduce with: ruby scripts/fuzz_css_parser.rb #{ITERATIONS} #{RNG_SEED}"
|
|
715
|
-
warn "Input size: #{actual_crash.length} bytes"
|
|
716
|
-
warn "Input preview: #{actual_crash.inspect[0..200]}"
|
|
717
|
-
warn "Error: #{error}" if is_real_crash
|
|
718
|
-
if crashed_input != input && crashed_input
|
|
719
|
-
warn 'Note: Crash detected on PREVIOUS input (worker died before processing current input)'
|
|
720
|
-
end
|
|
721
|
-
warn ''
|
|
722
|
-
|
|
723
|
-
# Respawn worker to continue fuzzing
|
|
724
|
-
begin
|
|
725
|
-
stdin.close
|
|
726
|
-
rescue StandardError
|
|
727
|
-
nil
|
|
728
|
-
end
|
|
729
|
-
begin
|
|
730
|
-
stdout.close
|
|
731
|
-
rescue StandardError
|
|
732
|
-
nil
|
|
733
|
-
end
|
|
734
|
-
begin
|
|
735
|
-
stderr.close
|
|
736
|
-
rescue StandardError
|
|
737
|
-
nil
|
|
738
|
-
end
|
|
739
|
-
stdin, stdout, stderr, wait_thr = spawn_worker
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
# Progress
|
|
743
|
-
next unless ((i + 1) % 1000).zero?
|
|
744
|
-
|
|
745
|
-
elapsed = Time.now - start_time
|
|
746
|
-
rate = (i + 1) / elapsed
|
|
747
|
-
|
|
748
|
-
# Get worker memory usage (cross-platform)
|
|
749
|
-
rss_mb = begin
|
|
750
|
-
if File.exist?("/proc/#{wait_thr.pid}/status")
|
|
751
|
-
# Linux: read from /proc filesystem
|
|
752
|
-
status = File.read("/proc/#{wait_thr.pid}/status")
|
|
753
|
-
if status =~ /VmRSS:\s+(\d+)\s+kB/
|
|
754
|
-
Regexp.last_match(1).to_i / 1024.0
|
|
755
|
-
else
|
|
756
|
-
0.0
|
|
757
|
-
end
|
|
758
|
-
else
|
|
759
|
-
# macOS/BSD: use ps command
|
|
760
|
-
rss_kb = `ps -o rss= -p #{wait_thr.pid}`.strip.to_i
|
|
761
|
-
rss_kb / 1024.0
|
|
762
|
-
end
|
|
763
|
-
rescue StandardError
|
|
764
|
-
0.0
|
|
765
|
-
end
|
|
766
|
-
|
|
767
|
-
progress = "#{(i + 1).to_s.rjust(6)}/#{ITERATIONS}"
|
|
768
|
-
iter_rate = "(#{rate.round(1).to_s.rjust(6)} iter/sec)"
|
|
769
|
-
parsed = "Parsed: #{stats[:parsed].to_s.rjust(5)}"
|
|
770
|
-
merged = "Merged: #{stats[:merge_tested].to_s.rjust(5)}"
|
|
771
|
-
to_s = "ToS: #{stats[:to_s_tested].to_s.rjust(4)}"
|
|
772
|
-
color = "Color: #{stats[:color_converted].to_s.rjust(4)}"
|
|
773
|
-
parse_err = "Err: #{stats[:parse_errors].to_s.rjust(4)}"
|
|
774
|
-
crashes = "Crash: #{stats[:crashes].to_s.rjust(2)}"
|
|
775
|
-
memory = "Mem: #{rss_mb.round(1).to_s.rjust(6)} MB"
|
|
776
|
-
|
|
777
|
-
# Use \r to overwrite the same line
|
|
778
|
-
print "\rProgress: #{progress} #{iter_rate} | #{parsed} | #{merged} | #{to_s} | #{color} | #{parse_err} | #{crashes} | #{memory}"
|
|
779
|
-
$stdout.flush
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
# Print newline after final progress update
|
|
783
|
-
puts ''
|
|
784
|
-
|
|
785
|
-
# Clean up worker subprocess
|
|
786
|
-
begin
|
|
787
|
-
stdin.close
|
|
788
|
-
rescue StandardError
|
|
789
|
-
nil
|
|
790
|
-
end
|
|
791
|
-
begin
|
|
792
|
-
stdout.close
|
|
793
|
-
rescue StandardError
|
|
794
|
-
nil
|
|
795
|
-
end
|
|
796
|
-
begin
|
|
797
|
-
stderr.close
|
|
798
|
-
rescue StandardError
|
|
799
|
-
nil
|
|
800
|
-
end
|
|
801
|
-
begin
|
|
802
|
-
Process.kill('TERM', wait_thr.pid)
|
|
803
|
-
rescue StandardError
|
|
804
|
-
nil
|
|
805
|
-
end
|
|
806
|
-
begin
|
|
807
|
-
wait_thr.join
|
|
808
|
-
rescue StandardError
|
|
809
|
-
nil
|
|
810
|
-
end
|
|
811
|
-
|
|
812
|
-
elapsed = Time.now - start_time
|
|
813
|
-
|
|
814
|
-
puts "\n#{'=' * 60}"
|
|
815
|
-
puts 'Fuzzing complete!'
|
|
816
|
-
puts "Time: #{elapsed.round(2)}s (#{(stats[:total] / elapsed).round(1)} iter/sec)"
|
|
817
|
-
puts "Total: #{stats[:total]}"
|
|
818
|
-
puts "Parsed: #{stats[:parsed]} (#{(stats[:parsed] * 100.0 / stats[:total]).round(1)}%)"
|
|
819
|
-
puts "Merge tested: #{stats[:merge_tested]} (#{(stats[:merge_tested] * 100.0 / stats[:total]).round(1)}%)"
|
|
820
|
-
puts "ToS tested: #{stats[:to_s_tested]} (#{(stats[:to_s_tested] * 100.0 / stats[:total]).round(1)}%)"
|
|
821
|
-
puts "Color converted: #{stats[:color_converted]} (#{(stats[:color_converted] * 100.0 / stats[:total]).round(1)}%)"
|
|
822
|
-
puts "Depth Errors: #{stats[:depth_errors]} (#{(stats[:depth_errors] * 100.0 / stats[:total]).round(1)}%)"
|
|
823
|
-
puts "Size Errors: #{stats[:size_errors]} (#{(stats[:size_errors] * 100.0 / stats[:total]).round(1)}%)"
|
|
824
|
-
puts "Other Errors: #{stats[:other_errors]} (#{(stats[:other_errors] * 100.0 / stats[:total]).round(1)}%)"
|
|
825
|
-
puts "Crashes: #{stats[:crashes]}"
|
|
826
|
-
puts '=' * 60
|
|
827
|
-
|
|
828
|
-
exit(stats[:crashes].positive? ? 1 : 0)
|