diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 11eb789f000a..3176e003ac8f 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,24 @@ 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, +} + +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. @@ -245,7 +265,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 +336,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 +714,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 +809,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 +1195,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 +1212,20 @@ 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(); + // 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, + 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 +1508,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 +1632,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 +1675,22 @@ 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 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; } - match file.read_to_end() { + 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(), + }; + 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..0616cca0d3ab 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 { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; +import fs from "node:fs"; import zlib from "node:zlib"; import { join } from "path"; @@ -193,6 +194,339 @@ 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, -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 }], + ["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(); + 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("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)) + .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"); + // 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); + // 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 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); + }); + + // 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 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(() => 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(). + 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 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 (#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); + 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"); + }); + + // 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"); + }); + }); + // 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