Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/runtime/image/Image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ fn source_from_js(
out.truncate(r.written);
return Ok(Source::Owned(out));
}
// Same check as `Bun.file()`; the worker opens this as a C string.
crate::node::types::Valid::path_null_bytes(s, global)?;
return Ok(Source::Path(ZBox::from_bytes(s)));
}
if let Some(ab) = value.as_array_buffer(global) {
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ The codecs themselves are vendored via `scripts/build/deps/{libjpeg-turbo,libspn
codecs return `Encoded` with the right `free`, not a default_allocator slice.
- The `max_pixels` guard fires **after the header read, before the RGBA alloc**
in every codec. New codecs must do the same.
- `probe()` reports the dimensions the decoder will produce, so `metadata()`
and the terminals agree on what `max_pixels` rejects. For GIF that is the
first Image Descriptor, not the Logical Screen Descriptor.
- Metadata is bounded independently of `max_pixels`. An ICC profile over
`MAX_ICC_PROFILE_BYTES` is dropped at decode before it is copied out of the
codec (JPEG cannot carry more than 255 × 65519 bytes anyway), and libspng
runs with chunk limits so an iCCP/zTXt/iTXt that inflates past the cap
fails the decode instead of filling memory. New codecs must cap whatever
they inflate or copy out of the container the same way.
- `image_resize.cpp` must stay in `noUnify` (see `scripts/build/unified.ts`) —
highway's `foreach_target.h` has a TU-wide include guard that breaks with
two highway TUs in one bundle.
58 changes: 46 additions & 12 deletions src/runtime/image/codec_gif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,23 @@ impl Dict {
}
}

pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, codecs::Error> {
/// Everything in front of the first frame's LZW data. `width`/`height` are the
/// Image Descriptor's (the decoded size), not the Logical Screen Descriptor's.
Comment thread
robobun marked this conversation as resolved.
pub(crate) struct Header {
pub(crate) width: u32,
pub(crate) height: u32,
interlace: bool,
/// Local colour table if the frame has one, else the global one.
color_table: core::ops::Range<usize>,
min_code: u8,
/// Transparency index from the most recent Graphics Control Extension.
transparent: Option<u8>,
/// Offset of the first LZW sub-block.
lzw_off: usize,
}

/// Walk to the first Image Descriptor, skipping extensions. No LZW is read.
pub(crate) fn parse_header(bytes: &[u8]) -> Result<Header, codecs::Error> {
// ── header + LSD ───────────────────────────────────────────────────────
if bytes.len() < 13 || !(&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") {
return Err(codecs::Error::DecodeFailed);
Expand All @@ -146,11 +162,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
if i > bytes.len() {
return Err(codecs::Error::DecodeFailed);
}
let gct: &[u8] = if has_gct {
&bytes[13..][..(gct_size as usize) * 3]
} else {
&[]
};
let gct: core::ops::Range<usize> = if has_gct { 13..i } else { 0..0 };

let mut trns: Option<u8> = None; // transparency index from the most recent GCE

Expand Down Expand Up @@ -203,18 +215,17 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
if w == 0 || h == 0 {
return Err(codecs::Error::DecodeFailed);
}
codecs::guard(w, h, max_pixels)?;
let ct: &[u8] = if has_lct {
let color_table: core::ops::Range<usize> = if has_lct {
if i + lct_size * 3 > bytes.len() {
return Err(codecs::Error::DecodeFailed);
}
let s = &bytes[i..][..lct_size * 3];
let r = i..i + lct_size * 3;
i += lct_size * 3;
s
r
} else {
gct
};
if ct.is_empty() {
if color_table.is_empty() {
return Err(codecs::Error::DecodeFailed); // no palette at all
}

Expand All @@ -223,14 +234,37 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
}
let min_code: u8 = bytes[i].clamp(2, 11);
i += 1;
return decode_frame(bytes, i, w, h, interlace, ct, min_code, trns);
return Ok(Header {
width: w,
height: h,
interlace,
color_table,
min_code,
transparent: trns,
lzw_off: i,
});
}
_ => return Err(codecs::Error::DecodeFailed),
}
}
Err(codecs::Error::DecodeFailed)
}

pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, codecs::Error> {
let hdr = parse_header(bytes)?;
codecs::guard(hdr.width, hdr.height, max_pixels)?;
decode_frame(
bytes,
hdr.lzw_off,
hdr.width,
hdr.height,
hdr.interlace,
&bytes[hdr.color_table],
hdr.min_code,
hdr.transparent,
)
}

fn decode_frame(
bytes: &[u8],
lzw_off: usize,
Expand Down
60 changes: 38 additions & 22 deletions src/runtime/image/codec_jpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ type tjhandle = *mut c_void;

// TJINIT_COMPRESS=0, TJINIT_DECOMPRESS=1.
unsafe extern "C" {
pub(crate) fn tj3Init(init_type: c_int) -> tjhandle;
pub(crate) fn tj3Destroy(h: tjhandle);
fn tj3Init(init_type: c_int) -> tjhandle;
fn tj3Destroy(h: tjhandle);
fn tj3Set(h: tjhandle, param: c_int, value: c_int) -> c_int;
pub(crate) fn tj3Get(h: tjhandle, param: c_int) -> c_int;
pub(crate) fn tj3DecompressHeader(h: tjhandle, buf: *const u8, len: usize) -> c_int;
fn tj3Get(h: tjhandle, param: c_int) -> c_int;
fn tj3DecompressHeader(h: tjhandle, buf: *const u8, len: usize) -> c_int;
fn tj3Decompress8(
h: tjhandle,
buf: *const u8,
Expand All @@ -38,6 +38,7 @@ unsafe extern "C" {
fn tj3SetScalingFactor(h: tjhandle, sf: ScalingFactor) -> c_int;
fn tj3SetCroppingRegion(h: tjhandle, r: CropRegion) -> c_int;
fn tj3GetScalingFactors(n: *mut c_int) -> *const ScalingFactor;
fn tj3GetErrorCode(h: tjhandle) -> c_int;
pub(crate) fn tj3Free(ptr: *mut c_void);
// ICC profile transport: the APP2 ICC_PROFILE marker carries the source's
// colour space (sRGB implicit when absent; Display-P3 / Adobe RGB / Jpegli
Expand Down Expand Up @@ -105,8 +106,8 @@ fn scaled(dim: u32, sf: ScalingFactor) -> u32 {
// tjparam / tjpf enum values from turbojpeg.h.
const TJPARAM_QUALITY: c_int = 3;
const TJPARAM_SUBSAMP: c_int = 4;
pub(crate) const TJPARAM_JPEGWIDTH: c_int = 5;
pub(crate) const TJPARAM_JPEGHEIGHT: c_int = 6;
const TJPARAM_JPEGWIDTH: c_int = 5;
const TJPARAM_JPEGHEIGHT: c_int = 6;
const TJPARAM_PROGRESSIVE: c_int = 12;
const TJPARAM_MAXPIXELS: c_int = 24;
/// `2` = save only APP2/ICC_PROFILE markers (enough for colour management,
Expand All @@ -115,6 +116,33 @@ const TJPARAM_MAXPIXELS: c_int = 24;
const TJPARAM_SAVEMARKERS: c_int = 25;
const TJPF_RGBA: c_int = 7;
const TJSAMP_420: c_int = 2;
/// `tj3GetErrorCode` after a -1 return: libjpeg warned but kept going.
const TJERR_WARNING: c_int = 0;

/// Parse the header (SOF dims, saved markers) without touching scan data.
/// A warning (`JWRN_BOGUS_ICC`: ICC markers that do not reassemble) leaves
/// the SOF fields valid and the profile absent, so only a fatal error rejects.
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn read_header(h: tjhandle, bytes: &[u8]) -> Result<(u32, u32), codecs::Error> {
// SAFETY: `h` is a live tjhandle; ptr/len come from a valid `&[u8]`
// borrowed for the call.
let rc = unsafe { tj3DecompressHeader(h, bytes.as_ptr(), bytes.len()) };
// SAFETY: `h` is live; tj3GetErrorCode only reads handle state.
if rc != 0 && unsafe { tj3GetErrorCode(h) } != TJERR_WARNING {
return Err(codecs::Error::DecodeFailed);
}
// SAFETY: `h` is live; tj3Get only reads handle state.
let rw = unsafe { tj3Get(h, TJPARAM_JPEGWIDTH) };
// SAFETY: `h` is live; tj3Get only reads handle state.
let rh = unsafe { tj3Get(h, TJPARAM_JPEGHEIGHT) };
// -1 on error, 0 if SOF was never reached: reject before the cast.
if rw <= 0 || rh <= 0 {
return Err(codecs::Error::DecodeFailed);
}
Ok((
u32::try_from(rw).expect("int cast"),
u32::try_from(rh).expect("int cast"),
))
}

pub(crate) fn decode(
bytes: &[u8],
Expand All @@ -134,21 +162,9 @@ pub(crate) fn decode(
// marker buffer is discarded if we set this after.
// SAFETY: `h` is a live tjhandle for the duration of `_h_guard`.
unsafe { tj3Set(h, TJPARAM_SAVEMARKERS, 2) };
// SAFETY: `h` is live; ptr/len come from a valid `&[u8]` borrowed for the call.
if unsafe { tj3DecompressHeader(h, bytes.as_ptr(), bytes.len()) } != 0 {
return Err(codecs::Error::DecodeFailed);
}
// SAFETY: `h` is live; tj3Get only reads handle state.
let rw = unsafe { tj3Get(h, TJPARAM_JPEGWIDTH) };
// SAFETY: `h` is live; tj3Get only reads handle state.
let rh = unsafe { tj3Get(h, TJPARAM_JPEGHEIGHT) };
// tj3Get returns -1 on error; treat any non-positive dim as a decode
// failure rather than letting the cast trap on hostile input.
if rw <= 0 || rh <= 0 {
return Err(codecs::Error::DecodeFailed);
}
let src_w: u32 = u32::try_from(rw).expect("int cast");
let src_h: u32 = u32::try_from(rh).expect("int cast");
let (src_w, src_h) = read_header(h, bytes)?;
let rw = c_int::try_from(src_w).expect("int cast");
let rh = c_int::try_from(src_h).expect("int cast");
codecs::guard(src_w, src_h, max_pixels)?;

let mut w = src_w;
Expand Down Expand Up @@ -279,7 +295,7 @@ pub(crate) fn decode(
unsafe { tj3Free(p.cast()) };
}
});
if icc_ptr.is_null() {
if icc_ptr.is_null() || icc_size > codecs::MAX_ICC_PROFILE_BYTES {
break 'blk None;
}
// SAFETY: tj3GetICCProfile wrote `icc_size` bytes at `icc_ptr`.
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/image/codec_png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ unsafe extern "C" {
/// the context and freed with `spng_ctx_free`; dupe out before then.
fn spng_get_iccp(ctx: *mut spng_ctx, iccp: *mut Iccp) -> c_int;
fn spng_set_iccp(ctx: *mut spng_ctx, iccp: *const Iccp) -> c_int;
fn spng_set_chunk_limits(ctx: *mut spng_ctx, chunk_size: usize, cache_limit: usize) -> c_int;
}

/// libspng inflates iCCP/zTXt/iTXt before the first IDAT, where `max_pixels`
/// cannot see it. Over either cap the decode fails with `SPNG_ECHUNK_LIMITS`.
Comment thread
robobun marked this conversation as resolved.
const MAX_CHUNK_BYTES: usize = codecs::MAX_ICC_PROFILE_BYTES;
const MAX_CHUNK_CACHE_BYTES: usize = 4 * MAX_CHUNK_BYTES;

#[repr(C)]
struct Iccp {
/// PNG's Latin-1 iCCP keyword (1-79 chars + NUL). libspng requires it
Expand Down Expand Up @@ -108,6 +114,10 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
unsafe { spng_ctx_free(c) }
});

// SAFETY: ctx is valid; both limits are plain sizes.
if unsafe { spng_set_chunk_limits(ctx, MAX_CHUNK_BYTES, MAX_CHUNK_CACHE_BYTES) } != 0 {
return Err(codecs::Error::DecodeFailed);
}
// SAFETY: ctx is valid; bytes outlives the ctx (freed at end of scope).
if unsafe { spng_set_png_buffer(ctx, bytes.as_ptr(), bytes.len()) } != 0 {
return Err(codecs::Error::DecodeFailed);
Expand Down Expand Up @@ -153,6 +163,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
// SAFETY: ctx is valid; iccp is a valid out-ptr.
let icc: Option<Vec<u8>> = if unsafe { spng_get_iccp(ctx, &raw mut iccp) } == 0
&& iccp.profile_len > 0
&& iccp.profile_len <= codecs::MAX_ICC_PROFILE_BYTES
&& !iccp.profile.is_null()
{
// SAFETY: libspng guarantees profile points to profile_len bytes owned by ctx.
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/image/codec_webp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result<codecs::Decoded, c
if iter.chunk.bytes.is_null() {
break 'blk None;
}
if iter.chunk.size == 0 {
// RIFF stores chunks raw: an ICCP chunk can be as large as the file.
if iter.chunk.size == 0 || iter.chunk.size > codecs::MAX_ICC_PROFILE_BYTES {
break 'blk None;
}
// SAFETY: chunk.bytes points into `bytes` for chunk.size bytes per libwebp contract.
Expand Down
37 changes: 13 additions & 24 deletions src/runtime/image/codecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ bun_core::oom_from_alloc!(Error);
/// cap is ~1 GiB, which is already past where you'd want to be.
pub(crate) const DEFAULT_MAX_PIXELS: u64 = 0x3FFF * 0x3FFF;

/// Largest ICC profile carried from decode to encode; bigger is "no profile".
/// Real profiles are under 4 MB, and JPEG cannot hold more than 255 × 65519.
Comment thread
robobun marked this conversation as resolved.
pub(crate) const MAX_ICC_PROFILE_BYTES: usize = 8 << 20;

/// Hint from the pipeline about the eventual output size. JPEG can do M/8
/// IDCT scaling for free, so when we know the resize target up front we
/// decode at the smallest factor that still ≥ the target — skipping most of
Expand Down Expand Up @@ -357,21 +361,7 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result<Probe, Error> {
Format::Jpeg => {
// turbojpeg's header decode is already cheap (no scan data read).
let handle = jpeg::Handle::init(1).ok_or(Error::OutOfMemory)?;
// SAFETY: handle is live; (ptr,len) come from a valid live slice.
if unsafe { jpeg::tj3DecompressHeader(handle.as_ptr(), bytes.as_ptr(), bytes.len()) }
!= 0
{
return Err(Error::DecodeFailed);
}
// SAFETY: handle is live and has had a header decoded into it above.
let rw = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGWIDTH) };
// SAFETY: same handle invariant as above.
let rh = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGHEIGHT) };
if rw <= 0 || rh <= 0 {
return Err(Error::DecodeFailed);
}
w = u32::try_from(rw).expect("int cast");
h = u32::try_from(rh).expect("int cast");
(w, h) = jpeg::read_header(handle.as_ptr(), bytes)?;
}
Format::Webp => {
let mut cw: c_int = 0;
Expand All @@ -393,14 +383,10 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result<Probe, Error> {
h = ih.height;
}
Format::Gif => {
// sig(6) · LSD: w(u16le) h(u16le) …
if bytes.len() < 10 {
return Err(Error::DecodeFailed);
}
w = u16::from_le_bytes(bytes[6..8].try_into().expect("infallible: size matches"))
as u32;
h = u16::from_le_bytes(bytes[8..10].try_into().expect("infallible: size matches"))
as u32;
// The dims the decoder produces, not the Logical Screen Descriptor's.
let hdr = gif::parse_header(bytes)?;
w = hdr.width;
h = hdr.height;
}
Format::Tiff => {
// IFD walk would be a full TIFF parser; defer to whoever
Expand Down Expand Up @@ -541,7 +527,10 @@ pub(crate) fn encode(
) -> Result<Encoded, Error> {
// SAFETY: `EncodeOptions.icc_profile` is borrowed from the caller for the
// duration of this call (raw-ptr stand-in for a lifetime param).
let icc: Option<&[u8]> = opts.icc_profile.map(|p| unsafe { p.as_ref() });
let icc: Option<&[u8]> = opts
.icc_profile
.map(|p| unsafe { p.as_ref() })
.filter(|p| p.len() <= MAX_ICC_PROFILE_BYTES);
match opts.format {
Format::Jpeg => jpeg::encode(rgba, width, height, opts.quality, opts.progressive, icc),
// PNG carries iCCP on both truecolour and indexed images — quantise
Expand Down
Loading
Loading