diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 9a9a0775bd7b..575980584b33 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -129,6 +129,8 @@ pub struct Blob { pub charset: Cell, /// Was it created via the `File` constructor? pub is_jsdom_file: Cell, + /// `size` is a caller-supplied `slice()` bound, not a `stat` hint. + pub size_is_explicit: Cell, /// `bun.ptr.RawRefCount(u32, .single_threaded)` — counts in-flight `*Blob` /// borrows handed to async readers; not the JS GC retain count. Zero while /// the JS cell is the sole owner. @@ -162,6 +164,7 @@ impl Default for Blob { content_type_was_set: Cell::new(false), charset: Cell::new(AsciiStatus::Unknown), is_jsdom_file: Cell::new(false), + size_is_explicit: Cell::new(false), ref_count: bun_ptr::RawRefCount::init(0), global_this: Cell::new(core::ptr::null()), last_modified: Cell::new(0.0), @@ -374,6 +377,7 @@ impl Blob { content_type_was_set: Cell::new(self.content_type_was_set.get()), charset: Cell::new(self.charset.get()), is_jsdom_file: Cell::new(self.is_jsdom_file.get()), + size_is_explicit: Cell::new(self.size_is_explicit.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(self.global_this.get()), last_modified: Cell::new(self.last_modified.get()), diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index b3dedd3bf6c3..9191ddbac057 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -257,6 +257,8 @@ pub trait BlobExt { fn get_size_for_bindings(&self) -> u64; fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult; fn get_size(&self, _: &JSGlobalObject) -> JSValue; + fn view_size(&self) -> SizeType; + fn stat_file_size(&self, store: &StoreRef) -> Option; fn resolve_size(&self); fn resolved_size(&self) -> (SizeType, SizeType); fn constructor(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<*mut Blob> @@ -755,11 +757,12 @@ impl BlobExt for Blob { writer.write_int_le::(stored_name.len() as u32)?; writer.write_all(stored_name)?; } else { - // Version 4: a file-backed slice's window end. Written before - // resolve_size() so an unresolved blob stays MAX_SIZE (unknown) - // on the wire and the receiver stats it locally, like v3. - writer.write_int_le::(self.size.get())?; - self.resolve_size(); + // Version 4: slice window end (MAX_SIZE = unbounded; receiver re-stats). + writer.write_int_le::(if self.size_is_explicit.get() { + self.size.get() + } else { + MAX_SIZE + })?; store.serialize(writer)?; } } @@ -1266,25 +1269,22 @@ impl BlobExt for Blob { } fn get_exists_sync(&self) -> JSValue { - if self.size.get() == MAX_SIZE { - self.resolve_size(); - } - // If there's no store that means it's empty and we just return true let Some(store) = self.store.get() else { return JSValue::TRUE; }; - if matches!(store.data, store::Data::Bytes(_)) { + match store.data_mut().tag() { // Bytes will never error - return JSValue::TRUE; + store::DataTag::Bytes => JSValue::TRUE, + store::DataTag::File => { + resolve_file_stat(store); + let file = store.data_mut().as_file(); + // We say regular files and pipes exist. + JSValue::from(bun_sys::S::ISREG(file.mode) || bun_sys::S::ISFIFO(file.mode)) + } + store::DataTag::S3 => JSValue::FALSE, } - - // We say regular files and pipes exist. - let store::Data::File(file) = &store.data else { - return JSValue::FALSE; - }; - JSValue::from(bun_sys::S::ISREG(file.mode) || bun_sys::S::ISFIFO(file.mode)) } fn do_write(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { // SAFETY: bun_vm() never returns null for a Bun-owned global. @@ -1970,6 +1970,7 @@ impl BlobExt for Blob { let blob = self.dupe(); blob.offset.set(offset); blob.size.set(len); + blob.size_is_explicit.set(len != MAX_SIZE); let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); // infer the content type if it was not specified @@ -1997,17 +1998,22 @@ impl BlobExt for Blob { // index the full fixed-3 array (args[2] is written below regardless of len). let args = &mut arguments_[..]; - if self.size.get() == 0 { + if self.size.get() == 0 && !self.is_bun_file() { let ptr = Blob::new(Blob::init_empty(global_this)); // SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force // the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`. return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) }); } + let this_size_i64 = i64::try_from(self.size.get()).expect("int cast"); + let mut neg_base: Option = None; + let mut resolve_neg = + || *neg_base.get_or_insert_with(|| i64::try_from(self.view_size()).expect("int cast")); + // If the optional start parameter is not used as a parameter, let relativeStart be 0. let mut relative_start: i64 = 0; // If the optional end parameter is not used, let relativeEnd be size. - let mut relative_end: i64 = i64::try_from(self.size.get()).expect("int cast"); + let mut relative_end: i64 = this_size_i64; // Mutate the fixed-3 args array in place to shift the string arg into [2]. if args[0].is_string() { @@ -2024,11 +2030,9 @@ impl BlobExt for Blob { if start_.is_number() { let start = start_.to_int64(); if start < 0 { - relative_start = (start - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); + relative_start = (start.wrapping_add(resolve_neg())).max(0); } else { - relative_start = start.min(i64::try_from(self.size.get()).expect("int cast")); + relative_start = start.min(this_size_i64); } } } @@ -2037,11 +2041,9 @@ impl BlobExt for Blob { if end_.is_number() { let end = end_.to_int64(); if end < 0 { - relative_end = (end - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); + relative_end = (end.wrapping_add(resolve_neg())).max(0); } else { - relative_end = end.min(i64::try_from(self.size.get()).expect("int cast")); + relative_end = end.min(this_size_i64); } } } @@ -2152,21 +2154,13 @@ impl BlobExt for Blob { fn get_last_modified(&self, _: &JSGlobalObject) -> JSValue { if let Some(store) = self.store.get() { if matches!(store.data, store::Data::File(_)) { - // do not hold a pattern-bound `&File` across - // `resolve_file_stat` — it materializes `&mut File` on the same - // memory (Stacked Borrows UB; the optimizer may legally cache the - // pre-call `last_modified` and return the stale `INIT_TIMESTAMP`). - // Re-read via `StoreRef::data_mut` (raw-ptr-backed accessor) after - // the mutating call. + resolve_file_stat(store); let last_modified = store.data_mut().as_file().last_modified; - // last_modified can be already set during read. - if last_modified == jsc::INIT_TIMESTAMP && !self.is_s3() { - resolve_file_stat(store); + // stat failed (missing file): expose 0, not the internal sentinel. + if last_modified == jsc::INIT_TIMESTAMP { + return JSValue::js_number(0.0); } - // Fresh borrow after possible mutation by `resolve_file_stat`. - return JSValue::js_number(JSValue::purify_nan( - store.data_mut().as_file().last_modified as f64, - )); + return JSValue::js_number(JSValue::purify_nan(last_modified as f64)); } } @@ -2174,28 +2168,25 @@ impl BlobExt for Blob { return JSValue::js_number(JSValue::purify_nan(self.last_modified.get())); } - JSValue::js_number(jsc::INIT_TIMESTAMP as f64) + JSValue::js_number(0.0) } fn get_size_for_bindings(&self) -> u64 { - if self.size.get() == MAX_SIZE { - self.resolve_size(); - } - - // If the file doesn't exist or is not seekable - // signal that the size is unknown. if let Some(store) = self.store.get() { - if let store::Data::File(file) = &store.data { - if !file.seekable.unwrap_or(false) { - return u64::MAX; - } + if matches!(store.data, store::Data::File(_)) { + return match self.stat_file_size(store) { + Some(s) => s, + None if self.size_is_explicit.get() => self.size.get(), + None => u64::MAX, + }; } } - + if self.size.get() == MAX_SIZE { + self.resolve_size(); + } if self.size.get() == MAX_SIZE { return u64::MAX; } - self.size.get() } fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult { @@ -2261,6 +2252,16 @@ impl BlobExt for Blob { } fn get_size(&self, _: &JSGlobalObject) -> JSValue { + if let Some(store) = self.store.get() { + if matches!(store.data, store::Data::File(_)) { + return JSValue::js_number(match self.stat_file_size(store) { + Some(s) => s as f64, + None if self.size_is_explicit.get() => self.size.get() as f64, + None if store.data_mut().as_file().seekable == Some(false) => f64::INFINITY, + None => 0.0, + }); + } + } if self.size.get() == MAX_SIZE { if self.is_s3() { return JSValue::js_number(f64::NAN); @@ -2268,19 +2269,39 @@ impl BlobExt for Blob { self.resolve_size(); if self.size.get() == MAX_SIZE && self.store.get().is_some() { return JSValue::js_number(f64::INFINITY); - } else if self.size.get() == 0 && self.store.get().is_some() { - if let store::Data::File(file) = - &self.store().expect("infallible: store present").data - { - if !file.seekable.unwrap_or(true) && file.max_size == MAX_SIZE { - return JSValue::js_number(f64::INFINITY); - } - } } } JSValue::js_number(self.size.get() as f64) } + /// Live size for the W3C slice algorithm; does not write `self.size`. + fn view_size(&self) -> SizeType { + if let Some(store) = self.store.get() { + if matches!(store.data, store::Data::File(_)) { + return self + .stat_file_size(store) + .unwrap_or_else(|| self.size.get()); + } + } + self.size.get() + } + + /// Re-stat: bytes past `self.offset` (clamped by an explicit bound), or `None`. + fn stat_file_size(&self, store: &StoreRef) -> Option { + resolve_file_stat(store); + let file = store.data_mut().as_file(); + if file.seekable.is_some() && file.max_size != MAX_SIZE { + let offset = file.max_size.min(self.offset.get()); + let available = file.max_size - offset; + return Some(if self.size_is_explicit.get() { + available.min(self.size.get()) + } else { + available + }); + } + None + } + fn resolve_size(&self) { let Some(store) = self.store.get() else { self.size.set(0); @@ -3339,6 +3360,7 @@ impl BlobExt for Blob { ), charset: Cell::new(blob.charset.get()), is_jsdom_file: Cell::new(blob.is_jsdom_file.get()), + size_is_explicit: Cell::new(blob.size_is_explicit.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(blob.global_this.get()), last_modified: Cell::new(blob.last_modified.get()), @@ -4271,6 +4293,7 @@ fn on_structured_clone_deserialize>( // resolve_size() clamps this to the actual file size on first use. if size != MAX_SIZE { blob.size.set(size as SizeType); + blob.size_is_explicit.set(true); } } if let Some(store) = blob.store.get() { @@ -6184,37 +6207,31 @@ fn resolve_file_stat(store: &StoreRef) { // `StoreRef` liveness invariant; the caller holds the only ref across // this call, so an exclusive borrow is sound. let file = store.data_mut().as_file_mut(); - match &file.pathlike { + let stat = match &file.pathlike { PathOrFileDescriptor::Path(path) => { let mut buffer = bun_paths::PathBuffer::uninit(); - match bun_sys::stat(path.slice_z(&mut buffer)) { - bun_sys::Result::Ok(stat) => { - file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 { - ((stat.st_size.max(0)) as u64) as SizeType - } else { - MAX_SIZE - }; - file.mode = stat.st_mode as bun_sys::Mode; - file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _)); - file.last_modified = stat_to_js_mtime(&stat); - } - // the file may not exist yet. That's okay. - _ => {} - } + bun_sys::stat(path.slice_z(&mut buffer)) + } + PathOrFileDescriptor::Fd(fd) => bun_sys::fstat(*fd), + }; + match stat { + bun_sys::Result::Ok(stat) => { + file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 { + ((stat.st_size.max(0)) as u64) as SizeType + } else { + MAX_SIZE + }; + file.mode = stat.st_mode as bun_sys::Mode; + file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _)); + file.last_modified = stat_to_js_mtime(&stat); + } + // stat failed: clear the cached snapshot. + _ => { + file.max_size = MAX_SIZE; + file.mode = 0; + file.seekable = None; + file.last_modified = jsc::INIT_TIMESTAMP; } - PathOrFileDescriptor::Fd(fd) => match bun_sys::fstat(*fd) { - bun_sys::Result::Ok(stat) => { - file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 { - ((stat.st_size.max(0)) as u64) as SizeType - } else { - MAX_SIZE - }; - file.mode = stat.st_mode as bun_sys::Mode; - file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _)); - file.last_modified = stat_to_js_mtime(&stat); - } - _ => {} - }, } } diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index babb222fc6f8..6315c0266339 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -1942,7 +1942,6 @@ describe("should support Content-Range with Bun.file()", () => { const badRanges = [ [10, NaN], [10, -Infinity], - [-(full.byteLength / 2) | 0, Infinity], [-(full.byteLength / 2) | 0, -Infinity], [full.byteLength + 100, full.byteLength], [full.byteLength + 100, full.byteLength + 100], @@ -1960,6 +1959,18 @@ describe("should support Content-Range with Bun.file()", () => { }); }); } + + it("negative start resolves against the live file size", async () => { + // Blob.slice(-N, Infinity) is the last N bytes per the W3C slice algorithm. + // This used to be treated as a bad range because slice() saw the unresolved + // MAX_SIZE sentinel and produced a past-EOF offset. + const start = -(full.byteLength / 2) | 0; + await getServer(async server => { + const response = await fetch(`${server.url.origin}/?start=${start}&end=Infinity`); + expect(await response.arrayBuffer()).toEqual(full.buffer.slice(start)); + expect(response.status).toBe(206); + }); + }); }); it("formats error responses correctly", async () => { diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index 6a422f38589f..a7ebfe06b2aa 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -1,6 +1,7 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import fs from "fs"; import fsPromises from "fs/promises"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isPosix, tempDir } from "harness"; import { join } from "path"; test("delete() and stat() should work with unicode paths", async () => { @@ -155,3 +156,251 @@ test("Bun.file().json() with UTF-8 BOM does not free an interior pointer", async }); expect(exitCode).toBe(0); }); + +describe("BunFile exists()/size/lastModified reflect the current filesystem state", () => { + test("exists() sees a file deleted after the first call", async () => { + using dir = tempDir("bunfile-stat-deleted", {}); + const p = join(String(dir), "a"); + fs.writeFileSync(p, "abc"); + const f = Bun.file(p); + expect(await f.exists()).toBe(true); + expect(f.size).toBe(3); + fs.unlinkSync(p); + expect({ exists: await f.exists(), size: f.size, truth: fs.existsSync(p) }).toEqual({ + exists: false, + size: 0, + truth: false, + }); + }); + + test("exists() sees a file created after the first call, and reads its contents", async () => { + using dir = tempDir("bunfile-stat-created", {}); + const p = join(String(dir), "b"); + const f = Bun.file(p); + expect(await f.exists()).toBe(false); + fs.writeFileSync(p, "content"); + expect({ exists: await f.exists(), size: f.size, text: await f.text() }).toEqual({ + exists: true, + size: 7, + text: "content", + }); + }); + + test("size and lastModified track changes to the underlying file", async () => { + using dir = tempDir("bunfile-stat-changed", {}); + const p = join(String(dir), "c"); + fs.writeFileSync(p, "0123456789"); + const f = Bun.file(p); + expect(f.size).toBe(10); + const firstMtime = f.lastModified; + fs.appendFileSync(p, "0123456789"); + fs.utimesSync(p, 1000, 2000); + expect({ size: f.size, lastModified: f.lastModified }).toEqual({ + size: fs.statSync(p).size, + lastModified: fs.statSync(p).mtimeMs, + }); + expect(f.lastModified).not.toBe(firstMtime); + }); + + test("polling exists() observes create and delete", async () => { + using dir = tempDir("bunfile-stat-poll", {}); + const p = join(String(dir), "d"); + const f = Bun.file(p); + const seen: boolean[] = []; + seen.push(await f.exists()); + fs.writeFileSync(p, "x"); + seen.push(await f.exists()); + fs.unlinkSync(p); + seen.push(await f.exists()); + fs.writeFileSync(p, "y"); + seen.push(await f.exists()); + expect(seen).toEqual([false, true, false, true]); + expect(await f.text()).toBe("y"); + }); + + test("slice() size is preserved across re-stat", async () => { + using dir = tempDir("bunfile-stat-slice", {}); + const p = join(String(dir), "e"); + fs.writeFileSync(p, "0123456789"); + const f = Bun.file(p); + const s = f.slice(0, 5); + expect(s.size).toBe(5); + expect(await s.exists()).toBe(true); + fs.appendFileSync(p, "0123456789"); + expect({ whole: f.size, slice: s.size }).toEqual({ whole: 20, slice: 5 }); + }); + + test("slice() size is preserved for non-seekable and missing files", () => { + using dir = tempDir("bunfile-stat-slice-edge", {}); + // A slice bound must survive a re-stat that cannot produce a regular-file + // size: a missing file has no stat, and a char device has no st_size. + expect(Bun.file(join(String(dir), "missing")).slice(0, 5).size).toBe(5); + if (isPosix) { + expect(Bun.file("/dev/null").slice(0, 5).size).toBe(5); + } + }); + + test("size on a deleted file does not poison a later read", async () => { + using dir = tempDir("bunfile-stat-poison", {}); + const p = join(String(dir), "f"); + fs.writeFileSync(p, "0123456789"); + const f = Bun.file(p); + expect(f.size).toBe(10); + fs.unlinkSync(p); + expect(f.size).toBe(0); + fs.writeFileSync(p, "content"); + expect(await f.text()).toBe("content"); + }); + + test("size on a shrunken file does not poison a later read", async () => { + using dir = tempDir("bunfile-stat-shrink", {}); + const p = join(String(dir), "f"); + fs.writeFileSync(p, "0123456789abcdefg"); + const f = Bun.file(p); + expect(f.size).toBe(17); + fs.truncateSync(p, 1); + expect(f.size).toBe(1); + fs.writeFileSync(p, "hello world"); + expect(await f.text()).toBe("hello world"); + }); + + test("slice() on an empty regular file keeps its file store and content-type", async () => { + using dir = tempDir("bunfile-slice-empty", {}); + const p = join(String(dir), "f"); + fs.writeFileSync(p, ""); + const s = Bun.file(p).slice(0, 5, "text/plain"); + expect({ type: s.type.split(";")[0], exists: await s.exists() }).toEqual({ + type: "text/plain", + exists: true, + }); + fs.writeFileSync(p, "hello world"); + expect(await s.text()).toBe("hello"); + }); + + test("slice() with no end stays unbounded when the file grows", async () => { + using dir = tempDir("bunfile-slice-noend", {}); + const p = join(String(dir), "f"); + fs.writeFileSync(p, "0123456789"); + const a = Bun.file(p).slice(2); + const b = Bun.file(p).slice(); + fs.appendFileSync(p, "ABCDE"); + expect({ a: await a.text(), b: await b.text() }).toEqual({ + a: "23456789ABCDE", + b: "0123456789ABCDE", + }); + }); + + test("slice() negative indices use the live file size", async () => { + using dir = tempDir("bunfile-slice-neg", {}); + const p = join(String(dir), "f"); + fs.writeFileSync(p, "BunFoo"); + const f = Bun.file(p); + expect(await f.slice(-3, 4).slice(-1, 3).text()).toBe("F"); + + fs.writeFileSync(p, "abc"); + const s = Bun.file(p).slice(0, 100); + expect({ size: s.size, last: await s.slice(-1).text() }).toEqual({ size: 3, last: "c" }); + }); + + test("expect().toHaveLength / .toBeEmpty do not poison the source blob's later read", async () => { + using dir = tempDir("bunfile-bindings-poison", {}); + const p = join(String(dir), "f"); + const f = Bun.file(p); + try { + expect(f).toBeEmpty(); + } catch {} + fs.writeFileSync(p, "content"); + expect(await f.text()).toBe("content"); + + const g = Bun.file(p); + expect(g).toHaveLength(7); + fs.appendFileSync(p, "!!!"); + expect(await g.text()).toBe("content!!!"); + + fs.writeFileSync(p, "abc"); + const s = Bun.file(p).slice(0, 100); + expect(s.size).toBe(3); + expect(s).toHaveLength(3); + }); + + test("structuredClone does not poison the source blob's later read", async () => { + using dir = tempDir("bunfile-clone-poison", {}); + const p = join(String(dir), "f"); + const f = Bun.file(p); + const fc = structuredClone(f); + fs.writeFileSync(p, "content"); + expect({ source: await f.text(), clone: await fc.text() }).toEqual({ + source: "content", + clone: "content", + }); + + const g = Bun.file(p); + const gc = structuredClone(g); + fs.appendFileSync(p, "!!!"); + expect({ source: await g.text(), clone: await gc.text() }).toEqual({ + source: "content!!!", + clone: "content!!!", + }); + + const s = Bun.file(p).slice(0, 5); + const sc = structuredClone(s); + fs.writeFileSync(p, "0123456789"); + expect({ source: await s.text(), clone: await sc.text() }).toEqual({ + source: "01234", + clone: "01234", + }); + }); + + test("a file replaced by rename reads the new inode's full contents", async () => { + using dir = tempDir("bunfile-stat-rename", {}); + const p = join(String(dir), "f"); + const tmp = join(String(dir), "f.tmp"); + fs.writeFileSync(p, "short"); + const f = Bun.file(p); + void f.size; + fs.writeFileSync(tmp, "much longer replacement content"); + fs.renameSync(tmp, p); + expect({ exists: await f.exists(), size: f.size, text: await f.text() }).toEqual({ + exists: true, + size: 31, + text: "much longer replacement content", + }); + }); + + // 2**52 - 1: the internal "not yet statted" sentinel that must never reach JS. + const LAST_MODIFIED_SENTINEL = 4503599627370495; + + test("lastModified is 0 for a path that does not exist", async () => { + using dir = tempDir("lastmodified-missing", {}); + const missing = Bun.file(join(String(dir), "does-not-exist.txt")); + expect({ exists: await missing.exists(), lastModified: missing.lastModified }).toEqual({ + exists: false, + lastModified: 0, + }); + expect(missing.lastModified).not.toBe(LAST_MODIFIED_SENTINEL); + }); + + test("lastModified: a missing file is not newer than a real one", async () => { + using dir = tempDir("lastmodified-compare", { "real.txt": "x" }); + const real = Bun.file(join(String(dir), "real.txt")); + const missing = Bun.file(join(String(dir), "nope.txt")); + expect(real.lastModified).toBeGreaterThan(0); + expect(real.lastModified).toBeLessThan(LAST_MODIFIED_SENTINEL); + expect(missing.lastModified).toBe(0); + expect(missing.lastModified < real.lastModified).toBe(true); + }); + + test("lastModified is 0 after the file is deleted", async () => { + using dir = tempDir("lastmodified-deleted", { "gone.txt": "x" }); + const path = join(String(dir), "gone.txt"); + const f = Bun.file(path); + expect(f.lastModified).toBeGreaterThan(0); + fs.unlinkSync(path); + expect(f.lastModified).toBe(0); + }); + + test("in-memory Blob.lastModified is 0, not the internal sentinel", () => { + expect(new Blob(["x"]).lastModified).toBe(0); + expect(new Blob().lastModified).toBe(0); + }); +});