From bf0d1fe91cdaecee0cfc57b8b19c738b6d88b902 Mon Sep 17 00:00:00 2001 From: Jiang Liu Date: Fri, 9 Oct 2026 17:53:19 +0800 Subject: [PATCH] core/passthrough: support the COPY_FILE_RANGE request The ABI already defines Opcode::CopyFileRange (47) with CopyFileRangeIn, and the io_uring session already knows the request length, but the server never dispatched the opcode: a copy_file_range(2) issued on the mount was always answered with ENOSYS, so the kernel disabled the op and fell back to its generic splice-based copy loop. Wire the request up following the LSEEK precedent: a copy_file_range() method on FileSystem and AsyncFileSystem with a default ENOSYS, the Arc delegation, a Linux-gated server handler replying WriteOut, and dispatch arms in the sync and async servers. PassthroughFs forwards it to the copy_file_range(2) syscall with the request's explicit offsets, so the backing files' own positions are neither read nor advanced and no locking against lseek() is needed; the requested length is capped at u32::MAX because the WriteOut reply reports the copied length as a u32. File systems that keep the default still get the documented ENOSYS-to-fallback contract, and VFS and OverlayFs are left on it, as they are for lseek. Ported from PR #220 ("Add support for copy_file_range"). Co-authored-by: EJ Campbell Signed-off-by: Jiang Liu --- .../src/api/filesystem/async_io.rs | 23 ++ .../src/api/filesystem/sync_io.rs | 41 +++ .../src/api/server/async_io.rs | 2 + .../src/api/server/sync_io.rs | 130 +++++++++ .../fuse-backend-passthrough/src/sync_io.rs | 270 ++++++++++++++++++ 5 files changed, 466 insertions(+) diff --git a/crates/fuse-backend-core/src/api/filesystem/async_io.rs b/crates/fuse-backend-core/src/api/filesystem/async_io.rs index 467526ee..1dba544a 100644 --- a/crates/fuse-backend-core/src/api/filesystem/async_io.rs +++ b/crates/fuse-backend-core/src/api/filesystem/async_io.rs @@ -801,6 +801,29 @@ pub trait AsyncFileSystem: FileSystem { Err(io::Error::from_raw_os_error(libc::ENOSYS)) } + /// Copy data from one file to another. + /// + /// The `inode_in`/`fh_in` pair identifies the source file and the `inode_out`/`fh_out` pair + /// the destination file. `offset_in` and `offset_out` are the starting offsets. `len` is the + /// maximum number of bytes to copy and the return value is the number of bytes actually + /// copied, which may be smaller than `len`, for example when the end of the source file is + /// reached. `flags` is currently unused by the kernel and must be zero. + #[allow(clippy::too_many_arguments)] + fn copy_file_range( + &self, + ctx: Context, + inode_in: Self::Inode, + fh_in: Self::Handle, + offset_in: u64, + inode_out: Self::Inode, + fh_out: Self::Handle, + offset_out: u64, + len: u64, + flags: u64, + ) -> io::Result { + Err(io::Error::from_raw_os_error(libc::ENOSYS)) + } + /// TODO: support this fn getlk(&self) -> io::Result<()> { Err(io::Error::from_raw_os_error(libc::ENOSYS)) diff --git a/crates/fuse-backend-core/src/api/filesystem/sync_io.rs b/crates/fuse-backend-core/src/api/filesystem/sync_io.rs index 317a4961..b78d28df 100644 --- a/crates/fuse-backend-core/src/api/filesystem/sync_io.rs +++ b/crates/fuse-backend-core/src/api/filesystem/sync_io.rs @@ -818,6 +818,29 @@ pub trait FileSystem { Err(io::Error::from_raw_os_error(libc::ENOSYS)) } + /// Copy data from one file to another. + /// + /// The `inode_in`/`fh_in` pair identifies the source file and the `inode_out`/`fh_out` pair + /// the destination file. `offset_in` and `offset_out` are the starting offsets. `len` is the + /// maximum number of bytes to copy and the return value is the number of bytes actually + /// copied, which may be smaller than `len`, for example when the end of the source file is + /// reached. `flags` is currently unused by the kernel and must be zero. + #[allow(clippy::too_many_arguments)] + fn copy_file_range( + &self, + ctx: &Context, + inode_in: Self::Inode, + fh_in: Self::Handle, + offset_in: u64, + inode_out: Self::Inode, + fh_out: Self::Handle, + offset_out: u64, + len: u64, + flags: u64, + ) -> io::Result { + Err(io::Error::from_raw_os_error(libc::ENOSYS)) + } + /// Query file lock status fn getlk( &self, @@ -1279,6 +1302,24 @@ impl FileSystem for Arc { self.deref().lseek(ctx, inode, handle, offset, whence) } + #[allow(clippy::too_many_arguments)] + fn copy_file_range( + &self, + ctx: &Context, + inode_in: Self::Inode, + fh_in: Self::Handle, + offset_in: u64, + inode_out: Self::Inode, + fh_out: Self::Handle, + offset_out: u64, + len: u64, + flags: u64, + ) -> io::Result { + self.deref().copy_file_range( + ctx, inode_in, fh_in, offset_in, inode_out, fh_out, offset_out, len, flags, + ) + } + /// Query file lock status fn getlk( &self, diff --git a/crates/fuse-backend-core/src/api/server/async_io.rs b/crates/fuse-backend-core/src/api/server/async_io.rs index abadc957..a59bfa41 100644 --- a/crates/fuse-backend-core/src/api/server/async_io.rs +++ b/crates/fuse-backend-core/src/api/server/async_io.rs @@ -201,6 +201,8 @@ impl Server { x if x == Opcode::Rename2 as u32 => self.rename2(ctx), #[cfg(target_os = "linux")] x if x == Opcode::Lseek as u32 => self.lseek(ctx), + #[cfg(target_os = "linux")] + x if x == Opcode::CopyFileRange as u32 => self.copy_file_range(ctx), #[cfg(feature = "virtiofs")] x if x == Opcode::SetupMapping as u32 => self.setupmapping(ctx, vu_req), #[cfg(feature = "virtiofs")] diff --git a/crates/fuse-backend-core/src/api/server/sync_io.rs b/crates/fuse-backend-core/src/api/server/sync_io.rs index fb38e036..28e24973 100644 --- a/crates/fuse-backend-core/src/api/server/sync_io.rs +++ b/crates/fuse-backend-core/src/api/server/sync_io.rs @@ -282,6 +282,8 @@ impl Server { x if x == Opcode::Rename2 as u32 => self.rename2(ctx), #[cfg(target_os = "linux")] x if x == Opcode::Lseek as u32 => self.lseek(ctx), + #[cfg(target_os = "linux")] + x if x == Opcode::CopyFileRange as u32 => self.copy_file_range(ctx), #[cfg(feature = "virtiofs")] x if x == Opcode::SetupMapping as u32 => self.setupmapping(ctx, vu_req), #[cfg(feature = "virtiofs")] @@ -1452,6 +1454,44 @@ impl Server { Err(e) => ctx.reply_error(e), } } + + #[cfg(target_os = "linux")] + pub(super) fn copy_file_range( + &self, + mut ctx: SrvContext<'_, F, S, W>, + ) -> Result { + let CopyFileRangeIn { + fh_in, + offset_in, + nodeid_out, + fh_out, + offset_out, + len, + flags, + } = ctx.r.read_obj().map_err(Error::DecodeMessage)?; + + match self.fs.copy_file_range( + ctx.context(), + ctx.nodeid(), + fh_in.into(), + offset_in, + nodeid_out.into(), + fh_out.into(), + offset_out, + len, + flags, + ) { + Ok(count) => { + let out = WriteOut { + size: count, + ..Default::default() + }; + + ctx.reply_ok(Some(out), None) + } + Err(e) => ctx.reply_error(e), + } + } } #[cfg(feature = "virtiofs")] @@ -2120,4 +2160,94 @@ mod tests { assert_eq!(server.forget(ctx).unwrap(), 0); } + + #[cfg(target_os = "linux")] + #[test] + fn test_server_copy_file_range() { + use crate::api::filesystem::Context; + + // Echoes the decoded request back as the copied size, so the whole + // decode/call/reply wiring can be verified. + struct CopyFs; + impl FileSystem for CopyFs { + type Inode = u64; + type Handle = u64; + + #[allow(clippy::too_many_arguments)] + fn copy_file_range( + &self, + _ctx: &Context, + inode_in: Self::Inode, + fh_in: Self::Handle, + offset_in: u64, + inode_out: Self::Inode, + fh_out: Self::Handle, + offset_out: u64, + len: u64, + flags: u64, + ) -> io::Result { + assert_eq!((inode_in, fh_in, offset_in), (10, 100, 4096)); + assert_eq!((inode_out, fh_out, offset_out), (20, 200, 512)); + assert_eq!((len, flags), (2048, 0)); + Ok(1024) + } + } + + let server = Server::new(CopyFs); + + let mut read_buf = [0u8; size_of::()]; + let in_arg = CopyFileRangeIn { + fh_in: 100, + offset_in: 4096, + nodeid_out: 20, + fh_out: 200, + offset_out: 512, + len: 2048, + flags: 0, + }; + read_buf.copy_from_slice(in_arg.as_slice()); + + let mut write_buf = [0u8; 4096]; + let ctx = { + let reader = Reader::<()>::from_slice(&mut read_buf); + let writer = TestWriter::new(&mut write_buf); + SrvContext::new( + InHeader { + nodeid: 10, + ..Default::default() + }, + reader, + writer, + ) + }; + + let res = server.copy_file_range(ctx).unwrap(); + assert_eq!(res, size_of::() + size_of::()); + + let mut out = WriteOut::default(); + out.as_mut_slice() + .copy_from_slice(&write_buf[size_of::()..res]); + assert_eq!(out.size, 1024); + assert_eq!(out.padding, 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_server_copy_file_range_enosys() { + // The default implementation answers ENOSYS, on which the kernel + // falls back to its generic read/write copy loop. + let server = Server::new(MockFS); + + let mut read_buf = [0u8; size_of::()]; + let mut write_buf = [0u8; 4096]; + let ctx = prepare_srvcontext(&mut read_buf, &mut write_buf); + + let res = server.copy_file_range(ctx).unwrap(); + assert_eq!(res, size_of::()); + + let mut out = OutHeader::default(); + out.as_mut_slice() + .copy_from_slice(&write_buf[..size_of::()]); + assert_eq!(out.error, -libc::ENOSYS); + } } diff --git a/crates/fuse-backend-passthrough/src/sync_io.rs b/crates/fuse-backend-passthrough/src/sync_io.rs index 236ddf85..0412a823 100644 --- a/crates/fuse-backend-passthrough/src/sync_io.rs +++ b/crates/fuse-backend-passthrough/src/sync_io.rs @@ -1749,6 +1749,55 @@ impl FileSystem for PassthroughFs { Ok(res as u64) } } + + #[cfg(target_os = "linux")] + #[allow(clippy::too_many_arguments)] + fn copy_file_range( + &self, + _ctx: &Context, + inode_in: Inode, + fh_in: Handle, + offset_in: u64, + inode_out: Inode, + fh_out: Handle, + offset_out: u64, + len: u64, + flags: u64, + ) -> io::Result { + // Keep the Arc in scope, otherwise the fds may get invalid. + let data_in = self.handle_map.get(fh_in, inode_in)?; + let data_out = self.handle_map.get(fh_out, inode_out)?; + + // The FUSE protocol carries explicit offsets and the reply only reports + // the copied length, so the backing files' own positions are neither + // read nor advanced: no need to serialize against lseek() by taking + // get_file_mut(). + let mut off_in: libc::loff_t = offset_in as libc::loff_t; + let mut off_out: libc::loff_t = offset_out as libc::loff_t; + + // The WriteOut reply reports the copied length as a u32, so never ask + // for more than can be answered; the kernel re-requests the remainder. + let len = len.min(u32::MAX as u64) as usize; + + // Safe because the only memory this modifies is the two offset locals + // which we own, and we check the return value. + let res = unsafe { + libc::syscall( + libc::SYS_copy_file_range, + data_in.get_file().as_raw_fd(), + &mut off_in as *mut libc::loff_t, + data_out.get_file().as_raw_fd(), + &mut off_out as *mut libc::loff_t, + len, + flags as libc::c_uint, + ) + }; + if res < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(res as u32) + } + } } #[cfg(test)] @@ -2122,6 +2171,227 @@ mod tests { assert_eq!(std::fs::metadata(&link_path).unwrap().nlink(), 2); } + // Copying between two files through copy_file_range(): a full copy with + // non-zero offsets on both ends, which also grows the destination, and a + // short copy truncated by the end of the source file. + // + // Built without inode file handles like test_link_regular(): under an + // unprivileged runner open_by_handle_at() would fail EPERM first and + // shadow the code under test. + #[cfg(target_os = "linux")] + #[test] + fn test_copy_file_range() { + let source = TempDir::new().expect("Cannot create temporary directory."); + let fs_cfg = Config { + root_dir: source + .as_path() + .to_str() + .expect("source path to string") + .to_string(), + ..Default::default() + }; + let fs = PassthroughFs::<()>::new(fs_cfg).unwrap(); + fs.import().unwrap(); + fs.init(FsOptions::all()).unwrap(); + let ctx = prepare_context(); + + // An 8KiB source file with recognizable content. + let content: Vec = (0..8192u32).map(|i| (i % 251) as u8).collect(); + let src_path = source.as_path().join("copy_src.bin"); + std::fs::write(&src_path, &content).unwrap(); + + let dst1_path = source.as_path().join("copy_dst1.bin"); + std::fs::write(&dst1_path, "").unwrap(); + let dst2_path = source.as_path().join("copy_dst2.bin"); + std::fs::write(&dst2_path, "").unwrap(); + + let src_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("copy_src.bin").unwrap()) + .unwrap(); + let dst1_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("copy_dst1.bin").unwrap()) + .unwrap(); + let dst2_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("copy_dst2.bin").unwrap()) + .unwrap(); + let (src_handle, _, _) = fs + .open(&ctx, src_entry.inode, libc::O_RDONLY as u32, 0) + .unwrap(); + let (dst1_handle, _, _) = fs + .open(&ctx, dst1_entry.inode, libc::O_WRONLY as u32, 0) + .unwrap(); + let (dst2_handle, _, _) = fs + .open(&ctx, dst2_entry.inode, libc::O_WRONLY as u32, 0) + .unwrap(); + + // Full copy: 2KiB from the middle of the source into the middle of + // the (empty) destination, which grows to hold the data. + let copied = fs + .copy_file_range( + &ctx, + src_entry.inode, + src_handle.unwrap(), + 4 * 1024, + dst1_entry.inode, + dst1_handle.unwrap(), + 512, + 2 * 1024, + 0, + ) + .unwrap(); + assert_eq!(copied, 2 * 1024); + + let out = std::fs::read(&dst1_path).unwrap(); + assert_eq!(out.len(), 512 + 2 * 1024); + assert!(out[..512].iter().all(|&b| b == 0)); + assert_eq!(&out[512..], &content[4 * 1024..6 * 1024]); + + // Short copy: only 1KiB is left in the source past offset 7KiB, so a + // request for 2KiB reports a truncated result. + let copied = fs + .copy_file_range( + &ctx, + src_entry.inode, + src_handle.unwrap(), + 7 * 1024, + dst2_entry.inode, + dst2_handle.unwrap(), + 0, + 2 * 1024, + 0, + ) + .unwrap(); + assert_eq!(copied, 1024); + + let out = std::fs::read(&dst2_path).unwrap(); + assert_eq!(out.len(), 1024); + assert_eq!(&out[..], &content[7 * 1024..]); + + // The source is untouched. + assert_eq!(std::fs::read(&src_path).unwrap(), content); + } + + // The WriteOut reply reports the copied length as a u32, so a request + // longer than u32::MAX bytes must not fail: the length is capped and the + // copy stays bounded by what the source holds. The kernel clamps the + // request the same way (min(len, UINT_MAX & PAGE_MASK)) before sending + // it, so this guards the trait method against direct callers. + // + // Built without inode file handles like test_link_regular(): under an + // unprivileged runner open_by_handle_at() would fail EPERM first and + // shadow the code under test. + #[cfg(target_os = "linux")] + #[test] + fn test_copy_file_range_len_capped_to_reply_size() { + let source = TempDir::new().expect("Cannot create temporary directory."); + let fs_cfg = Config { + root_dir: source + .as_path() + .to_str() + .expect("source path to string") + .to_string(), + ..Default::default() + }; + let fs = PassthroughFs::<()>::new(fs_cfg).unwrap(); + fs.import().unwrap(); + fs.init(FsOptions::all()).unwrap(); + let ctx = prepare_context(); + + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(source.as_path().join("cap_src.bin"), &content).unwrap(); + std::fs::write(source.as_path().join("cap_dst.bin"), "").unwrap(); + + let src_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("cap_src.bin").unwrap()) + .unwrap(); + let dst_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("cap_dst.bin").unwrap()) + .unwrap(); + let (src_handle, _, _) = fs + .open(&ctx, src_entry.inode, libc::O_RDONLY as u32, 0) + .unwrap(); + let (dst_handle, _, _) = fs + .open(&ctx, dst_entry.inode, libc::O_WRONLY as u32, 0) + .unwrap(); + + let copied = fs + .copy_file_range( + &ctx, + src_entry.inode, + src_handle.unwrap(), + 0, + dst_entry.inode, + dst_handle.unwrap(), + 0, + u32::MAX as u64 + 1, + 0, + ) + .unwrap(); + assert_eq!(copied, 4096); + assert_eq!( + std::fs::read(source.as_path().join("cap_dst.bin")).unwrap(), + content + ); + } + + // A handle issued for one inode must not be usable against another: the + // mismatched pair fails with EBADF before anything is copied. + #[cfg(target_os = "linux")] + #[test] + fn test_copy_file_range_handle_inode_mismatch() { + let source = TempDir::new().expect("Cannot create temporary directory."); + let fs_cfg = Config { + root_dir: source + .as_path() + .to_str() + .expect("source path to string") + .to_string(), + ..Default::default() + }; + let fs = PassthroughFs::<()>::new(fs_cfg).unwrap(); + fs.import().unwrap(); + fs.init(FsOptions::all()).unwrap(); + let ctx = prepare_context(); + + std::fs::write(source.as_path().join("mm_src.bin"), [1u8; 64]).unwrap(); + std::fs::write(source.as_path().join("mm_dst.bin"), [0u8; 64]).unwrap(); + + let src_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("mm_src.bin").unwrap()) + .unwrap(); + let dst_entry = fs + .lookup(&ctx, ROOT_ID, &CString::new("mm_dst.bin").unwrap()) + .unwrap(); + let (src_handle, _, _) = fs + .open(&ctx, src_entry.inode, libc::O_RDONLY as u32, 0) + .unwrap(); + let (dst_handle, _, _) = fs + .open(&ctx, dst_entry.inode, libc::O_WRONLY as u32, 0) + .unwrap(); + + // The source handle paired with the destination inode. + let err = fs + .copy_file_range( + &ctx, + dst_entry.inode, + src_handle.unwrap(), + 0, + dst_entry.inode, + dst_handle.unwrap(), + 0, + 64, + 0, + ) + .unwrap_err(); + assert_eq!(err.raw_os_error(), Some(libc::EBADF)); + + // Nothing was copied. + assert_eq!( + std::fs::read(source.as_path().join("mm_dst.bin")).unwrap(), + [0u8; 64] + ); + } + #[test] fn test_unlink_delete_file() { let (fs, source) = prepare_fs_tmpdir();