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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +27 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +62 -0
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +60 -139
  7. data/CHANGELOG.md +14 -0
  8. data/README.md +30 -2
  9. data/Rakefile +49 -22
  10. data/cataract.gemspec +4 -1
  11. data/ext/cataract/cataract.c +47 -47
  12. data/ext/cataract/css_parser.c +17 -33
  13. data/ext/cataract/merge.c +58 -2
  14. data/lib/cataract/at_rule.rb +8 -9
  15. data/lib/cataract/declaration.rb +18 -0
  16. data/lib/cataract/import_resolver.rb +3 -4
  17. data/lib/cataract/pure/byte_constants.rb +69 -0
  18. data/lib/cataract/pure/helpers.rb +35 -0
  19. data/lib/cataract/pure/imports.rb +255 -0
  20. data/lib/cataract/pure/merge.rb +1146 -0
  21. data/lib/cataract/pure/parser.rb +1236 -0
  22. data/lib/cataract/pure/serializer.rb +590 -0
  23. data/lib/cataract/pure/specificity.rb +206 -0
  24. data/lib/cataract/pure.rb +130 -0
  25. data/lib/cataract/rule.rb +22 -13
  26. data/lib/cataract/stylesheet.rb +14 -9
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +18 -5
  29. metadata +12 -25
  30. data/benchmarks/benchmark_harness.rb +0 -193
  31. data/benchmarks/benchmark_merging.rb +0 -121
  32. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  33. data/benchmarks/benchmark_parsing.rb +0 -153
  34. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  35. data/benchmarks/benchmark_runner.rb +0 -70
  36. data/benchmarks/benchmark_serialization.rb +0 -180
  37. data/benchmarks/benchmark_shorthand.rb +0 -109
  38. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  39. data/benchmarks/benchmark_specificity.rb +0 -124
  40. data/benchmarks/benchmark_string_allocation.rb +0 -151
  41. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  42. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  43. data/benchmarks/benchmark_value_splitter.rb +0 -54
  44. data/benchmarks/benchmark_yjit.rb +0 -158
  45. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  46. data/benchmarks/profile_to_s.rb +0 -23
  47. data/benchmarks/speedup_calculator.rb +0 -83
  48. data/benchmarks/system_metadata.rb +0 -81
  49. data/benchmarks/templates/benchmarks.md.erb +0 -221
  50. data/benchmarks/yjit_tests.rb +0 -141
  51. data/scripts/fuzzer/run.rb +0 -828
  52. data/scripts/fuzzer/worker.rb +0 -99
  53. data/scripts/generate_benchmarks_md.rb +0 -155
@@ -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)