Skip to content

css: reject out-of-range percentages in color-mix() - #33953

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/farm/e52b65e1/color-mix-percent-range
Jul 11, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
claude/farm/e52b65e1/color-mix-percent-range

Conversation

@robobun

@robobun robobun commented Jul 11, 2026 •

Copy link
Copy Markdown
Collaborator

What

parse_color_mix accepted mix percentages outside [0%, 100%]. With a negative percentage the interpolated HSL saturation goes negative and trips a debug_assert!(saturation >= 0.0 && saturation <= 1.0) in hsl_to_rgb. Release builds skip the assert and emit garbage colors.

Repro

bun -e 'Bun.color("color-mix(in hsl,red -9%,color(display-p3 0 0 0)", "lab")'

Debug build panics:

panic: assertion failed: saturation >= 0.0 && saturation <= 1.0

Release build returns a bogus value instead of null:

Bun.color("color-mix(in srgb, red -9%, blue)", "css")  // "#0043e8" (should be null)
Bun.color("color-mix(in hsl, red 150%, blue)", "css")  // "#ff0"    (should be null)

Fix

Per css-color-5 §3 the grammar is [ <color> && <percentage [0,100]>? ]; values outside that range are parse errors. WPT color-invalid-color-mix-function.html tests exactly this ("Percentages less than 0 are not valid", "Percentages greater than 100 are not valid") and WebKit enforces it via CSS::Percentage<Range{0, 100}>.

Added a range check on each parsed percentage in parse_color_mix before normalization.

Tests

Added to test/js/bun/css/color.test.ts:

  • subprocess test for the fuzz repro (asserts null output)
  • out-of-range percentages (negative, >100%, before/after the color, both operands, across color spaces) return null
  • boundary values 0% / 100% still parse

