diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index b3a6a52daae4..18e5de0680be 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -129,6 +129,11 @@ pub struct Blob { pub charset: Cell, /// Was it created via the `File` constructor? pub is_jsdom_file: Cell, + /// Set when `size` is a window the caller asked for with `slice()`. A + /// file-backed blob otherwise holds `MAX_SIZE` there until something + /// (`.size`, `exists()`, ...) caches the file's stat size into it, and a + /// cached stat size must not be mistaken for a window. + 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 +167,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), @@ -375,6 +381,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 006cf841e312..98d38c10610f 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -780,10 +780,15 @@ 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())?; + // Version 4: the window end of a sliced file-backed blob. An + // unsliced blob puts MAX_SIZE on the wire even once it has + // cached a stat size, so the receiver stats the file itself + // (like v3) instead of turning that stale size into a window. + writer.write_int_le::(if self.size_is_explicit.get() { + self.size.get() + } else { + MAX_SIZE + })?; self.resolve_size(); store.serialize(writer)?; } @@ -1948,6 +1953,9 @@ impl BlobExt for Blob { let blob = self.dupe(); blob.offset.set(offset); blob.size.set(len); + // `slice()` of a still-unresolved file blob yields `MAX_SIZE`, which + // is not a window. + 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 @@ -3305,6 +3313,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()), @@ -4210,6 +4219,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() { @@ -4685,6 +4695,15 @@ pub(crate) fn write_file_with_source_destination( } // If this is file <> file, we can just copy the file else if destination_type == store::DataTag::File && source_type == store::DataTag::File { + // Only a slice() window on the destination bounds the copy. Otherwise + // `size` is at most the stat size that `.size` / `exists()` cached, and + // passing it would cut the copy to the destination's old length (0 when + // it did not exist yet) instead of replacing the file. + let max_length = if destination_blob.size_is_explicit.get() { + destination_blob.size.get() + } else { + MAX_SIZE + }; #[cfg(windows)] { return Ok(copy_file::CopyFileWindows::init( @@ -4692,7 +4711,7 @@ pub(crate) fn write_file_with_source_destination( source_store, ctx.bun_vm().event_loop_shared(), options.mkdirp_if_not_exists.unwrap_or(true), - destination_blob.size.get(), + max_length, options.mode, )); } @@ -4702,7 +4721,7 @@ pub(crate) fn write_file_with_source_destination( destination_store, source_store, destination_blob.offset.get(), - destination_blob.size.get(), + max_length, ctx, options.mkdirp_if_not_exists.unwrap_or(true), options.mode, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index f02bdf4f8e17..1ee914ecabda 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,6 +7,7 @@ import { exampleSite, gcTick, isASAN, + isLinux, isWindows, tempDir, withoutAggressiveGC, @@ -275,6 +276,140 @@ const IS_UV_FS_COPYFILE_DISABLED = } }); + // The file -> file copy took the destination blob's `size` as the number of + // bytes to copy. On an unsliced Bun.file() that field only holds the stat + // size cached by `.size` / `exists()`, so the copy was cut to the + // destination's previous length: 0 bytes for the `exists()` guard in #4930. + describe("Bun.file -> Bun.file is not capped by the destination's cached size", () => { + const source = Buffer.alloc(100_000, "S").toString(); + + // uv_fs_copyfile (Windows) and fcopyfile over an existing file (macOS) + // resolve with 0 regardless of this fix, so only Linux checks the count. + function expectCopiedWhole(destPath, written) { + expect(fs.statSync(destPath).size).toBe(source.length); + expect(fs.readFileSync(destPath, "utf8")).toBe(source); + if (isLinux) expect(written).toBe(source.length); + } + + // Each primer caches the destination's current size onto a blob and + // returns the blob to write to. The cached size lives on the blob, so the + // clone and the whole-file slice have to be the destination themselves. + const primers = [ + [ + "f.size", + (f, size) => { + expect(f.size).toBe(size); + return f; + }, + ], + [ + "await f.exists()", + async f => { + expect(await f.exists()).toBe(true); + return f; + }, + ], + [ + "expect(f).toHaveLength()", + (f, size) => { + expect(f).toHaveLength(size); + return f; + }, + ], + [ + "structuredClone(f), which stats f while serializing it", + f => { + structuredClone(f); + return f; + }, + ], + [ + "f.size, writing to structuredClone(f)", + (f, size) => { + expect(f.size).toBe(size); + return structuredClone(f); + }, + ], + [ + "f.slice().size, writing to the whole-file slice", + (f, size) => { + const whole = f.slice(); + expect(whole.size).toBe(size); + return whole; + }, + ], + ]; + + describe.each([ + ["shorter", "short"], + ["empty", ""], + ])("%s existing destination", (_, existing) => { + it.each(primers)("primed by %s", async (_, prime) => { + using dir = tempDir("bun-write-dest-size-cached", { "src.bin": source, "dest.bin": existing }); + const destPath = join(String(dir), "dest.bin"); + const dest = await prime(Bun.file(destPath), existing.length); + + const written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + expectCopiedWhole(destPath, written); + }); + }); + + it("primed by exists() on a destination that does not exist yet (#4930)", async () => { + using dir = tempDir("bun-write-dest-exists-guard", { "in.txt": source }); + const outPath = join(String(dir), "out.txt"); + const out = Bun.file(outPath); + expect(await out.exists()).toBe(false); + + const written = await Bun.write(out, Bun.file(join(String(dir), "in.txt"))); + expectCopiedWhole(outPath, written); + }); + + it("primed by .size on an fd destination", async () => { + using dir = tempDir("bun-write-fd-dest-size-cached", { "src.bin": source, "dest.bin": "short" }); + const destPath = join(String(dir), "dest.bin"); + const fd = fs.openSync(destPath, "r+"); + let written; + try { + const dest = Bun.file(fd); + expect(dest.size).toBe(5); + written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + } finally { + fs.closeSync(fd); + } + expectCopiedWhole(destPath, written); + }); + + // The cached size is also wrong in the other direction: the Windows copy + // padded the shorter copy back out to it. + it("primed by .size, a shorter source replaces a longer destination", async () => { + using dir = tempDir("bun-write-dest-shrinks", { + "src.bin": "tiny", + "dest.bin": Buffer.alloc(1000, "D").toString(), + }); + const destPath = join(String(dir), "dest.bin"); + const dest = Bun.file(destPath); + expect(dest.size).toBe(1000); + + const written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + expect(fs.readFileSync(destPath, "utf8")).toBe("tiny"); + if (isLinux) expect(written).toBe(4); + }); + + // A window the caller asked for with slice() still bounds the copy, and + // survives structuredClone. + it.each([ + ["f.slice(0, 4)", f => f.slice(0, 4)], + ["structuredClone(f.slice(0, 4))", f => structuredClone(f.slice(0, 4))], + ])("a destination sliced with %s is still bounded by the slice", async (_, slice) => { + using dir = tempDir("bun-write-dest-window", { "src.bin": source, "dest.bin": "0123456789" }); + const destPath = join(String(dir), "dest.bin"); + + const written = await Bun.write(slice(Bun.file(destPath)), Bun.file(join(String(dir), "src.bin"))); + expect(fs.readFileSync(destPath, "utf8")).toBe("SSSS"); + if (isLinux) expect(written).toBe(4); + }); + }); + it("Bun.file", async () => { const file = path.join(import.meta.dir, "fetch.js.txt"); await gcTick();