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/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..cf412e89be 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -47,6 +47,12 @@ 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", +] } + [[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..216d2e1b25 --- /dev/null +++ b/src/uu/cp/src/platform/windows.rs @@ -0,0 +1,283 @@ +// 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 misalign deleters +use std::fs::{File, OpenOptions}; +use std::io::Read; +use std::os::windows::fs::{FileExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; + +use uucore::translate; + +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, +}; + +/// 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; + +/// 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 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`. +/// +/// 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. 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; + /// 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) + .ok() + .filter(|&c| c != 0) + .unwrap_or(DEFAULT_CLUSTER_SIZE); + let granularity = if cluster <= MAX_COMPRESSED_CLUSTER { + cluster.saturating_mul(COMPRESSION_UNIT_CLUSTERS) + } else { + cluster + }; + granularity.min(MAX_BLOCK_SIZE) +} + +/// Read from `reader` until `buf` is full or EOF is reached, returning the +/// number of bytes read. +/// +/// 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 the open `src_file` to `dest` for +/// `--sparse=always`. +/// +/// The destination is flagged sparse, sized to match the source, and only the +/// 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 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(); + + 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. + 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)?; + + // 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 = 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) { + write_all_at(&dst_file, chunk, offset)?; + } + offset += read as u64; + } + Ok(()) +} + +/// 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. +/// +/// 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, + }; + + let context_err = |e| CpError::IoErrContext(e, context.to_owned()); + match sparse_mode { + SparseMode::Always => { + copy_debug.sparse_detection = SparseDebug::Zeros; + 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; + // 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 => { + 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=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"); + } +} 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..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 +// spell-checker:ignore backport Ioctl absolutized #[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); + } } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 7261ef51c9..f5ad9a74f6 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2786,6 +2786,125 @@ 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() { + 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. + 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. + 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() { + 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]; + 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); + + assert_eq!( + at.metadata("dst_file_non_sparse").file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0, + "destination must not be sparse with --sparse=never" + ); +} + +// `--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]