From add27283c56fbc73a9d57ef619110a2280f19efd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:06:30 +0000 Subject: [PATCH] shell mv: trim the preallocated destination to the bytes copied on the cross-device fallback The EXDEV fallback preallocates the destination to the source's st_size and then copies to EOF. A source that holds fewer bytes than st_size (it shrank during the move, or it is a sysfs file) left the destination at st_size with a zero-filled tail, and the source was then unlinked. Truncate the destination to the bytes copied before the unlink. --- src/runtime/shell/builtin/mv.rs | 10 ++++++++++ test/js/bun/shell/commands/mv.test.ts | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/runtime/shell/builtin/mv.rs b/src/runtime/shell/builtin/mv.rs index ef318c0037e0..249f5107a9cb 100644 --- a/src/runtime/shell/builtin/mv.rs +++ b/src/runtime/shell/builtin/mv.rs @@ -612,6 +612,16 @@ impl ShellMvBatchedTask { let _ = bun_sys::unlinkat(dst_dir, dst); return Err(e); } + // The source can hold fewer bytes than `st_size` said (it shrank, or it + // is a sysfs file). The copy advanced the source position by the bytes + // it copied; FICLONE does not move it, so fall back to the source size. + let copied = match bun_sys::lseek(in_.fd(), 0, libc::SEEK_CUR) { + Ok(pos) if pos > 0 => pos, + _ => bun_sys::fstat(in_.fd()).map_or(st.st_size as i64, |s| s.st_size as i64), + }; + if copied < st.st_size as i64 { + let _ = bun_sys::ftruncate(out.fd(), copied); + } #[cfg(unix)] { // `fchown` first: Linux clears S_ISUID/S_ISGID on chown. diff --git a/test/js/bun/shell/commands/mv.test.ts b/test/js/bun/shell/commands/mv.test.ts index 4389044ae861..dbb1e39b80a6 100644 --- a/test/js/bun/shell/commands/mv.test.ts +++ b/test/js/bun/shell/commands/mv.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; import { describe, expect, test } from "bun:test"; -import { isPosix } from "harness"; +import { isPosix, tempDir } from "harness"; import { accessSync, chmodSync, @@ -118,6 +118,24 @@ describe("mv", async () => { } }); + // The copy fallback preallocates the destination to the source's st_size. + // A sysfs file reports st_size 4096 but holds a few bytes, so the + // destination must shrink to the bytes copied. The unlink of the sysfs + // source fails, so mv exits non-zero, but the copy has already happened. + const sysfsFile = "/sys/devices/system/cpu/online"; + const hasSysfs = process.platform === "linux" && existsSync(sysfsFile); + test.skipIf(!hasSysfs)("across devices, the destination is the bytes copied, not st_size", async () => { + using dst = tempDir("bun-mv-xdev-short-read", {}); + const content = readFileSync(sysfsFile); + expect(content.length).toBeGreaterThan(0); + expect(statSync(sysfsFile).size).toBeGreaterThan(content.length); + const dstFile = join(String(dst), "online"); + + const r = await $`mv ${sysfsFile} ${dstFile}`.quiet(); + expect(r.exitCode).not.toBe(0); + expect(readFileSync(dstFile)).toEqual(content); + }); + test.skipIf(skip)("file -> directory across devices", async () => { const [src, dst] = crossDevicePair("into-dir"); try {