Skip to content

Bun.Image: throw ERR_OUT_OF_RANGE for out-of-range encoder options - #40520

Open
robobun wants to merge 1 commit into
farm/be6b582d/image-option-validationfrom
farm/6049a00e/image-option-range
Open

robobun wants to merge 1 commit into
farm/be6b582d/image-option-validationfrom
farm/6049a00e/image-option-range

Conversation

@robobun

@robobun robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #40491. That PR makes a wrong-type option value throw. This one makes an out-of-range value throw too. The diff shown here is only the range check.

Problem

Fix

  • Add get_int_option. It reads the property and runs it through JSGlobalObject::validate_integer_range with the documented range: quality 1-100, compressionLevel 0-9, colors 2-256. A value outside the range throws a RangeError with code: "ERR_OUT_OF_RANGE", for example The value of "quality" is out of range. It must be >= 1 and <= 100. Received 999. NaN and fractions throw ERR_INVALID_ARG_TYPE with type integer.
  • This matches how the other Bun APIs treat a documented integer range: Bun.Archive level (1-12), Bun.password bcrypt cost (4-31) and Response compress.level all throw on an out-of-range value. None of them clamp.
  • resize, rotate, maxPixels and modulate keep their existing clamps. Those values are sizes and multipliers without a documented range, and the clamp there is a guard, not an option parse.
  • Verified: test/js/bun/image/image.test.ts (new test, fails on released bun, passes with this change). The full file passes: 97 pass, 2 skip.

Background

  • Bun.Image records encode options synchronously in .jpeg() / .png() / .webp() and does the work later in a terminal such as .bytes(). So the error surfaces at the format call, before any decode.
  • validate_integer_range is the Rust port of Node's validateInteger. It throws ERR_INVALID_ARG_TYPE for a non-number or a fraction and ERR_OUT_OF_RANGE for a value outside min..=max. It maps NaN to the default, so get_int_option rejects NaN first, the same way Bun.build does for bytecodeDepth.
  • Unknown keys (the qualiy typo in the report) stay ignored. No Bun API rejects unknown option keys, and the TypeScript types catch the typo at compile time.
Notes

Measured on released bun 1.4.1 with a 256x256 noise PNG, .jpeg(o).bytes().byteLength: {} 40743, { quality: 100 } 123985, { quality: 999 } 123985 (clamped to 100), { quality: -5 } 3343, { quality: 0 } 3343, { quality: NaN } 3343 (all clamped to 1), { quality: 80.5 } 40743, { quality: "84" } 40743 (ignored). .png({ compressionLevel: 10 }) and .png({ palette: true, colors: 1000 }) and colors: 1 were accepted.

With this change each of those throws: quality: 999, -5, 0, compressionLevel: 10, colors: 1000, colors: 1 throw ERR_OUT_OF_RANGE. quality: NaN and 80.5 throw ERR_INVALID_ARG_TYPE (must be of type integer). quality: "84" throws ERR_INVALID_ARG_TYPE (must be of type number, from #40491).

The existing test non-finite / huge numeric inputs are clamped by coerceInt asserted that .jpeg({ quality: NaN }) does not throw. That assertion is removed. The resize(NaN, NaN) and maxPixels: Infinity assertions in that test are unchanged.

The docs (docs/runtime/image.mdx) now state the ranges and the two error codes. The type declarations already document the ranges.

Suites run: bun bd test test/js/bun/image/image.test.ts (97 pass, 2 skip, 0 fail). cargo fmt --check, cargo clippy -p bun_runtime (no new warnings), prettier check on the test and docs.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/image/image.test.ts

quality, compressionLevel and colors used to clamp an out-of-range value
to the nearest bound and map NaN to the low bound, with no error. They
now go through validate_integer_range with the documented ranges
(1-100, 0-9, 2-256). A wrong type throws ERR_INVALID_ARG_TYPE, NaN or a
fraction throws ERR_INVALID_ARG_TYPE with "integer", and a value outside
the range throws ERR_OUT_OF_RANGE.
@robobun

robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on released bun 1.4.1 with a 256x256 noise PNG: .jpeg({ quality: 999 }) produced the same 123985 bytes as { quality: 100 }, and { quality: -5 }, { quality: 0 } and { quality: NaN } produced the same 3343 bytes as { quality: 1 }. .png({ compressionLevel: 10 }) and .png({ palette: true, colors: 1000 }) were accepted. Nothing threw.

With this change each of those throws at the format call. The new test in test/js/bun/image/image.test.ts fails on released bun and passes with the debug build. The full file passes (97 pass, 2 skip).

This PR is stacked on #40491 (the wrong-type check). GitHub retargets it to main once that PR merges.

CI: image.test.ts passed on every lane. The two red tests do not touch this diff. test-http-should-accept-custom-certs-when-provided.ts fails because the base branch predates the cert fixture renewal on main (#40488). It goes green once #40491 is rebased or merged. The test/bake/deinitialization.test.ts segfault on Windows 11 aarch64 is unrelated and is reported separately.

@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 — clean swap from coerce_int! clamping to validate_integer_range, matching how Bun.Archive level and Bun.password cost already validate.

What was reviewed:

  • get_int_option: opt.get() maps both missing and undefined to None, so the T::ZERO default passed to validate_integer_range is unreachable and can't leak as a value; the explicit NaN pre-check correctly closes the one gap where validate_integer_range would have returned the default.
  • All format methods (jpeg/png/webp/heic/avif) route through the single set_format, so the fix covers the whole class.
  • Test covers out-of-range, NaN, fractional, and exact boundary values; the removed quality: NaN assertion in the existing clamp test is the only pre-existing coverage this invalidates.
  • CODEOWNERS: none of the changed paths are owned; type declarations already document these ranges as number, so no .d.ts update needed.
Extended reasoning...

Overview

This PR replaces silent clamping of Bun.Image encoder options (quality, compressionLevel, colors) with strict validation that throws ERR_INVALID_ARG_TYPE for non-integers and ERR_OUT_OF_RANGE for out-of-bound values. It touches three files: src/runtime/image/Image.rs adds a get_int_option<T> helper wrapping the existing JSGlobalObject::validate_integer_range and swaps three get_number_option + coerce_int! call sites to use it; test/js/bun/image/image.test.ts gains a new test for #40490 and drops one now-throwing assertion from an existing clamp test; docs/runtime/image.mdx documents the ranges and error codes.

Security risks

None. This is input validation on a Bun-native API that tightens (not loosens) accepted values. No parsing of untrusted data, no memory management, no auth/crypto/permissions. The change makes the API stricter, which if anything reduces the surface for surprising downstream behavior.

Level of scrutiny

Low-to-medium. The implementation is a thin wrapper over an existing, widely-used validation helper (validate_integer_range), and the three call sites in set_format are the only ones changed — verified that all format methods (jpeg/png/webp/heic/avif) share set_format, so the fix covers the whole class. I traced JSValue::get to confirm it returns None for both missing and undefined properties, which means the T::ZERO default is never reachable (the one case where it would be — NaN — is explicitly rejected first). The behavior change from clamp → throw is user-facing, but the PR cites precedent in Bun.Archive level, Bun.password bcrypt cost, and Response compress.level, all of which throw on out-of-range values rather than clamping.

Other factors

Test coverage is thorough: exact error shape via toMatchObject (name, code, message), out-of-range values across all three options and multiple format methods, NaN and fractional inputs, and — importantly — the exact boundary values (1/100, 0/9, 2/256) are asserted to be accepted. The one removed assertion (quality: NaN in the existing clamp test) is justified since it now throws, and the rest of that test is preserved. CODEOWNERS covers only .d.ts, test/expectations.txt, and CODEOWNERS itself — none of the changed files. The .d.ts type declarations already document the ranges in JSDoc and keep the type as number, which remains accurate. The bug hunt exited on dry_streak with no findings.

This branch has not been deployed

No deployments
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