diff --git a/src/libdeflate_sys/libdeflate.rs b/src/libdeflate_sys/libdeflate.rs index 23d1e2534ac7..866300426ea3 100644 --- a/src/libdeflate_sys/libdeflate.rs +++ b/src/libdeflate_sys/libdeflate.rs @@ -20,8 +20,15 @@ impl Default for Options { } } +/// Valid `compression_level` range for `libdeflate_alloc_compressor`. Values +/// outside this range make the allocator return NULL (indistinguishable from OOM), +/// so callers must range-check first. +pub const MIN_COMPRESSION_LEVEL: c_int = 0; +pub const MAX_COMPRESSION_LEVEL: c_int = 12; + unsafe extern "C" { - // Allocation: scalar arg, no preconditions; returns null on OOM. + // Allocation: scalar arg, no preconditions; returns null on OOM or + // compression_level outside MIN..=MAX_COMPRESSION_LEVEL. pub(crate) safe fn libdeflate_alloc_compressor(compression_level: c_int) -> *mut Compressor; // NOT safe: `Options` carries caller-supplied `malloc_func`/`free_func` // callbacks that libdeflate will invoke and write through. A bogus callback @@ -254,7 +261,8 @@ impl Compressor { pub struct OwnedCompressor(NonNull); impl OwnedCompressor { - /// Allocate a compressor at `level` (0..=12). Returns `None` on OOM. + /// Allocate a compressor at `level` ([`MIN_COMPRESSION_LEVEL`]..=[`MAX_COMPRESSION_LEVEL`]). + /// Returns `None` on OOM or if `level` is out of range. #[inline] pub fn new(level: c_int) -> Option { NonNull::new(Compressor::alloc(level)).map(Self) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 2547980b2d02..c592c826915e 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2708,8 +2708,17 @@ pub mod JSZlib { leak_list_into_uint8array(global_this, list) } Library::Libdeflate => { - let Some(mut compressor) = bun_libdeflate::OwnedCompressor::new(level.unwrap_or(6)) - else { + let level = level.unwrap_or(6); + if !(bun_libdeflate::MIN_COMPRESSION_LEVEL..=bun_libdeflate::MAX_COMPRESSION_LEVEL) + .contains(&level) + { + return Err(global_this.throw_invalid_arguments(format_args!( + "Compression level must be between {} and {} for libdeflate", + bun_libdeflate::MIN_COMPRESSION_LEVEL, + bun_libdeflate::MAX_COMPRESSION_LEVEL, + ))); + } + let Some(mut compressor) = bun_libdeflate::OwnedCompressor::new(level) else { return Err(global_this.throw_out_of_memory()); }; let encoding = if is_gzip { diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index 164b531e51a3..1167a38455b3 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -105,6 +105,36 @@ describe("zlib", () => { const data = new TextEncoder().encode("Hello World!".repeat(1)); expect(() => gunzipSync(data, { library: "zlib" })).toThrow(new Error("incorrect header check")); }); + + describe("libdeflate level validation", () => { + const data = Buffer.alloc(64, "a"); + // libdeflate_alloc_compressor returns NULL for level outside [0, 12]; that NULL must + // surface as an invalid-argument error, not "Out of memory". + for (const fn of [gzipSync, deflateSync]) { + it(`${fn.name}: out-of-range level throws an argument error, not OOM`, () => { + for (const level of [-2, -1, 13, 100]) { + let err; + try { + fn(data, { library: "libdeflate", level }); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.message).not.toContain("memory"); + expect(err.message).toContain("Compression level must be between 0 and 12"); + } + }); + + it(`${fn.name}: in-range levels 0..12 succeed and round-trip`, () => { + const decompress = fn === gzipSync ? gunzipSync : inflateSync; + for (const level of [0, 1, 6, 9, 12]) { + const out = fn(data, { library: "libdeflate", level }); + expect(out.length).toBeGreaterThan(0); + expect(Buffer.from(decompress(out, { library: "libdeflate" }))).toEqual(data); + } + }); + } + }); }); function* window(buffer, size, advance = size) {