From 1fa378899f0da2bc269511dee3ada008e0797adf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:00:24 +0000 Subject: [PATCH 1/4] Bun.Image: as a body, keep the window of a Bun.file() slice that starts past byte 0 new Response(image), new Request(url, { body: image }) and fetch(url, { body: image }) encode inside the call. For an Image made from Bun.file(path), encode_for_body replaces the source with the path of the file and reads that. The offset and the size of a slice were lost there, so the body was an encode of the whole file. The source stayed the bare path, so later bodies and terminals decoded the whole file, and so did a terminal that was pending across the call. A source whose Blob starts past byte 0 now becomes a path source with that window (Source::PathWindow). The path reader in PipelineTask::run reads the window with one positioned read, after the same open, fstat and regular-file guard, and the 256 MiB cap measures the window. Only an offset proves a slice. A Blob that starts at byte 0 can be an un-sliced Bun.file() with a cached size, so it is read to the end of the file, as before. A window that starts at or past the end of the file is read as the whole file, as before. --- src/runtime/image/Image.rs | 109 ++++++++++++-- test/js/bun/image/image.test.ts | 242 ++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 14 deletions(-) diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index b31492c3c963..efe9760667d6 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -112,6 +112,8 @@ pub(crate) enum Source { Owned(Vec), /// Owned, NUL-terminated. Read on the worker thread. Path(ZBox), + /// What a `Bun.file(path).slice()` source becomes as a body: `.path` with the slice's window. + PathWindow(Box), /// `Bun.file()`, `Bun.s3()`, an fd-backed Blob — anything whose bytes /// don't exist until read. We hold a Strong on the JS Blob and, at /// terminal time, just call its own `.bytes()` (whatever that means for @@ -124,6 +126,13 @@ pub(crate) enum Source { // `Vec`, `ZString`, and // `Strong` all implement `Drop`, so no explicit `Drop` body is needed. +/// The part of the file at `path` that a slice names: `size` bytes from `offset`. +pub(crate) struct PathWindow { + path: ZBox, + offset: u64, + size: u64, +} + // These C++ helpers are // Image-specific (they pin/adopt typed-array storage for the off-thread // pipeline) and have no `bun_jsc` wrapper. @@ -245,7 +254,7 @@ macro_rules! coerce_int { }}; } -/// Size cap for `.path` sources, applied at fstat time before reading +/// Size cap for what a `.path` or `.path_window` source reads, applied at fstat time before reading /// anything. This is the *encoded* file, not the decoded RGBA — `maxPixels` /// covers the latter once we have a header. 256 MiB comfortably fits any /// real-world image (a 268 MP JPEG is ~80 MB) while keeping a single @@ -316,6 +325,8 @@ impl Image { Source::JsBuffer | Source::Blob(_) => 0, Source::Owned(b) => b.len(), Source::Path(p) => p.len(), + // A GC marker thread can be the caller: no read through the Box. + Source::PathWindow(_) => mem::size_of::(), } } } @@ -692,7 +703,7 @@ impl Image { unsafe { &*std::ptr::from_ref::<[u8]>(ab.byte_slice()) } }), Source::Owned(b) => Some(b.as_slice()), - Source::Path(_) | Source::Blob(_) => None, + Source::Path(_) | Source::PathWindow(_) | Source::Blob(_) => None, } } @@ -787,7 +798,14 @@ impl Image { )), Source::Path(p) => Ok(( Input { - path: Some(std::ptr::from_ref::(p.as_zstr())), + path: InputPath::Whole(std::ptr::from_ref::(p.as_zstr())), + ..Default::default() + }, + Pin::NONE, + )), + Source::PathWindow(window) => Ok(( + Input { + path: InputPath::Window(std::ptr::from_ref::(window)), ..Default::default() }, Pin::NONE, @@ -1166,7 +1184,7 @@ impl Image { ) -> JsResult<(codecs::Encoded, &'static ZStr)> { // The body-init contract is synchronous, so a `.blob` source can't go // through the async read chain here. For the common case (file by path) - // fall back to the `.path` source — `run()` reads it inline. fd/S3-backed + // fall back to the `.path` source (`.path_window` for a slice), which `run()` reads inline. fd/S3-backed // BunFiles would block or need network; refuse with a clear message until // the body path is made `.Locked`. if let Source::Blob(strong) = self.source.get() { @@ -1183,8 +1201,19 @@ impl Image { if let blob_store::Data::File(file) = &store.data { if let PathOrFileDescriptor::Path(path) = &file.pathlike { let p = ZBox::from_bytes(path.slice()); + // Only an offset proves a slice: an un-sliced `Bun.file()` caches a size as well (#4930). + let offset = blob.offset.get(); + let source = if offset > 0 { + Source::PathWindow(Box::new(PathWindow { + path: p, + offset, + size: blob.size.get(), + })) + } else { + Source::Path(p) + }; // `Source::Blob`'s `Strong` Drop releases the JS ref. - self.source.set(Source::Path(p)); + self.source.set(source); } else { return Err(global.throw(format_args!("{REFUSE}"))); } @@ -1467,18 +1496,65 @@ pub(crate) struct Input { // Borrows pinned ArrayBuffer or `image.source.owned`; the owning `Image` // is held via BACKREF for the task's lifetime — `RawSlice` invariant. bytes: bun_ptr::RawSlice, - // Borrows `image.source.path` (NUL-terminated); the owning `Image` is + // Borrows `image.source` (a `.path` or a `.path_window`); the owning `Image` is // held via BACKREF for the task's lifetime, same as `bytes` above. - path: Option<*const ZStr>, + path: InputPath, /// FastTypedArray inputs are tiny and GC-movable: copied instead of pinned. copied: Option>, } +/// The file the worker reads for an `Input`: none, a whole file, or a window of one. +#[derive(Clone, Copy)] +pub(crate) enum InputPath { + None, + Whole(*const ZStr), + Window(*const PathWindow), +} + +// Every scheduled task holds an `Input`: the window does not grow it. +const _: () = assert!(mem::size_of::() == mem::size_of::>()); + +impl InputPath { + /// The path to open, and the window of that file when the source is a slice. + fn file(&self) -> Option<(&ZStr, Option<&PathWindow>)> { + match *self { + InputPath::None => None, + // SAFETY: `p` borrows `image.source.path`, which outlives the task + // because `this_ref` is held Strong while pending_tasks > 0. + InputPath::Whole(p) => Some((unsafe { &*p }, None)), + InputPath::Window(window) => { + // SAFETY: as for `Whole`: `window` borrows the Box in `image.source.path_window`. + let window = unsafe { &*window }; + Some((window.path.as_zstr(), Some(window))) + } + } + } +} + +/// Reads `len` bytes of `file` from `offset`, or fewer when the file ends first. +fn read_window(file: &sys::File, offset: u64, len: u64) -> sys::Result> { + let len = usize::try_from(len).expect("int cast"); + let mut bytes: Vec = Vec::new(); + if bytes.try_reserve_exact(len).is_err() { + return Err(sys::Error::oom()); + } + // SAFETY: `pread_all` only writes into the spare bytes and returns how many it filled. + unsafe { + bun_core::vec::fill_spare(&mut bytes, 0, |spare| { + match file.pread_all(&mut spare[..len], offset) { + Ok(read) => (read, Ok(())), + Err(e) => (0, Err(e)), + } + }) + }?; + Ok(bytes) +} + impl Default for Input { fn default() -> Self { Self { bytes: bun_ptr::RawSlice::EMPTY, - path: None, + path: InputPath::None, copied: None, } } @@ -1544,10 +1620,7 @@ impl PipelineTask { // pinned ArrayBuffer slice (pin lives until `then()` unpins), an owned // buffer, or a path to read here. let owned_file: Option>; - let input: &[u8] = if let Some(p) = self.input.path { - // SAFETY: `p` borrows `image.source.path`, which outlives the task - // because `this_ref` is held Strong while pending_tasks > 0. - let p: &ZStr = unsafe { &*p }; + let input: &[u8] = if let Some((p, window)) = self.input.path.file() { // The path string came straight from the constructor, so treat // it as untrusted: open + fstat first instead of `readFrom`. // • !S_ISREG → ENODEV. `/dev/zero`/`/dev/urandom` would @@ -1590,11 +1663,19 @@ impl PipelineTask { }); return; } - if u64::try_from(st.st_size.max(0)).expect("int cast") > MAX_INPUT_FILE_BYTES { + let file_size = u64::try_from(st.st_size.max(0)).expect("int cast"); + // A window that starts at or past the end of the file reads the whole file: `slice(-n)` on an unread size has its offset near `MAX_SIZE`. + let window = window.filter(|w| w.offset < file_size); + let len = window.map_or(file_size, |w| (file_size - w.offset).min(w.size)); + if len > MAX_INPUT_FILE_BYTES { self.result = TaskResult::Err(codecs::Error::TooManyPixels); return; } - match file.read_to_end() { + let read = match window { + Some(w) => read_window(&file, w.offset, len), + None => file.read_to_end(), + }; + match read { Ok(bytes) => owned_file = Some(bytes), Err(e) => { self.result = TaskResult::IoErr(e.with_path(p.as_bytes())); diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index c56d6cd86322..23a692151504 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -1,6 +1,7 @@ import { S3Client } from "bun"; import { afterAll, describe, expect, test } from "bun:test"; import { isMacOS, isWindows, tempDir } from "harness"; +import fs from "node:fs"; import zlib from "node:zlib"; import { join } from "path"; @@ -193,6 +194,247 @@ describe("Bun.Image", () => { expect((await res.bytes()).subarray(8, 12)).toEqual(Buffer.from("WEBP")); }); + // `new Response(image)`, `new Request(url, { body: image })` and + // `fetch(url, { body: image })` encode inside the call. A Bun.file() source + // becomes a path source there, and a slice that starts past byte 0 keeps its + // window: the body, later bodies and every terminal read the slice. + describe("a slice of Bun.file() as a body", () => { + // Three pictures back to back. A slice of the file selects one of them. + const pictures = [gradientPng, cmykJpeg, cornersPng]; + const [a, b, c] = pictures.map(picture => picture.length); + const threeIn = () => tempDir("image-body-window", { "three.bin": Buffer.concat(pictures) }); + const three = (dir: string) => Bun.file(join(dir, "three.bin")); + const shape = async (bytes: Uint8Array) => { + const { format, width, height } = await new Bun.Image(bytes).metadata(); + return `${format} ${width}x${height}`; + }; + // "image/png: png 4x3" for a body. + const typeAndShape = async (body: Response | Request) => + `${body.headers.get("content-type")}: ${await shape(await body.bytes())}`; + // The encoded picture of a body or of `image.blob()`, or the message of what it throws. + const encodedBy = async (make: () => Response | Promise) => { + try { + const encoded = await make(); + const type = encoded instanceof Response ? encoded.headers.get("content-type") : encoded.type; + const bytes = await encoded.bytes(); + return { type, shape: await shape(bytes), sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex") }; + } catch (e) { + return { message: (e as Error).message }; + } + }; + const UNRECOGNISED = "Image: unrecognised format (expected JPEG, PNG, WebP, GIF, BMP, TIFF, HEIC or AVIF)"; + const TOO_LONG = "Image: input exceeds maxPixels limit"; + const jpeg = { type: "image/jpeg", shape: "jpeg 64x64" }; + const png = { type: "image/png", shape: "png 4x3" }; + + test.each([ + ["slice(a)", (file: Blob) => file.slice(a), jpeg], + ["slice(a, a + b)", (file: Blob) => file.slice(a, a + b), jpeg], + ["slice(a + b)", (file: Blob) => file.slice(a + b), png], + ["slice(a + b, a + b + c)", (file: Blob) => file.slice(a + b, a + b + c), png], + ["slice(a).slice(b)", (file: Blob) => file.slice(a).slice(b), png], + // The end of the window is the end of the input: libjpeg fills the rows it has no data for. + ["slice(a, a + b - 200)", (file: Blob) => file.slice(a, a + b - 200), jpeg], + ["slice(a + b, a + b + 30)", (file: Blob) => file.slice(a + b, a + b + 30), { message: "Image: decode failed" }], + ["slice(a, a)", (file: Blob) => file.slice(a, a), { message: UNRECOGNISED }], + ] as const)("new Response(image) encodes what image.blob() encodes for %s", async (_, window, expected) => { + using dir = threeIn(); + const sliced = () => window(three(String(dir))).image(); + const body = await encodedBy(() => new Response(sliced())); + expect(body).toMatchObject(expected); + expect(body).toEqual(await encodedBy(() => sliced().blob())); + }); + + test("new Request(), fetch() and a Bun.serve() response carry the slice", async () => { + using dir = threeIn(); + const file = () => three(String(dir)); + const second = file().slice(a, a + b); + const request = new Request("http://localhost/", { method: "POST", body: second.image() }); + expect(await typeAndShape(request)).toBe("image/jpeg: jpeg 64x64"); + + using server = Bun.serve({ + port: 0, + // Says what arrived, and answers with a slice of its own. + async fetch(received) { + const third = file().slice(a + b); + return new Response(third.image(), { headers: { "x-received": await typeAndShape(received) } }); + }, + }); + const fromSecond = file().slice(a); + const response = await fetch(server.url, { method: "POST", body: fromSecond.image() }); + expect({ received: response.headers.get("x-received"), answered: await typeAndShape(response) }).toEqual({ + received: "image/jpeg: jpeg 64x64", + answered: "image/png: png 4x3", + }); + }); + + test("after a body, later bodies and terminals read the slice", async () => { + using dir = threeIn(); + const img = three(String(dir)) + .slice(a + b) + .image(); + expect(await typeAndShape(new Response(img))).toBe("image/png: png 4x3"); + expect([img.width, img.height]).toEqual([4, 3]); + expect(await typeAndShape(new Response(img))).toBe("image/png: png 4x3"); + expect(await img.metadata()).toEqual({ width: 4, height: 3, format: "png" }); + expect(await shape(await img.jpeg().bytes())).toBe("jpeg 4x3"); + }); + + test("terminals pending across a body read the slice", async () => { + using dir = threeIn(); + const img = three(String(dir)) + .slice(a + b) + .image(); + const metadata = img.metadata(); + const bytes = img.png().bytes(); + const response = new Response(img); + expect(await metadata).toEqual({ width: 4, height: 3, format: "png" }); + expect(await shape(await bytes)).toBe("png 4x3"); + expect(await typeAndShape(response)).toBe("image/png: png 4x3"); + }); + + test("a body that cannot read the file still names the slice", async () => { + using dir = tempDir("image-body-window", {}); + const path = join(String(dir), "three.bin"); + const img = Bun.file(path) + .slice(a + b) + .image(); + expect(() => new Response(img)).toThrow("ENOENT"); + await Bun.write(path, Buffer.concat(pictures)); + expect(await typeAndShape(new Response(img))).toBe("image/png: png 4x3"); + expect(await shape(await img.bytes())).toBe("png 4x3"); + }); + + test("each body of a slice reads the file again", async () => { + using dir = tempDir("image-body-window", { "two.bin": Buffer.concat([gradientPng, cornersPng]) }); + const path = join(String(dir), "two.bin"); + const img = Bun.file(path).slice(gradientPng.length).image(); + expect(await typeAndShape(new Response(img))).toBe("image/png: png 4x3"); + // Another picture behind the same offset. + await Bun.write(path, Buffer.concat([gradientPng, cmykJpeg])); + expect(await typeAndShape(new Response(img))).toBe("image/jpeg: jpeg 64x64"); + expect(await img.metadata()).toEqual({ width: 64, height: 64, format: "jpeg" }); + }); + + test("the 256 MiB cap is for the slice, not for the file", async () => { + using dir = tempDir("image-body-window", {}); + const path = join(String(dir), "long.bin"); + const cap = 256 * 1024 * 1024; + // One byte, the picture, then zeros to 2 bytes over the cap. + fs.writeFileSync(path, Buffer.concat([Buffer.alloc(1), cornersPng])); + fs.truncateSync(path, cap + 2); + const file = () => Bun.file(path); + const inside = file().slice(1, 1 + cornersPng.length); + expect(await typeAndShape(new Response(inside.image()))).toBe("image/png: png 4x3"); + // One byte over the cap is refused before the read. The cap itself is read, and is no picture. + const overCap = file().slice(1); + expect(() => new Response(overCap.image())).toThrow(TOO_LONG); + const atCap = file().slice(2); + expect(() => new Response(atCap.image())).toThrow(UNRECOGNISED); + // Without a window the cap measures the file: an un-sliced body and a path string are refused, as before. + expect(() => new Response(file().image())).toThrow(TOO_LONG); + expect(() => new Response(new Bun.Image(path))).toThrow(TOO_LONG); + await expect(new Bun.Image(path).metadata()).rejects.toThrow(TOO_LONG); + }); + + // The window is measured against the file at each read. One that starts at or past the end is read as the + // whole file, as every window was before. + test("a slice that starts at or past the end of the file is the whole file", async () => { + using dir = threeIn(); + const path = join(String(dir), "three.bin"); + const atEnd = Bun.file(path).slice(a + b + c); + const pastEnd = Bun.file(path).slice(a + b + c + 10); + expect([ + await typeAndShape(new Response(atEnd.image())), + await typeAndShape(new Response(pastEnd.image())), + ]).toEqual(["image/png: png 16x16", "image/png: png 16x16"]); + + // The same Image, before and after the file loses the bytes of its window. + const img = Bun.file(path).slice(a).image(); + expect(await typeAndShape(new Response(img))).toBe("image/jpeg: jpeg 64x64"); + fs.truncateSync(path, a); + expect(await typeAndShape(new Response(img))).toBe("image/png: png 16x16"); + expect(await img.metadata()).toEqual({ width: 16, height: 16, format: "png" }); + }); + + // A Bun.file() keeps the size it read once. A slice of it ends where that size says, for a body as for image.blob(). + test("a slice of a Bun.file() with a stale size ends at that size", async () => { + using dir = tempDir("image-body-window", {}); + const path = join(String(dir), "stale.bin"); + // Reads the size of `before`, replaces the file with `after`, and gives the body of a slice of the stale handle. + const staleBody = async ( + before: readonly Uint8Array[], + after: readonly Uint8Array[], + window: (file: Blob) => Blob, + ) => { + await Bun.write(path, Buffer.concat(before)); + const file = Bun.file(path); + expect(file.size).toBe(Buffer.concat(before).length); + await Bun.write(path, Buffer.concat(after)); + const sliced = () => window(file).image(); + const body = await encodedBy(() => new Response(sliced())); + expect(body).toEqual(await encodedBy(() => sliced().blob())); + return body; + }; + const appended = [[gradientPng], [gradientPng, cornersPng]] as const; + expect(await staleBody(...appended, file => file.slice(a))).toEqual({ message: UNRECOGNISED }); + expect(await staleBody(...appended, file => file.slice(a, a + c))).toEqual({ message: UNRECOGNISED }); + // A longer PNG in place of the tail is cut short. + const longer = [ + [gradientPng, cornersPng], + [gradientPng, gradientPng], + ] as const; + expect(await staleBody(...longer, file => file.slice(a))).toEqual({ message: "Image: decode failed" }); + // A JPEG that is cut short decodes with no error: libjpeg fills the rows it has no data for. + const cut = [ + [gradientPng, cmykJpeg.subarray(0, b - 200)], + [gradientPng, cmykJpeg], + ] as const; + const cutBody = await staleBody(...cut, file => file.slice(a)); + expect(cutBody).toMatchObject(jpeg); + const fresh = Bun.file(path).slice(a); + expect(cutBody).not.toEqual(await encodedBy(() => new Response(fresh.image()))); + }); + + // A size at offset 0 can be the cached size of an un-sliced Bun.file(), so these read the file to its end. + test("an un-sliced Bun.file() with a stale size is still served whole", async () => { + using dir = tempDir("image-body-window", { "grown.png": Buffer.from(cornersPng) }); + const later = join(String(dir), "later.png"); + const missing = Bun.file(later); + expect(await missing.exists()).toBe(false); + await Bun.write(later, gradientPng); + expect(await typeAndShape(new Response(missing.image()))).toBe("image/png: png 16x16"); + + const grown = Bun.file(join(String(dir), "grown.png")); + expect(grown.size).toBe(cornersPng.length); + await Bun.write(join(String(dir), "grown.png"), gradientPng); + expect(await typeAndShape(new Response(grown.image()))).toBe("image/png: png 16x16"); + }); + + // slice(0, n) has the offset of an un-sliced Bun.file() with a cached size (#4930), so the body is the whole file. + test.failing("slice(0, n) ends the body at n", () => { + using dir = threeIn(); + const cut = three(String(dir)).slice(0, 30); + expect(() => new Response(cut.image())).toThrow("Image: decode failed"); + const empty = three(String(dir)).slice(0, 0); + expect(() => new Response(empty.image())).toThrow(UNRECOGNISED); + }); + + // A negative index on a size that is not read yet gives an offset past the end of the file, so the body is the whole file. + test.failing("slice(-n) of a Bun.file() with an unread size is the last n bytes", async () => { + using dir = threeIn(); + const last = three(String(dir)).slice(-c); + expect(await typeAndShape(new Response(last.image()))).toBe("image/png: png 4x3"); + }); + + test("slice(-n) of a Bun.file() whose size was read is the last n bytes", async () => { + using dir = threeIn(); + const file = three(String(dir)); + expect(file.size).toBe(a + b + c); + expect(await typeAndShape(new Response(file.slice(-c).image()))).toBe("image/png: png 4x3"); + }); + }); + // Store-backed Blob sources are read at terminal time through the Blob's // own store dispatch. The Bun.file() test above covers the file store; this // covers the S3 download callback (bytes and error arm) and the synchronous From 42e6127adf833de8cbc1f03fb4c2dc1a846740b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:18:44 +0000 Subject: [PATCH 2/4] Bun.Image: a slice that starts at or past the end of the file is empty The path reader dropped the window of a slice when the file ended at or before its offset, and read the whole file. One Image then changed between the slice and the first picture in the file when the file changed length. The reader now keeps the window. A window past the end is empty, so the body throws "unrecognised format", as image.bytes() does. slice(-n) on a Bun.file() whose size is not read yet counts back from the largest size of a Blob. No file has that offset. encode_for_body stores the bare path for it, so the body is the whole file, as before. The test of the 256 MiB cap no longer reads a window of exactly the cap. It asserts that a slice from byte 0 of a file over the cap is refused. --- src/runtime/image/Image.rs | 10 +++++---- test/js/bun/image/image.test.ts | 40 +++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index ed92f3f19484..ad6821c123a3 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1203,7 +1203,8 @@ impl Image { let p = ZBox::from_bytes(path.slice()); // Only an offset proves a slice: an un-sliced `Bun.file()` caches a size as well (#4930). let offset = blob.offset.get(); - let source = if offset > 0 { + // `slice(-n)` on a size that is not read yet counts back from `MAX_SIZE`: no file has that offset. + let source = if offset > 0 && offset <= crate::webcore::blob::MAX_SIZE / 2 { Source::PathWindow(Box::new(PathWindow { path: p, offset, @@ -1664,9 +1665,10 @@ impl PipelineTask { return; } let file_size = u64::try_from(st.st_size.max(0)).expect("int cast"); - // A window that starts at or past the end of the file reads the whole file: `slice(-n)` on an unread size has its offset near `MAX_SIZE`. - let window = window.filter(|w| w.offset < file_size); - let len = window.map_or(file_size, |w| (file_size - w.offset).min(w.size)); + // A window that starts at or past the end of the file is empty. + let len = window.map_or(file_size, |w| { + file_size.saturating_sub(w.offset).min(w.size) + }); if len > MAX_INPUT_FILE_BYTES { self.result = TaskResult::Err(codecs::Error::TooManyPixels); return; diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index 23a692151504..23f7e3497abe 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -237,6 +237,10 @@ describe("Bun.Image", () => { ["slice(a, a + b - 200)", (file: Blob) => file.slice(a, a + b - 200), jpeg], ["slice(a + b, a + b + 30)", (file: Blob) => file.slice(a + b, a + b + 30), { message: "Image: decode failed" }], ["slice(a, a)", (file: Blob) => file.slice(a, a), { message: UNRECOGNISED }], + // A window that starts at or past the end of the file is empty. + ["slice(a + b + c)", (file: Blob) => file.slice(a + b + c), { message: UNRECOGNISED }], + ["slice(a + b + c + 10)", (file: Blob) => file.slice(a + b + c + 10), { message: UNRECOGNISED }], + ["slice(file.size)", (file: Blob) => file.slice(file.size), { message: UNRECOGNISED }], ] as const)("new Response(image) encodes what image.blob() encodes for %s", async (_, window, expected) => { using dir = threeIn(); const sliced = () => window(three(String(dir))).image(); @@ -326,35 +330,30 @@ describe("Bun.Image", () => { const file = () => Bun.file(path); const inside = file().slice(1, 1 + cornersPng.length); expect(await typeAndShape(new Response(inside.image()))).toBe("image/png: png 4x3"); - // One byte over the cap is refused before the read. The cap itself is read, and is no picture. + // A window of one byte over the cap is refused before the read. const overCap = file().slice(1); expect(() => new Response(overCap.image())).toThrow(TOO_LONG); - const atCap = file().slice(2); - expect(() => new Response(atCap.image())).toThrow(UNRECOGNISED); // Without a window the cap measures the file: an un-sliced body and a path string are refused, as before. expect(() => new Response(file().image())).toThrow(TOO_LONG); expect(() => new Response(new Bun.Image(path))).toThrow(TOO_LONG); await expect(new Bun.Image(path).metadata()).rejects.toThrow(TOO_LONG); + // A slice from byte 0 keeps no window (see "slice(0, n) ends the body at n"), so the cap measures the file. + const fromStart = file().slice(0, 1 + cornersPng.length); + expect(() => new Response(fromStart.image())).toThrow(TOO_LONG); }); - // The window is measured against the file at each read. One that starts at or past the end is read as the - // whole file, as every window was before. - test("a slice that starts at or past the end of the file is the whole file", async () => { + // The window is measured against the file at each read, and it stays the window of that Image. + test("a slice is empty while the file ends before it", async () => { using dir = threeIn(); const path = join(String(dir), "three.bin"); - const atEnd = Bun.file(path).slice(a + b + c); - const pastEnd = Bun.file(path).slice(a + b + c + 10); - expect([ - await typeAndShape(new Response(atEnd.image())), - await typeAndShape(new Response(pastEnd.image())), - ]).toEqual(["image/png: png 16x16", "image/png: png 16x16"]); - - // The same Image, before and after the file loses the bytes of its window. const img = Bun.file(path).slice(a).image(); expect(await typeAndShape(new Response(img))).toBe("image/jpeg: jpeg 64x64"); + // The file loses the bytes of the window, then gets them back. fs.truncateSync(path, a); - expect(await typeAndShape(new Response(img))).toBe("image/png: png 16x16"); - expect(await img.metadata()).toEqual({ width: 16, height: 16, format: "png" }); + expect(() => new Response(img)).toThrow(UNRECOGNISED); + await expect(img.metadata()).rejects.toThrow(UNRECOGNISED); + await Bun.write(path, Buffer.concat(pictures)); + expect(await typeAndShape(new Response(img))).toBe("image/jpeg: jpeg 64x64"); }); // A Bun.file() keeps the size it read once. A slice of it ends where that size says, for a body as for image.blob(). @@ -420,13 +419,20 @@ describe("Bun.Image", () => { expect(() => new Response(empty.image())).toThrow(UNRECOGNISED); }); - // A negative index on a size that is not read yet gives an offset past the end of the file, so the body is the whole file. + // A negative index on a size that is not read yet counts back from the largest size a Blob can have, not from + // the end of the file. Its offset is in no file, so the body is the whole file. test.failing("slice(-n) of a Bun.file() with an unread size is the last n bytes", async () => { using dir = threeIn(); const last = three(String(dir)).slice(-c); expect(await typeAndShape(new Response(last.image()))).toBe("image/png: png 4x3"); }); + test("slice(-n) of a Bun.file() with an unread size is the whole file when n is over its size", async () => { + using dir = threeIn(); + const all = three(String(dir)).slice(-(a + b + c + 10)); + expect(await typeAndShape(new Response(all.image()))).toBe("image/png: png 16x16"); + }); + test("slice(-n) of a Bun.file() whose size was read is the last n bytes", async () => { using dir = threeIn(); const file = three(String(dir)); From e1e0fee2c9d23c172912e014d7237c19003c764b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:57:18 +0000 Subject: [PATCH 3/4] Bun.Image: read the window of a file that reports no size A regular file can report a size of 0 and have bytes (procfs). The length of a window came from that size, so a sliced body of such a file was empty. The reader now reads that file whole, as it does for a path source, and cuts the window from the bytes. New tests: a slice of fs.openAsBlob(), a slice of /proc/self/cmdline, a negative end index, and a window at the 256 MiB cap of a longer file. Two test.failing rows record a negative end index on a size that is not read yet, and structuredClone() of a slice(-n) Blob before the body. --- src/runtime/image/Image.rs | 15 +++++- test/js/bun/image/image.test.ts | 94 +++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index ad6821c123a3..3176e003ac8f 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -133,6 +133,17 @@ pub(crate) struct PathWindow { size: u64, } +impl PathWindow { + /// The window's part of `bytes`, which are the whole file. + fn cut(&self, mut bytes: Vec) -> Vec { + let start = + usize::try_from(self.offset).map_or(bytes.len(), |offset| offset.min(bytes.len())); + bytes.drain(..start); + bytes.truncate(usize::try_from(self.size).unwrap_or(usize::MAX)); + bytes + } +} + // These C++ helpers are // Image-specific (they pin/adopt typed-array storage for the off-thread // pipeline) and have no `bun_jsc` wrapper. @@ -1203,7 +1214,7 @@ impl Image { let p = ZBox::from_bytes(path.slice()); // Only an offset proves a slice: an un-sliced `Bun.file()` caches a size as well (#4930). let offset = blob.offset.get(); - // `slice(-n)` on a size that is not read yet counts back from `MAX_SIZE`: no file has that offset. + // An offset above `MAX_SIZE / 2` is not a place in a file: `slice(-n)` on a size that is not read yet counts back from `MAX_SIZE`. let source = if offset > 0 && offset <= crate::webcore::blob::MAX_SIZE / 2 { Source::PathWindow(Box::new(PathWindow { path: p, @@ -1674,6 +1685,8 @@ impl PipelineTask { return; } let read = match window { + // A regular file can report no size (procfs): its window is cut from a read of the whole file. + Some(w) if file_size == 0 => file.read_to_end().map(|bytes| w.cut(bytes)), Some(w) => read_window(&file, w.offset, len), None => file.read_to_end(), }; diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index 23f7e3497abe..1197a3ee45e4 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -1,6 +1,6 @@ import { S3Client } from "bun"; import { afterAll, describe, expect, test } from "bun:test"; -import { isMacOS, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; import fs from "node:fs"; import zlib from "node:zlib"; import { join } from "path"; @@ -236,6 +236,7 @@ describe("Bun.Image", () => { // The end of the window is the end of the input: libjpeg fills the rows it has no data for. ["slice(a, a + b - 200)", (file: Blob) => file.slice(a, a + b - 200), jpeg], ["slice(a + b, a + b + 30)", (file: Blob) => file.slice(a + b, a + b + 30), { message: "Image: decode failed" }], + ["slice(a, -c)", (file: Blob) => file.slice(a, -c), jpeg], ["slice(a, a)", (file: Blob) => file.slice(a, a), { message: UNRECOGNISED }], // A window that starts at or past the end of the file is empty. ["slice(a + b + c)", (file: Blob) => file.slice(a + b + c), { message: UNRECOGNISED }], @@ -272,6 +273,71 @@ describe("Bun.Image", () => { }); }); + test("a slice of fs.openAsBlob() as a body is the slice", async () => { + using dir = threeIn(); + const blob = await fs.openAsBlob(join(String(dir), "three.bin")); + const sliced = () => blob.slice(a, a + b).image(); + const body = await encodedBy(() => new Response(sliced())); + expect(body).toMatchObject(jpeg); + expect(body).toEqual(await encodedBy(() => sliced().blob())); + }); + + // /proc/self/cmdline is a regular file that reports no size. It holds the arguments of the process, each with a + // NUL after it. The last arguments of the child are two pictures that have only ASCII bytes, cut at each NUL. + test.skipIf(!isLinux)("a slice of a file that reports no size is the slice", async () => { + const asciiBmp = (width: number, height: number) => { + const stride = Math.ceil((width * 3) / 4) * 4; + const bmp = Buffer.alloc(54 + stride * height); + bmp.write("BM"); + bmp.writeUInt32LE(bmp.length, 2); + bmp.writeUInt32LE(54, 10); + bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(width, 18); + bmp.writeInt32LE(height, 22); + bmp.writeUInt16LE(1, 26); + bmp.writeUInt16LE(24, 28); + for (let y = 0; y < height; y++) bmp.fill(0x20, 54 + y * stride, 54 + y * stride + width * 3); + return bmp; + }; + const first = asciiBmp(3, 2); + const second = asciiBmp(2, 5); + const script = ` + import fs from "node:fs"; + const path = "/proc/self/cmdline"; + // One NUL comes after the last argument. + const start = fs.readFileSync(path).length - ${first.length + second.length + 1}; + const shape = async body => { + const { format, width, height } = await new Bun.Image(await body.bytes()).metadata(); + return format + " " + width + "x" + height; + }; + const sliced = (...window) => Bun.file(path).slice(...window).image(); + console.log(JSON.stringify({ + size: fs.statSync(path).size, + first: await shape(new Response(sliced(start))), + second: await shape(new Response(sliced(start + ${first.length}))), + secondToItsEnd: await shape(new Response(sliced(start + ${first.length}, start + ${first.length + second.length}))), + sameAsBlob: await shape(await sliced(start + ${first.length}).blob()), + })); + `; + const pictures = Buffer.concat([first, second]).toString("latin1").split("\0"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script, ...pictures], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + size: 0, + first: "png 3x2", + second: "png 2x5", + secondToItsEnd: "png 2x5", + sameAsBlob: "png 2x5", + }); + expect(exitCode).toBe(0); + }); + test("after a body, later bodies and terminals read the slice", async () => { using dir = threeIn(); const img = three(String(dir)) @@ -337,6 +403,9 @@ describe("Bun.Image", () => { expect(() => new Response(file().image())).toThrow(TOO_LONG); expect(() => new Response(new Bun.Image(path))).toThrow(TOO_LONG); await expect(new Bun.Image(path).metadata()).rejects.toThrow(TOO_LONG); + // The length of a window is measured from its offset: 2 bytes are left at the cap, and they are no picture. + const lastBytes = file().slice(cap); + expect(() => new Response(lastBytes.image())).toThrow(UNRECOGNISED); // A slice from byte 0 keeps no window (see "slice(0, n) ends the body at n"), so the cap measures the file. const fromStart = file().slice(0, 1 + cornersPng.length); expect(() => new Response(fromStart.image())).toThrow(TOO_LONG); @@ -419,8 +488,8 @@ describe("Bun.Image", () => { expect(() => new Response(empty.image())).toThrow(UNRECOGNISED); }); - // A negative index on a size that is not read yet counts back from the largest size a Blob can have, not from - // the end of the file. Its offset is in no file, so the body is the whole file. + // A negative start on a size that is not read yet counts back from the largest size a Blob can have, not from + // the end of the file. That offset is not a place in the file, so the body is the whole file. test.failing("slice(-n) of a Bun.file() with an unread size is the last n bytes", async () => { using dir = threeIn(); const last = three(String(dir)).slice(-c); @@ -433,11 +502,28 @@ describe("Bun.Image", () => { expect(await typeAndShape(new Response(all.image()))).toBe("image/png: png 16x16"); }); - test("slice(-n) of a Bun.file() whose size was read is the last n bytes", async () => { + // A step that resolves the size of that Blob, structuredClone() here, leaves it empty at the end of the file. + test.failing("slice(-n) of a Bun.file() with an unread size is the same after structuredClone()", async () => { + using dir = threeIn(); + const all = three(String(dir)).slice(-(a + b + c + 10)); + structuredClone(all); + expect(await typeAndShape(new Response(all.image()))).toBe("image/png: png 16x16"); + }); + + // A negative end on a size that is not read yet does not end the window: the body reads to the end of the file. + test.failing("slice(n, -m) of a Bun.file() with an unread size ends m bytes before the end", () => { + using dir = threeIn(); + const cut = three(String(dir)).slice(a + b, -30); + expect(() => new Response(cut.image())).toThrow("Image: decode failed"); + }); + + test("a negative index on a Bun.file() whose size was read counts back from the end of the file", async () => { using dir = threeIn(); const file = three(String(dir)); expect(file.size).toBe(a + b + c); expect(await typeAndShape(new Response(file.slice(-c).image()))).toBe("image/png: png 4x3"); + expect(await typeAndShape(new Response(file.slice(a, -c).image()))).toBe("image/jpeg: jpeg 64x64"); + expect(() => new Response(file.slice(a + b, -30).image())).toThrow("Image: decode failed"); }); }); From 67c7493ee54a0c4672308ffaadab118143cef23a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:10:56 +0000 Subject: [PATCH 4/4] test: name the open change to slice() beside the slice(-n) row of image.test.ts --- test/js/bun/image/image.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index 1197a3ee45e4..0616cca0d3ab 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -489,7 +489,7 @@ describe("Bun.Image", () => { }); // A negative start on a size that is not read yet counts back from the largest size a Blob can have, not from - // the end of the file. That offset is not a place in the file, so the body is the whole file. + // the end of the file (#41257). That offset is not a place in the file, so the body is the whole file. test.failing("slice(-n) of a Bun.file() with an unread size is the last n bytes", async () => { using dir = threeIn(); const last = three(String(dir)).slice(-c);