Found by fuzzing Bun.color().


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 12 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/color.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5acf29404)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [3.75ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [1.51ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.26ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [2.17ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [17.03ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [2.00ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [2.01ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.42ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (64edcaf68)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [0.08ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [0.02ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [0.01ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [0.03ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [0.55ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [0.03ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [0.01ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit"))
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256"))
�[92m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-16"))
(pass) color({"r":0,"g":255,"b":0}, "{rgb}") = {"r":0,"g":255,"b":0}
(pass) color({"r":0,"g":255,"b":0}, "ansi-24bit")
(pass) color({"r":0,"g":255,"b":0}, "ansi-16")
(pass) color({"r":0,"g":255,"b":0}, "ansi256")
�[38;2;0;0;255m[object Object]
(pass) console.log(color({"r":0,"g":0,"b":255}, "ansi-24bit"))
�[38;5;
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/color.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5acf29404)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [4.00ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [1.74ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.29ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [2.20ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [16.69ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [2.26ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [2.02ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.33ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 733ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_css v0.0.0 (/workspace/bun/src/css)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standal
... (truncated)
diff hotspot
src/css/values/color.rs       |  7 +++++++
 test/js/bun/css/color.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 53 insertions(+)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                           reads  edits  tests
src/css/values/color.rs            4      1      0
test/js/bun/css/color.test.ts      5      2      0

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6ff7f527-69c1-4fc2-9c8b-be8929183c0b

📥 Commits

Reviewing files that changed from the base of the PR and between aaaa53f and b62c9d1.

📒 Files selected for processing (2)
  • src/css/values/color.rs
  • test/js/bun/css/color.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Jul 10th, 2026

@robobun, your commit 5acf294 is building: #71815

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward spec-mandated range check on color-mix() percentages.

What was reviewed:

  • Verified expect_percentage() returns a unit fraction, so the 0.0..=1.0 range matches the spec's <percentage [0,100]>.
  • Checked placement: validation runs before normalization and leaves the existing p1 + p2 == 0.0 check intact; boundary values 0%/100% are covered by tests.
  • Test matrix covers both operands, percent-before/after-color, multiple color spaces, and the fuzz repro in a subprocess.
Extended reasoning...

Overview

Adds a 6-line range check in parse_color_mix (src/css/values/color.rs) rejecting mix percentages outside [0%, 100%], plus a test block in test/js/bun/css/color.test.ts covering the fuzz repro, invalid inputs across color spaces/operand positions, and boundary acceptance.

Security risks

None. This is CSS color parsing with no I/O, auth, or memory-management surface; the change only tightens input validation to reject previously-accepted garbage per spec.

Level of scrutiny

Low. The fix is a pure input-validation guard with a clear spec citation (css-color-5 §3, WPT color-invalid-color-mix-function.html, WebKit's CSS::Percentage<Range{0,100}>). I confirmed expect_percentage() returns unit_value (a fraction), so !(0.0..=1.0).contains(&p) is the correct bound. The check sits before normalization, so the existing p1 + p2 == 0.0 invalid-value path and the unwrap_or_else(|| 1.0 - other) derivation are unaffected for in-range inputs.

Other factors

Tests are thorough for the size of the change: negative, >100%, just-over-boundary (100.001%), percent before and after the color, both operands, five interpolation spaces, plus explicit 0%/100%/50% acceptance so the guard isn't over-tight. The subprocess crash repro drains stdout/stderr/exited concurrently. A finder flagged the stderr: "" equality in that subprocess test; verifiers ruled it out (bunEnv quiets debug logs and the -e expression is trivial). No prior reviews or outstanding comments.

The color-mix() grammar is <percentage [0,100]>; values outside that range
are parse errors. parse_color_mix accepted them anyway, which could drive
HSL saturation negative and trip a debug assertion in hsl_to_rgb. Release
builds skipped the assert and emitted garbage colors.

Found by fuzzing Bun.color().
@robobun
robobun force-pushed the claude/farm/e52b65e1/color-mix-percent-range branch from b62c9d1 to 64edcaf Compare July 11, 2026 03:19

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the inline nit about stderr: "" is non-blocking.

Extended reasoning...

Overview

7 lines added to parse_color_mix in src/css/values/color.rs to reject mix percentages outside [0%, 100%] before normalization, plus ~46 lines of tests in test/js/bun/css/color.test.ts. The fix is a straightforward range check using is_some_and(|p| !(0.0..=1.0).contains(&p)) on the two optional parsed percentages, returning the same ParserError::invalid_value already used a few lines below for the both-zero case.

Security risks

None. This is input validation on a CSS color string that tightens what is accepted (rejects more, accepts nothing new). No allocation, no unsafe, no FFI, no user-controllable size arithmetic.

Level of scrutiny

Low. The change is small, self-contained, and spec-cited (css-color-5 §3 grammar <percentage [0,100]>, WPT color-invalid-color-mix-function.html, WebKit CSS::Percentage<Range{0,100}>). It sits in the CSS color parser used by Bun.color() and the bundler's CSS pipeline — not a hot path, not memory-safety-sensitive, and the failure mode of a bug here is Bun.color() returning null for a valid input, which the boundary tests (0%, 100%, 50%/50%) guard against.

Other factors

  • The check is placed correctly: after both percentages are parsed (so the None / omitted case passes through via is_some_and), before normalization (so the raw grammar constraint is enforced, not the derived weight). NaN would also be rejected since RangeInclusive::contains returns false for NaN — harmless here.
  • Error construction matches the existing file-local pattern (input.new_custom_error(css::ParserError::invalid_value)) used for the p1 + p2 == 0.0 case just below.
  • Test coverage is thorough: subprocess repro of the original fuzz crash, negative and >100% on both operands in both prefix/postfix positions across five color spaces, plus boundary acceptance tests confirming 0% and 100% still parse. The PR evidence shows the tests fail on main (debug panics, release returns garbage) and pass with the fix on both ASAN debug and release.
  • The one finding (asserting stderr: "" in the subprocess test) is a test-robustness nit against future ASAN noise, not a correctness issue; the ASAN lane already passed this run.

Comment thread test/js/bun/css/color.test.ts Outdated
Match the file-local convention (autoAnsi helper): include stderr in the
failure diff without requiring it to be empty on success.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the stderr nit from the earlier pass is addressed in 5acf294.

What was reviewed:

  • Range check placement in parse_color_mix: runs after both percentages are parsed and before normalization; is_some_and leaves the omitted-percentage path untouched, and (0.0..=1.0).contains correctly rejects NaN as well.
  • Boundary behavior: 0% and 100% remain accepted (inclusive range), matching the css-color-5 grammar and the WPT invalid-color-mix cases cited.
  • Tests: reject matrix covers negative / >100% / before-and-after-color / both operands / multiple color spaces; subprocess repro now follows the file-local autoAnsi stderr convention.
Extended reasoning...

Overview

Two files touched. src/css/values/color.rs gains a 4-line guard in parse_color_mix that rejects mix percentages outside [0.0, 1.0] before the normalization step, returning ParserError::invalid_value — the same error already used a few lines below for the p1 + p2 == 0 case. test/js/bun/css/color.test.ts gains a describe block with a subprocess crash-repro test, an 11-case reject matrix, and a 5-case accept matrix for boundary values.

Security risks

None. This is input validation on a CSS color string in a pure parsing path — no I/O, no allocation driven by the untrusted value, no auth or crypto surface. The change strictly tightens accepted input to match the spec grammar, which if anything reduces the reachable state space (previously a negative percentage could drive interpolation into a debug-assert panic).

Level of scrutiny

Low-to-moderate. The Rust change is a single self-contained conditional using an established error-return pattern already present in the same function. It is spec-cited (css-color-5 §3, WPT color-invalid-color-mix-function.html, WebKit's CSS::Percentage<Range{0,100}>). is_some_and correctly skips the check when a percentage was omitted, so the 1.0 - other inference path is unaffected. RangeInclusive::contains on f32 is false for NaN, so a hypothetical NaN percentage would also be rejected rather than passed to interpolation.

Other factors

The PR evidence shows the new tests fail on both debug (assertion panic) and release (garbage output) builds without the fix, and pass on both with it — satisfying the fails-for-the-right-reason requirement. The one review comment I left previously (asserting empty stderr in the subprocess test) was applied in 5acf294 and the thread is resolved; the current diff matches the file's existing autoAnsi convention. No CODEOWNERS on this path, no outstanding human review comments, and the bug-hunting system found no issues.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: the only hard failure on builds 71745 and 71815 is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on x64-asan (ASSERTION FAILED: !scope.exception() in JSC property access during worker termination). That test is unrelated to this change and is being addressed separately. color.test.ts passes on every lane that has run.

@Jarred-Sumner
Jarred-Sumner merged commit bad2927 into main Jul 11, 2026
75 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/e52b65e1/color-mix-percent-range branch July 11, 2026 04:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants