From e32c4b918ebabe3696a60276989b53d83be12d93 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 7 Jun 2026 13:10:06 +0200 Subject: [PATCH 1/7] cp: implement sparse copy support for windows --- Cargo.lock | 1 + src/uu/cp/Cargo.toml | 8 ++ src/uu/cp/src/platform/mod.rs | 15 +- src/uu/cp/src/platform/windows.rs | 218 ++++++++++++++++++++++++++++++ tests/by-util/test_cp.rs | 64 +++++++++ 5 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 src/uu/cp/src/platform/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 52abc7465b..3f792fc1c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3443,6 +3443,7 @@ dependencies = [ "thiserror 2.0.18", "uucore", "walkdir", + "windows-sys 0.61.2", ] [[package]] diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 1d248b9e93..66010ff0bd 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -47,6 +47,14 @@ selinux = { workspace = true, optional = true } exacl = { workspace = true, optional = true } nix = { workspace = true, features = ["fs"] } +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", +] } + [[bin]] name = "cp" path = "src/main.rs" diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index 2071e928f4..e9dfdb875d 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -24,13 +24,12 @@ mod linux; #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) use self::linux::copy_on_write; -#[cfg(not(any( - unix, - any(target_os = "macos", target_os = "linux", target_os = "android") -)))] +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub(crate) use self::windows::copy_on_write; + +#[cfg(not(any(unix, target_os = "windows")))] mod other; -#[cfg(not(any( - unix, - any(target_os = "macos", target_os = "linux", target_os = "android") -)))] +#[cfg(not(any(unix, target_os = "windows")))] pub(crate) use self::other::copy_on_write; diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs new file mode 100644 index 0000000000..e7204f426a --- /dev/null +++ b/src/uu/cp/src/platform/windows.rs @@ -0,0 +1,218 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore reflink ioctl Ioctl FSCTL fsctl +use std::fs::{File, OpenOptions}; +use std::io::Read; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::fs::FileExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; + +use uucore::translate; + +use windows_sys::Win32::Foundation::MAX_PATH; +use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceW, GetVolumePathNameW}; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; + +use crate::{ + CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode, +}; + +/// Fallback cluster size used when the destination volume's cluster size cannot +/// be queried. 4 KiB is the default NTFS cluster size for volumes up to 16 TB. +const DEFAULT_CLUSTER_SIZE: usize = 4096; + +/// Best-effort lookup of the cluster (allocation unit) size of the volume that +/// holds `dest`. The size is configurable at format time (512 B up to 2 MiB), so +/// it is read at runtime. Falls back to [`DEFAULT_CLUSTER_SIZE`] on failure. +fn cluster_size(dest: &Path) -> usize { + let path: Vec = dest + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Resolve the mount-point root of the volume holding `dest`; this handles + // both plain drive letters and volumes mounted on a directory. + let mut root = [0u16; MAX_PATH as usize]; + // SAFETY: `path` is a valid null-terminated wide string, and `root` with + // length `MAX_PATH` is a valid, correctly-sized output buffer. + let ok = unsafe { GetVolumePathNameW(path.as_ptr(), root.as_mut_ptr(), MAX_PATH) }; + if ok == 0 { + return DEFAULT_CLUSTER_SIZE; + } + + let mut sectors_per_cluster: u32 = 0; + let mut bytes_per_sector: u32 = 0; + let mut free_clusters: u32 = 0; + let mut total_clusters: u32 = 0; + // SAFETY: `root` is a valid null-terminated wide string produced by + // `GetVolumePathNameW`; the four out-params are valid `u32` pointers. + let ok = unsafe { + GetDiskFreeSpaceW( + root.as_ptr(), + &raw mut sectors_per_cluster, + &raw mut bytes_per_sector, + &raw mut free_clusters, + &raw mut total_clusters, + ) + }; + if ok == 0 { + return DEFAULT_CLUSTER_SIZE; + } + + match sectors_per_cluster as usize * bytes_per_sector as usize { + 0 => DEFAULT_CLUSTER_SIZE, + cluster => cluster, + } +} + +/// Buffer (and hole-detection) size for the sparse copy of `dest`. +/// +/// NTFS deallocates sparse space in "compression units" of 16 clusters when the +/// cluster size is 4 KiB or smaller — e.g. 64 KiB on the 4 KiB default, which is +/// why a sparse copy's allocated ranges land on 64 KiB boundaries. For larger +/// clusters NTFS has no compression unit and the granularity is the cluster +/// itself. Matching that granularity makes each read as large as possible while +/// never writing — and so never forcing allocation of — a region that could have +/// stayed a hole. The result is capped so a volume with very large clusters does +/// not allocate an oversized buffer; a smaller buffer still yields exact holes, +/// just with more reads. +fn sparse_block_size(dest: &Path) -> usize { + /// Clusters per NTFS compression unit (only for cluster sizes <= 4 KiB). + const COMPRESSION_UNIT_CLUSTERS: usize = 16; + /// Largest cluster size that uses compression units; above this the sparse + /// granularity is the cluster size itself. + const MAX_COMPRESSED_CLUSTER: usize = 4096; + /// Upper bound on the buffer so large clusters don't allocate excessively. + const MAX_BLOCK_SIZE: usize = 1024 * 1024; + + let cluster = cluster_size(dest); + let granularity = if cluster <= MAX_COMPRESSED_CLUSTER { + cluster.saturating_mul(COMPRESSION_UNIT_CLUSTERS) + } else { + cluster + }; + granularity.min(MAX_BLOCK_SIZE) +} + +/// Flag `file` as sparse using the Windows `FSCTL_SET_SPARSE` control code. +/// +/// Once a file is marked sparse, regions within its length that are never +/// written are not allocated on disk and read back as zeros. +fn set_sparse(file: &File) -> std::io::Result<()> { + let mut bytes_returned: u32 = 0; + // SAFETY: `file.as_raw_handle()` is a valid, open file handle owned by `file`. + // `FSCTL_SET_SPARSE` takes no input or output buffer, so the buffer pointers + // are null with zero lengths; `bytes_returned` is a valid out-parameter. + let ok = unsafe { + DeviceIoControl( + // `as_raw_handle()` yields the std `*mut c_void`; `.cast()` reinterprets + // it as the windows-sys `HANDLE` without an identity `as` pointer cast + // (which clippy::ptr_as_ptr flags). + file.as_raw_handle().cast(), + FSCTL_SET_SPARSE, + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + &raw mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// Perform a sparse copy from `source` to `dest` for `--sparse=always`. +/// +/// The destination is flagged sparse, sized to match the source, and only the +/// blocks of `source` that contain at least one non-zero byte are written; runs +/// of zeros are left as holes. This mirrors the Linux `sparse_copy` path. +/// +/// If the destination filesystem does not support sparse files (e.g. FAT), the +/// `FSCTL_SET_SPARSE` call fails and we fall back to a full byte-for-byte copy +/// so that the zero runs are written out as real zeros rather than left as +/// undefined extended ranges. +fn sparse_copy(source: &Path, dest: &Path) -> std::io::Result<()> { + let mut src_file = File::open(source)?; + let dst_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(dest)?; + + let size = src_file.metadata()?.len(); + + if set_sparse(&dst_file).is_err() { + // Sparse files unsupported here: do a plain copy so the result is still + // a faithful copy of the source. + std::io::copy(&mut src_file, &mut &dst_file)?; + return Ok(()); + } + + dst_file.set_len(size)?; + + // Detect holes at the destination volume's sparse allocation granularity. + let mut buf = vec![0u8; sparse_block_size(dest)]; + let mut offset: u64 = 0; + loop { + let read = src_file.read(&mut buf)?; + if read == 0 { + break; + } + let chunk = &buf[..read]; + // Only write blocks that contain data; unwritten ranges remain holes. + if chunk.iter().any(|&b| b != 0) { + dst_file.seek_write(chunk, offset)?; + } + offset += read as u64; + } + Ok(()) +} + +/// Copies `source` to `dest`, honoring `--sparse` on Windows. +/// +/// Windows has no copy-on-write reflink support, so any `--reflink` other than +/// the default `never` is rejected. Sparse copies are implemented via the +/// `FSCTL_SET_SPARSE` device control. +pub(crate) fn copy_on_write( + source: &Path, + dest: &Path, + reflink_mode: ReflinkMode, + sparse_mode: SparseMode, + context: &str, +) -> CopyResult { + if reflink_mode != ReflinkMode::Never { + return Err(translate!("cp-error-reflink-not-supported") + .to_string() + .into()); + } + + let mut copy_debug = CopyDebug { + offload: OffloadReflinkDebug::Unsupported, + reflink: OffloadReflinkDebug::Unsupported, + sparse_detection: SparseDebug::Unsupported, + }; + + match sparse_mode { + SparseMode::Always => { + copy_debug.sparse_detection = SparseDebug::Zeros; + sparse_copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + } + // `--sparse=auto` (the default) and `--sparse=never` perform a plain copy + // that never introduces holes. (`auto` would additionally preserve + // already-sparse sources, which is not detected here yet.) + SparseMode::Auto | SparseMode::Never => { + std::fs::copy(source, dest) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + } + } + + Ok(copy_debug) +} diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 7261ef51c9..13206b0b9f 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2786,6 +2786,70 @@ fn test_cp_sparse_never_reflink_always() { .fails(); } +// Regression test for https://github.com/uutils/coreutils/issues/12186 +// `cp --sparse=always` should be supported on Windows (matching GNU), not +// rejected with "--sparse is only supported on linux". +#[cfg(target_os = "windows")] +#[test] +fn test_cp_sparse_always_windows() { + const BUFFER_SIZE: usize = 4096 * 16 + 3; + let (at, mut ucmd) = at_and_ucmd!(); + + // A file with long runs of zeros: a candidate for sparse copying. + let mut buf = vec![0; BUFFER_SIZE].into_boxed_slice(); + let bytes_to_touch = [buf.len() / 3, 2 * (buf.len() / 3)]; + for i in bytes_to_touch { + buf[i] = b'x'; + } + + at.make_file("src_file1"); + at.write_bytes("src_file1", &buf); + + ucmd.args(&["--sparse=always", "src_file1", "dst_file_sparse"]) + .succeeds(); + + // The copy must be byte-for-byte identical to the source... + assert_eq!(at.read_bytes("dst_file_sparse").into_boxed_slice(), buf); + + // ...and the destination must actually be flagged sparse (proving the + // FSCTL_SET_SPARSE path ran rather than a plain copy). The temp dir used by + // the test harness is on NTFS, which supports sparse files. + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; + assert_ne!( + at.metadata("dst_file_sparse").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "destination should have the sparse file attribute set" + ); +} + +// Companion to the regression above: `cp --sparse=never` must also be accepted +// on Windows and produce a faithful, non-sparse copy (it was rejected by the +// same "--sparse is only supported on linux" error before #12186). +#[cfg(target_os = "windows")] +#[test] +fn test_cp_sparse_never_windows() { + const BUFFER_SIZE: usize = 4096 * 4; + let (at, mut ucmd) = at_and_ucmd!(); + + let buf: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE]; + at.make_file("src_file1"); + at.write_bytes("src_file1", &buf); + + ucmd.args(&["--sparse=never", "src_file1", "dst_file_non_sparse"]) + .succeeds(); + + assert_eq!(at.read_bytes("dst_file_non_sparse"), buf); + + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; + assert_eq!( + at.metadata("dst_file_non_sparse").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "destination must not be sparse with --sparse=never" + ); +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(feature = "truncate")] #[test] From 412b5d01c2358ce6a67a86fc67bd34500dbd04bc Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 7 Jun 2026 13:48:00 +0200 Subject: [PATCH 2/7] cp: preserve sparse source files with --sparse=auto on Windows --- src/uu/cp/src/platform/windows.rs | 30 ++++++++++++++--- tests/by-util/test_cp.rs | 55 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index e7204f426a..dcade1df23 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -6,14 +6,16 @@ use std::fs::{File, OpenOptions}; use std::io::Read; use std::os::windows::ffi::OsStrExt; -use std::os::windows::fs::FileExt; +use std::os::windows::fs::{FileExt, MetadataExt}; use std::os::windows::io::AsRawHandle; use std::path::Path; use uucore::translate; use windows_sys::Win32::Foundation::MAX_PATH; -use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceW, GetVolumePathNameW}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_SPARSE_FILE, GetDiskFreeSpaceW, GetVolumePathNameW, +}; use windows_sys::Win32::System::IO::DeviceIoControl; use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; @@ -176,6 +178,13 @@ fn sparse_copy(source: &Path, dest: &Path) -> std::io::Result<()> { Ok(()) } +/// Whether `path` is already a sparse file (has the sparse file attribute set). +fn is_sparse(path: &Path) -> bool { + std::fs::metadata(path) + .map(|m| m.file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE != 0) + .unwrap_or(false) +} + /// Copies `source` to `dest`, honoring `--sparse` on Windows. /// /// Windows has no copy-on-write reflink support, so any `--reflink` other than @@ -205,9 +214,20 @@ pub(crate) fn copy_on_write( copy_debug.sparse_detection = SparseDebug::Zeros; sparse_copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; } - // `--sparse=auto` (the default) and `--sparse=never` perform a plain copy - // that never introduces holes. (`auto` would additionally preserve - // already-sparse sources, which is not detected here yet.) + // `--sparse=auto` (the default) preserves holes only when the source is + // already sparse, matching GNU. A sparse source is re-copied sparsely; + // anything else is a plain copy that never introduces new holes. + // + // `sparse_copy` re-derives holes by scanning for zero runs rather than + // mirroring the source's exact allocated-range layout. The content is + // identical and the result is sparse; a byte-exact hole layout would + // instead query `FSCTL_QUERY_ALLOCATED_RANGES`, which is out of scope. + SparseMode::Auto if is_sparse(source) => { + copy_debug.sparse_detection = SparseDebug::Zeros; + sparse_copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + } + // `--sparse=auto` on a non-sparse source, and `--sparse=never`, perform a + // plain copy that never introduces holes. SparseMode::Auto | SparseMode::Never => { std::fs::copy(source, dest) .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 13206b0b9f..5a83c4781d 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2850,6 +2850,61 @@ fn test_cp_sparse_never_windows() { ); } +// `--sparse=auto` (the default) must preserve an already-sparse source on Windows, +// matching GNU. Before this fix the default mode did a plain copy that dropped the +// source's holes. A non-sparse source must still copy plainly (no new holes). +#[cfg(target_os = "windows")] +#[test] +fn test_cp_sparse_auto_preserves_sparse_source_windows() { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; + const BUFFER_SIZE: usize = 1024 * 1024; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // A 1 MiB file with data only near the start and end: a clear sparse candidate. + let mut buf = vec![0u8; BUFFER_SIZE].into_boxed_slice(); + buf[1024] = b'x'; + buf[BUFFER_SIZE - 2048] = b'y'; + at.make_file("src"); + at.write_bytes("src", &buf); + + // Seed a genuinely sparse source via the already-supported --sparse=always. + scene + .ucmd() + .args(&["--sparse=always", "src", "sparse_src"]) + .succeeds(); + assert_ne!( + at.metadata("sparse_src").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "precondition: seeded source should be sparse" + ); + + // Default mode is --sparse=auto: an already-sparse source must stay sparse. + scene.ucmd().args(&["sparse_src", "auto_dst"]).succeeds(); + assert_eq!( + at.read_bytes("auto_dst").into_boxed_slice(), + buf, + "auto copy must be byte-for-byte identical to the source" + ); + assert_ne!( + at.metadata("auto_dst").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "--sparse=auto should preserve an already-sparse source" + ); + + // A non-sparse source copied with the default mode must NOT become sparse. + at.make_file("plain_src"); + at.write_bytes("plain_src", &buf); + scene.ucmd().args(&["plain_src", "plain_dst"]).succeeds(); + assert_eq!( + at.metadata("plain_dst").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "--sparse=auto must not make a non-sparse source sparse" + ); +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(feature = "truncate")] #[test] From cb717eb2fd5ccd82ccdc34fff638922b0ccdc927 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 7 Jun 2026 13:52:08 +0200 Subject: [PATCH 3/7] cspell: add FSCTL and NTFS to dictionary --- .vscode/cspell.dictionaries/acronyms+names.wordlist.txt | 2 ++ src/uu/cp/src/platform/windows.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt b/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt index 5a6e69f0f8..75ffe637b0 100644 --- a/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt +++ b/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt @@ -14,6 +14,7 @@ FIFO FIFOs flac FQDN # fully qualified domain name +FSCTL # Windows file system control code GID # group ID GIDs GNU @@ -26,6 +27,7 @@ lzma MSRV # minimum supported rust version MSVC NixOS +NTFS # Windows NT file system POSIX POSIXLY ReiserFS diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index dcade1df23..bdbb812372 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore reflink ioctl Ioctl FSCTL fsctl +// spell-checker:ignore reflink ioctl Ioctl use std::fs::{File, OpenOptions}; use std::io::Read; use std::os::windows::ffi::OsStrExt; From 1a63dc8386baa0fce6a1491edcb2144be29c05e2 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 7 Jun 2026 14:04:37 +0200 Subject: [PATCH 4/7] cp: fix clippy 1.96 warnings in Windows sparse code --- src/uu/cp/src/platform/windows.rs | 4 +--- tests/by-util/test_cp.rs | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index bdbb812372..3aea72e8dc 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -180,9 +180,7 @@ fn sparse_copy(source: &Path, dest: &Path) -> std::io::Result<()> { /// Whether `path` is already a sparse file (has the sparse file attribute set). fn is_sparse(path: &Path) -> bool { - std::fs::metadata(path) - .map(|m| m.file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE != 0) - .unwrap_or(false) + std::fs::metadata(path).is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE != 0) } /// Copies `source` to `dest`, honoring `--sparse` on Windows. diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 5a83c4781d..f5ad9a74f6 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2792,7 +2792,9 @@ fn test_cp_sparse_never_reflink_always() { #[cfg(target_os = "windows")] #[test] fn test_cp_sparse_always_windows() { + use std::os::windows::fs::MetadataExt; const BUFFER_SIZE: usize = 4096 * 16 + 3; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; let (at, mut ucmd) = at_and_ucmd!(); // A file with long runs of zeros: a candidate for sparse copying. @@ -2814,8 +2816,6 @@ fn test_cp_sparse_always_windows() { // ...and the destination must actually be flagged sparse (proving the // FSCTL_SET_SPARSE path ran rather than a plain copy). The temp dir used by // the test harness is on NTFS, which supports sparse files. - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; assert_ne!( at.metadata("dst_file_sparse").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, 0, @@ -2829,7 +2829,9 @@ fn test_cp_sparse_always_windows() { #[cfg(target_os = "windows")] #[test] fn test_cp_sparse_never_windows() { + use std::os::windows::fs::MetadataExt; const BUFFER_SIZE: usize = 4096 * 4; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; let (at, mut ucmd) = at_and_ucmd!(); let buf: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE]; @@ -2841,8 +2843,6 @@ fn test_cp_sparse_never_windows() { assert_eq!(at.read_bytes("dst_file_non_sparse"), buf); - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; assert_eq!( at.metadata("dst_file_non_sparse").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, 0, From de695fea302d539af0847148d01c8f099d917c9c Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 5 Jul 2026 19:31:19 +0200 Subject: [PATCH 5/7] uucore: add safe Windows wrappers for volume info and sparse files --- src/uucore/Cargo.toml | 2 + src/uucore/src/lib/features/fs.rs | 159 +++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 5d71efba51..f797b5dc1e 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -124,6 +124,8 @@ windows-sys = { workspace = true, optional = true, default-features = false, fea "Wdk_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_Foundation", + "Win32_System_IO", + "Win32_System_Ioctl", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_WindowsProgramming", diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 7cd9041dd6..ff3205a504 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -5,7 +5,7 @@ //! Set of functions to manage regular files, special files, and links. -// spell-checker:ignore backport +// spell-checker:ignore backport Ioctl #[cfg(all(unix, not(target_os = "redox")))] pub use libc::{major, makedev, minor}; @@ -22,9 +22,21 @@ use std::io::{Error, ErrorKind, Result as IOResult}; use std::os::fd::AsFd; #[cfg(unix)] use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::{Component, MAIN_SEPARATOR, Path, PathBuf}; #[cfg(target_os = "windows")] use winapi_util::AsHandleRef; +#[cfg(windows)] +use windows_sys::Win32::Foundation::MAX_PATH; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceW, GetVolumePathNameW}; +#[cfg(windows)] +use windows_sys::Win32::System::IO::DeviceIoControl; +#[cfg(windows)] +use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; /// Used to check if the `mode` has its `perm` bit set. /// @@ -865,6 +877,112 @@ pub mod sane_blksize { } } +/// Disk geometry of a volume, as reported by the Windows `GetDiskFreeSpaceW` +/// API. Cluster counts are in clusters, not bytes. +#[cfg(windows)] +#[derive(Clone, Copy, Debug)] +pub struct DiskFreeSpace { + pub sectors_per_cluster: u32, + pub bytes_per_sector: u32, + pub free_clusters: u32, + pub total_clusters: u32, +} + +/// Safe wrapper around the Windows `GetVolumePathNameW` API. +/// +/// Returns the mount-point root of the volume holding `path` (e.g. `C:\` or +/// `C:\mount\`), handling both plain drive letters and volumes mounted on a +/// directory. +#[cfg(windows)] +pub fn volume_path_name(path: &Path) -> IOResult { + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // The returned mount point is a prefix of the (absolutized) input, so + // sizing the buffer to the input — with the documented MAX_PATH + 1 + // minimum — means it cannot be too small, even for long paths. + let mut root = vec![0u16; wide.len().max(MAX_PATH as usize + 1)]; + // SAFETY: `wide` is a valid null-terminated wide string, and `root` is a + // valid output buffer of `root.len()` u16s. + let ok = unsafe { GetVolumePathNameW(wide.as_ptr(), root.as_mut_ptr(), root.len() as u32) }; + if ok == 0 { + return Err(Error::last_os_error()); + } + let len = root.iter().position(|&c| c == 0).unwrap_or(root.len()); + Ok(PathBuf::from(OsString::from_wide(&root[..len]))) +} + +/// Safe wrapper around the Windows `GetDiskFreeSpaceW` API for the volume +/// rooted at `root` (as returned by [`volume_path_name`]). +/// +/// The trailing separator the API requires on drive and UNC roots is appended +/// if missing. +#[cfg(windows)] +pub fn disk_free_space(root: &Path) -> IOResult { + let mut wide: Vec = root.as_os_str().encode_wide().collect(); + if !matches!(wide.last(), Some(&sep) if sep == u16::from(b'\\') || sep == u16::from(b'/')) { + wide.push(u16::from(b'\\')); + } + wide.push(0); + + let mut info = DiskFreeSpace { + sectors_per_cluster: 0, + bytes_per_sector: 0, + free_clusters: 0, + total_clusters: 0, + }; + // SAFETY: `wide` is a valid null-terminated wide string; the four + // out-params are valid `u32` pointers. + let ok = unsafe { + GetDiskFreeSpaceW( + wide.as_ptr(), + &raw mut info.sectors_per_cluster, + &raw mut info.bytes_per_sector, + &raw mut info.free_clusters, + &raw mut info.total_clusters, + ) + }; + if ok == 0 { + return Err(Error::last_os_error()); + } + Ok(info) +} + +/// Flag `file` as sparse using the Windows `FSCTL_SET_SPARSE` control code. +/// +/// Once a file is marked sparse, regions within its length that are never +/// written are not allocated on disk and read back as zeros. On filesystems +/// without sparse support the call fails with `ERROR_INVALID_FUNCTION` or +/// `ERROR_NOT_SUPPORTED`. +#[cfg(windows)] +pub fn set_file_sparse(file: &fs::File) -> IOResult<()> { + let mut bytes_returned: u32 = 0; + // SAFETY: `file.as_raw_handle()` is a valid, open file handle owned by `file`. + // `FSCTL_SET_SPARSE` takes no input or output buffer, so the buffer pointers + // are null with zero lengths; `bytes_returned` is a valid out-parameter. + let ok = unsafe { + DeviceIoControl( + // `as_raw_handle()` yields the std `*mut c_void`; `.cast()` reinterprets + // it as the windows-sys `HANDLE` without an identity `as` pointer cast + // (which clippy::ptr_as_ptr flags). + file.as_raw_handle().cast(), + FSCTL_SET_SPARSE, + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + &raw mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(Error::last_os_error()); + } + Ok(()) +} + /// Extracts the filename component from the given `file` path and returns it as an `Option<&str>`. /// /// If the `file` path contains a filename, this function returns `Some(filename)` where `filename` is @@ -1175,4 +1293,43 @@ mod tests { let file_path = PathBuf::from("~/foo.txt"); assert!(matches!(get_filename(&file_path), Some("foo.txt"))); } + + #[cfg(windows)] + #[test] + fn test_volume_path_name() { + let temp = env::temp_dir(); + let root = volume_path_name(&temp).unwrap(); + assert!(path_ends_with_terminator(&root)); + assert!(temp.starts_with(&root)); + } + + #[cfg(windows)] + #[test] + fn test_disk_free_space() { + let root = volume_path_name(&env::temp_dir()).unwrap(); + let info = disk_free_space(&root).unwrap(); + assert!(info.sectors_per_cluster > 0); + assert!(info.bytes_per_sector > 0); + + // The trailing separator required by the underlying API is appended + // when missing, so a bare drive path works too. + let stripped = root + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .to_owned(); + let info = disk_free_space(Path::new(&stripped)).unwrap(); + assert!(info.bytes_per_sector > 0); + } + + #[cfg(windows)] + #[test] + fn test_set_file_sparse() { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_SPARSE_FILE; + + let file = tempfile::NamedTempFile::new().unwrap(); + set_file_sparse(file.as_file()).unwrap(); + let attributes = file.as_file().metadata().unwrap().file_attributes(); + assert_ne!(attributes & FILE_ATTRIBUTE_SPARSE_FILE, 0); + } } From a5f5f390eb7c4d5d1d042859cad245b704987e33 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 5 Jul 2026 19:31:57 +0200 Subject: [PATCH 6/7] cp: address review feedback on Windows sparse copy --- src/uu/cp/Cargo.toml | 2 - src/uu/cp/src/platform/windows.rs | 253 ++++++++++++++++++------------ 2 files changed, 150 insertions(+), 105 deletions(-) diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 66010ff0bd..cf412e89be 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -51,8 +51,6 @@ nix = { workspace = true, features = ["fs"] } windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", - "Win32_System_IO", - "Win32_System_Ioctl", ] } [[bin]] diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index 3aea72e8dc..ded4ad8631 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -2,22 +2,16 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore reflink ioctl Ioctl +// spell-checker:ignore reflink use std::fs::{File, OpenOptions}; use std::io::Read; -use std::os::windows::ffi::OsStrExt; -use std::os::windows::fs::{FileExt, MetadataExt}; -use std::os::windows::io::AsRawHandle; +use std::os::windows::fs::{FileExt, MetadataExt, OpenOptionsExt}; use std::path::Path; use uucore::translate; -use windows_sys::Win32::Foundation::MAX_PATH; -use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_SPARSE_FILE, GetDiskFreeSpaceW, GetVolumePathNameW, -}; -use windows_sys::Win32::System::IO::DeviceIoControl; -use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; +use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED}; +use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_SPARSE_FILE, FILE_SHARE_READ}; use crate::{ CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode, @@ -27,49 +21,14 @@ use crate::{ /// be queried. 4 KiB is the default NTFS cluster size for volumes up to 16 TB. const DEFAULT_CLUSTER_SIZE: usize = 4096; -/// Best-effort lookup of the cluster (allocation unit) size of the volume that -/// holds `dest`. The size is configurable at format time (512 B up to 2 MiB), so -/// it is read at runtime. Falls back to [`DEFAULT_CLUSTER_SIZE`] on failure. -fn cluster_size(dest: &Path) -> usize { - let path: Vec = dest - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - +/// Cluster (allocation unit) size of the volume that holds `dest`. The size is +/// configurable at format time (512 B up to 2 MiB), so it is read at runtime. +fn cluster_size(dest: &Path) -> std::io::Result { // Resolve the mount-point root of the volume holding `dest`; this handles // both plain drive letters and volumes mounted on a directory. - let mut root = [0u16; MAX_PATH as usize]; - // SAFETY: `path` is a valid null-terminated wide string, and `root` with - // length `MAX_PATH` is a valid, correctly-sized output buffer. - let ok = unsafe { GetVolumePathNameW(path.as_ptr(), root.as_mut_ptr(), MAX_PATH) }; - if ok == 0 { - return DEFAULT_CLUSTER_SIZE; - } - - let mut sectors_per_cluster: u32 = 0; - let mut bytes_per_sector: u32 = 0; - let mut free_clusters: u32 = 0; - let mut total_clusters: u32 = 0; - // SAFETY: `root` is a valid null-terminated wide string produced by - // `GetVolumePathNameW`; the four out-params are valid `u32` pointers. - let ok = unsafe { - GetDiskFreeSpaceW( - root.as_ptr(), - &raw mut sectors_per_cluster, - &raw mut bytes_per_sector, - &raw mut free_clusters, - &raw mut total_clusters, - ) - }; - if ok == 0 { - return DEFAULT_CLUSTER_SIZE; - } - - match sectors_per_cluster as usize * bytes_per_sector as usize { - 0 => DEFAULT_CLUSTER_SIZE, - cluster => cluster, - } + let root = uucore::fs::volume_path_name(dest)?; + let info = uucore::fs::disk_free_space(&root)?; + Ok(info.sectors_per_cluster as usize * info.bytes_per_sector as usize) } /// Buffer (and hole-detection) size for the sparse copy of `dest`. @@ -82,7 +41,8 @@ fn cluster_size(dest: &Path) -> usize { /// never writing — and so never forcing allocation of — a region that could have /// stayed a hole. The result is capped so a volume with very large clusters does /// not allocate an oversized buffer; a smaller buffer still yields exact holes, -/// just with more reads. +/// just with more reads. The block size is a granularity knob, not a correctness +/// input, so a failed cluster-size query falls back to [`DEFAULT_CLUSTER_SIZE`]. fn sparse_block_size(dest: &Path) -> usize { /// Clusters per NTFS compression unit (only for cluster sizes <= 4 KiB). const COMPRESSION_UNIT_CLUSTERS: usize = 16; @@ -92,7 +52,10 @@ fn sparse_block_size(dest: &Path) -> usize { /// Upper bound on the buffer so large clusters don't allocate excessively. const MAX_BLOCK_SIZE: usize = 1024 * 1024; - let cluster = cluster_size(dest); + let cluster = cluster_size(dest) + .ok() + .filter(|&c| c != 0) + .unwrap_or(DEFAULT_CLUSTER_SIZE); let granularity = if cluster <= MAX_COMPRESSED_CLUSTER { cluster.saturating_mul(COMPRESSION_UNIT_CLUSTERS) } else { @@ -101,61 +64,88 @@ fn sparse_block_size(dest: &Path) -> usize { granularity.min(MAX_BLOCK_SIZE) } -/// Flag `file` as sparse using the Windows `FSCTL_SET_SPARSE` control code. +/// Read from `reader` until `buf` is full or EOF is reached, returning the +/// number of bytes read. /// -/// Once a file is marked sparse, regions within its length that are never -/// written are not allocated on disk and read back as zeros. -fn set_sparse(file: &File) -> std::io::Result<()> { - let mut bytes_returned: u32 = 0; - // SAFETY: `file.as_raw_handle()` is a valid, open file handle owned by `file`. - // `FSCTL_SET_SPARSE` takes no input or output buffer, so the buffer pointers - // are null with zero lengths; `bytes_returned` is a valid out-parameter. - let ok = unsafe { - DeviceIoControl( - // `as_raw_handle()` yields the std `*mut c_void`; `.cast()` reinterprets - // it as the windows-sys `HANDLE` without an identity `as` pointer cast - // (which clippy::ptr_as_ptr flags). - file.as_raw_handle().cast(), - FSCTL_SET_SPARSE, - std::ptr::null(), - 0, - std::ptr::null_mut(), - 0, - &raw mut bytes_returned, - std::ptr::null_mut(), - ) - }; - if ok == 0 { - return Err(std::io::Error::last_os_error()); +/// Like `read_exact`, but EOF is not an error. Filling the whole buffer keeps +/// the hole-detection blocks of [`sparse_copy`] aligned to the sparse +/// allocation granularity; a short read would misalign every subsequent block. +/// The `Interrupted` arm mirrors std's own read loops, although Windows file +/// I/O does not produce it. +fn read_full(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result { + let mut total = 0; + while total < buf.len() { + match reader.read(&mut buf[total..]) { + Ok(0) => break, + Ok(n) => total += n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(total) +} + +/// Write the whole `buf` to `file` at `offset`, looping over partial writes. +/// +/// The Windows-only `seek_write` has no `write_all`-style counterpart (unlike +/// the Unix `write_all_at`), so dropped tails on partial writes are handled +/// here. The `Interrupted` arm mirrors std's own write loops, although Windows +/// file I/O does not produce it. +fn write_all_at(file: &File, mut buf: &[u8], mut offset: u64) -> std::io::Result<()> { + while !buf.is_empty() { + match file.seek_write(buf, offset) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "failed to write whole buffer", + )); + } + Ok(n) => { + buf = &buf[n..]; + offset += n as u64; + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } } Ok(()) } -/// Perform a sparse copy from `source` to `dest` for `--sparse=always`. +/// Perform a sparse copy from the open `src_file` to `dest` for +/// `--sparse=always`. /// /// The destination is flagged sparse, sized to match the source, and only the -/// blocks of `source` that contain at least one non-zero byte are written; runs -/// of zeros are left as holes. This mirrors the Linux `sparse_copy` path. +/// blocks of the source that contain at least one non-zero byte are written; +/// runs of zeros are left as holes. This mirrors the Linux `sparse_copy` path. /// /// If the destination filesystem does not support sparse files (e.g. FAT), the -/// `FSCTL_SET_SPARSE` call fails and we fall back to a full byte-for-byte copy -/// so that the zero runs are written out as real zeros rather than left as -/// undefined extended ranges. -fn sparse_copy(source: &Path, dest: &Path) -> std::io::Result<()> { - let mut src_file = File::open(source)?; +/// `FSCTL_SET_SPARSE` call fails with `ERROR_INVALID_FUNCTION` or +/// `ERROR_NOT_SUPPORTED` and we fall back to a full byte-for-byte copy so that +/// the zero runs are written out as real zeros. Any other error is propagated. +fn sparse_copy(src_file: &mut File, dest: &Path) -> std::io::Result<()> { let dst_file = OpenOptions::new() .write(true) .create(true) .truncate(true) + // Deny external writers and deleters while the copy is in progress so + // the destination cannot be corrupted mid-copy; readers are unaffected. + .share_mode(FILE_SHARE_READ) .open(dest)?; let size = src_file.metadata()?.len(); - if set_sparse(&dst_file).is_err() { + match uucore::fs::set_file_sparse(&dst_file) { + Ok(()) => {} // Sparse files unsupported here: do a plain copy so the result is still // a faithful copy of the source. - std::io::copy(&mut src_file, &mut &dst_file)?; - return Ok(()); + Err(e) + if e.raw_os_error() == Some(ERROR_INVALID_FUNCTION as i32) + || e.raw_os_error() == Some(ERROR_NOT_SUPPORTED as i32) => + { + std::io::copy(src_file, &mut &dst_file)?; + return Ok(()); + } + Err(e) => return Err(e), } dst_file.set_len(size)?; @@ -164,23 +154,26 @@ fn sparse_copy(source: &Path, dest: &Path) -> std::io::Result<()> { let mut buf = vec![0u8; sparse_block_size(dest)]; let mut offset: u64 = 0; loop { - let read = src_file.read(&mut buf)?; + let read = read_full(src_file, &mut buf)?; if read == 0 { break; } let chunk = &buf[..read]; // Only write blocks that contain data; unwritten ranges remain holes. if chunk.iter().any(|&b| b != 0) { - dst_file.seek_write(chunk, offset)?; + write_all_at(&dst_file, chunk, offset)?; } offset += read as u64; } Ok(()) } -/// Whether `path` is already a sparse file (has the sparse file attribute set). -fn is_sparse(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE != 0) +/// Whether the open `file` has the Windows sparse attribute set. +/// +/// Checked on the handle rather than the path so the decision applies to the +/// same file that is subsequently copied. +fn is_sparse(file: &File) -> std::io::Result { + Ok(file.metadata()?.file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE != 0) } /// Copies `source` to `dest`, honoring `--sparse` on Windows. @@ -207,10 +200,12 @@ pub(crate) fn copy_on_write( sparse_detection: SparseDebug::Unsupported, }; + let context_err = |e| CpError::IoErrContext(e, context.to_owned()); match sparse_mode { SparseMode::Always => { copy_debug.sparse_detection = SparseDebug::Zeros; - sparse_copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + let mut src_file = File::open(source).map_err(context_err)?; + sparse_copy(&mut src_file, dest).map_err(context_err)?; } // `--sparse=auto` (the default) preserves holes only when the source is // already sparse, matching GNU. A sparse source is re-copied sparsely; @@ -220,17 +215,69 @@ pub(crate) fn copy_on_write( // mirroring the source's exact allocated-range layout. The content is // identical and the result is sparse; a byte-exact hole layout would // instead query `FSCTL_QUERY_ALLOCATED_RANGES`, which is out of scope. - SparseMode::Auto if is_sparse(source) => { - copy_debug.sparse_detection = SparseDebug::Zeros; - sparse_copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + SparseMode::Auto => { + let mut src_file = File::open(source).map_err(context_err)?; + if is_sparse(&src_file).map_err(context_err)? { + copy_debug.sparse_detection = SparseDebug::Zeros; + sparse_copy(&mut src_file, dest).map_err(context_err)?; + } else { + std::fs::copy(source, dest).map_err(context_err)?; + } } - // `--sparse=auto` on a non-sparse source, and `--sparse=never`, perform a - // plain copy that never introduces holes. - SparseMode::Auto | SparseMode::Never => { - std::fs::copy(source, dest) - .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + // `--sparse=never` performs a plain copy that never introduces holes. + SparseMode::Never => { + std::fs::copy(source, dest).map_err(context_err)?; } } Ok(copy_debug) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + /// A reader that returns at most 3 bytes per `read` call, exercising the + /// partial-read handling of `read_full`. + struct ShortReader<'a>(&'a [u8]); + + impl Read for ShortReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.0.len().min(buf.len()).min(3); + buf[..n].copy_from_slice(&self.0[..n]); + self.0 = &self.0[n..]; + Ok(n) + } + } + + #[test] + fn read_full_fills_buffer_across_short_reads() { + let data: Vec = (0..=255).collect(); + let mut reader = ShortReader(&data); + let mut buf = [0u8; 256]; + assert_eq!(read_full(&mut reader, &mut buf).unwrap(), 256); + assert_eq!(&buf[..], &data[..]); + } + + #[test] + fn read_full_returns_partial_count_at_eof() { + let data = [7u8; 10]; + let mut reader = ShortReader(&data); + let mut buf = [0u8; 256]; + assert_eq!(read_full(&mut reader, &mut buf).unwrap(), 10); + assert_eq!(&buf[..10], &data[..]); + assert_eq!(read_full(&mut reader, &mut buf).unwrap(), 0); + } + + #[test] + fn write_all_at_writes_at_offset() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("write_all_at"); + let file = File::create(&path).unwrap(); + write_all_at(&file, b"data", 8).unwrap(); + drop(file); + let contents = std::fs::read(&path).unwrap(); + assert_eq!(contents, b"\0\0\0\0\0\0\0\0data"); + } +} From 0ac1b00f7c90f195e292532cc5868e7b5e137387 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Sun, 5 Jul 2026 19:36:12 +0200 Subject: [PATCH 7/7] cspell: ignore misalign, deleters and absolutized --- src/uu/cp/src/platform/windows.rs | 2 +- src/uucore/src/lib/features/fs.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index ded4ad8631..216d2e1b25 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore reflink +// spell-checker:ignore reflink misalign deleters use std::fs::{File, OpenOptions}; use std::io::Read; use std::os::windows::fs::{FileExt, MetadataExt, OpenOptionsExt}; diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index ff3205a504..afa87d2772 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -5,7 +5,7 @@ //! Set of functions to manage regular files, special files, and links. -// spell-checker:ignore backport Ioctl +// spell-checker:ignore backport Ioctl absolutized #[cfg(all(unix, not(target_os = "redox")))] pub use libc::{major, makedev, minor};