Skip to main content

coreutils_rs/cp/
core.rs

1use std::io;
2use std::path::Path;
3
4#[cfg(unix)]
5use std::os::unix::fs::MetadataExt;
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8
9// FICLONE support cache: avoids repeated failed ioctl attempts on non-reflink filesystems.
10// NOTE: this is per-process with no filesystem identity — it assumes all copies within a
11// single invocation target the same destination filesystem. A cross-filesystem recursive
12// copy (e.g. btrfs + ext4 mount points) may suppress FICLONE on the reflink-capable fs
13// after a failure on the non-reflink fs. This matches GNU cp's practical usage pattern
14// where --reflink=auto targets a single destination tree.
15#[cfg(target_os = "linux")]
16static FICLONE_UNSUPPORTED: std::sync::atomic::AtomicBool =
17    std::sync::atomic::AtomicBool::new(false);
18
19/// How to dereference (follow) symbolic links.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DerefMode {
22    /// Never follow symlinks (copy the link itself).
23    Never,
24    /// Follow symlinks given on the command line, but not encountered during recursion.
25    CommandLine,
26    /// Always follow symlinks.
27    Always,
28}
29
30/// Backup strategy, following GNU `--backup` semantics.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BackupMode {
33    /// Numbered backups (~1~, ~2~, ...).
34    Numbered,
35    /// Numbered if numbered backups already exist, otherwise simple.
36    Existing,
37    /// Simple backup with suffix.
38    Simple,
39    /// Never make backups.
40    None,
41}
42
43/// Reflink (copy-on-write clone) strategy.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ReflinkMode {
46    /// Try reflink, fall back to normal copy.
47    Auto,
48    /// Require reflink; fail if not supported.
49    Always,
50    /// Never attempt reflink.
51    Never,
52}
53
54/// Configuration for a cp invocation.
55pub struct CpConfig {
56    pub recursive: bool,
57    pub force: bool,
58    pub interactive: bool,
59    pub no_clobber: bool,
60    pub verbose: bool,
61    pub preserve_mode: bool,
62    pub preserve_ownership: bool,
63    pub preserve_timestamps: bool,
64    pub dereference: DerefMode,
65    pub link: bool,
66    pub symbolic_link: bool,
67    pub update: bool,
68    pub one_file_system: bool,
69    pub backup: Option<BackupMode>,
70    pub suffix: String,
71    pub reflink: ReflinkMode,
72    pub target_directory: Option<String>,
73    pub no_target_directory: bool,
74}
75
76impl Default for CpConfig {
77    fn default() -> Self {
78        Self {
79            recursive: false,
80            force: false,
81            interactive: false,
82            no_clobber: false,
83            verbose: false,
84            preserve_mode: false,
85            preserve_ownership: false,
86            preserve_timestamps: false,
87            dereference: DerefMode::CommandLine,
88            link: false,
89            symbolic_link: false,
90            update: false,
91            one_file_system: false,
92            backup: None,
93            suffix: "~".to_string(),
94            reflink: ReflinkMode::Auto,
95            target_directory: None,
96            no_target_directory: false,
97        }
98    }
99}
100
101/// Parse a `--backup=CONTROL` value.
102pub fn parse_backup_mode(s: &str) -> Result<BackupMode, String> {
103    match s {
104        "none" | "off" => Ok(BackupMode::None),
105        "numbered" | "t" => Ok(BackupMode::Numbered),
106        "existing" | "nil" => Ok(BackupMode::Existing),
107        "simple" | "never" => Ok(BackupMode::Simple),
108        _ => Err(format!("invalid backup type '{}'", s)),
109    }
110}
111
112/// Parse a `--reflink[=WHEN]` value.
113pub fn parse_reflink_mode(s: &str) -> Result<ReflinkMode, String> {
114    match s {
115        "auto" => Ok(ReflinkMode::Auto),
116        "always" => Ok(ReflinkMode::Always),
117        "never" => Ok(ReflinkMode::Never),
118        _ => Err(format!("invalid reflink value '{}'", s)),
119    }
120}
121
122/// Parse a `--preserve[=LIST]` attribute list.
123///
124/// Supports: mode, ownership, timestamps, links, context, xattr, all.
125pub fn apply_preserve(list: &str, config: &mut CpConfig) {
126    for attr in list.split(',') {
127        match attr.trim() {
128            "mode" => config.preserve_mode = true,
129            "ownership" => config.preserve_ownership = true,
130            "timestamps" => config.preserve_timestamps = true,
131            "links" | "context" | "xattr" => { /* acknowledged but not yet implemented */ }
132            "all" => {
133                config.preserve_mode = true;
134                config.preserve_ownership = true;
135                config.preserve_timestamps = true;
136            }
137            _ => {}
138        }
139    }
140}
141
142// ---- backup helpers ----
143
144/// Create a backup of `dst` if it exists, according to the configured backup mode.
145/// Returns `Ok(())` when no backup is needed or the backup was made successfully.
146fn make_backup(dst: &Path, config: &CpConfig) -> io::Result<()> {
147    let mode = match config.backup {
148        Some(m) => m,
149        None => return Ok(()),
150    };
151    if mode == BackupMode::None {
152        return Ok(());
153    }
154    if !dst.exists() {
155        return Ok(());
156    }
157
158    let backup_path = match mode {
159        BackupMode::Simple | BackupMode::None => {
160            let mut p = dst.as_os_str().to_os_string();
161            p.push(&config.suffix);
162            std::path::PathBuf::from(p)
163        }
164        BackupMode::Numbered => numbered_backup_path(dst),
165        BackupMode::Existing => {
166            // Use numbered if any numbered backup already exists.
167            let numbered = numbered_backup_candidate(dst, 1);
168            if numbered.exists() {
169                numbered_backup_path(dst)
170            } else {
171                let mut p = dst.as_os_str().to_os_string();
172                p.push(&config.suffix);
173                std::path::PathBuf::from(p)
174            }
175        }
176    };
177
178    std::fs::rename(dst, &backup_path)?;
179    Ok(())
180}
181
182fn numbered_backup_path(dst: &Path) -> std::path::PathBuf {
183    let mut n: u64 = 1;
184    loop {
185        let candidate = numbered_backup_candidate(dst, n);
186        if !candidate.exists() {
187            return candidate;
188        }
189        n += 1;
190    }
191}
192
193fn numbered_backup_candidate(dst: &Path, n: u64) -> std::path::PathBuf {
194    let mut p = dst.as_os_str().to_os_string();
195    p.push(format!(".~{}~", n));
196    std::path::PathBuf::from(p)
197}
198
199// ---- attribute preservation ----
200
201/// Preserve file attributes (mode, timestamps, ownership) on `dst` using
202/// pre-fetched source metadata (avoids redundant stat calls).
203fn preserve_attributes_from_meta(
204    meta: &std::fs::Metadata,
205    dst: &Path,
206    config: &CpConfig,
207) -> io::Result<()> {
208    // Only chmod when -p/--preserve=mode is set. Without it, the destination
209    // keeps its O_CREAT permissions (source_mode & ~umask), matching GNU cp.
210    #[cfg(unix)]
211    if config.preserve_mode {
212        let mode = meta.mode();
213        std::fs::set_permissions(dst, std::fs::Permissions::from_mode(mode))?;
214    }
215
216    #[cfg(unix)]
217    if config.preserve_timestamps {
218        let atime_spec = libc::timespec {
219            tv_sec: meta.atime(),
220            tv_nsec: meta.atime_nsec(),
221        };
222        let mtime_spec = libc::timespec {
223            tv_sec: meta.mtime(),
224            tv_nsec: meta.mtime_nsec(),
225        };
226        let times = [atime_spec, mtime_spec];
227        // SAFETY: CString::new checks for interior NULs; the path is valid UTF-8/bytes.
228        let c_path = std::ffi::CString::new(dst.as_os_str().as_encoded_bytes())
229            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
230        // SAFETY: c_path is a valid NUL-terminated C string, times is a valid [timespec; 2].
231        let ret = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
232        if ret != 0 {
233            return Err(io::Error::last_os_error());
234        }
235    }
236
237    #[cfg(unix)]
238    if config.preserve_ownership {
239        // SAFETY: CString::new checks for interior NULs; the path is valid bytes.
240        let c_path = std::ffi::CString::new(dst.as_os_str().as_encoded_bytes())
241            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
242        // SAFETY: c_path is a valid NUL-terminated C string, uid/gid are valid u32 values.
243        let ret = unsafe { libc::lchown(c_path.as_ptr(), meta.uid(), meta.gid()) };
244        if ret != 0 {
245            // Ownership preservation may fail for non-root; ignore EPERM.
246            let err = io::Error::last_os_error();
247            if err.raw_os_error() != Some(libc::EPERM) {
248                return Err(err);
249            }
250        }
251    }
252
253    // Suppress unused-variable warnings on non-unix platforms.
254    #[cfg(not(unix))]
255    {
256        let _ = (meta, config);
257    }
258
259    Ok(())
260}
261
262// ---- large-buffer fallback copy ----
263
264/// Copy file data using a thread-local buffer (up to 4MB, capped to file size).
265/// Avoids stdlib's 64KB default buffer and amortizes allocation across files.
266/// Creates the destination with `src_mode` so the kernel applies the process umask.
267/// Used on non-Linux platforms; Linux uses `copy_data_linux` instead.
268#[cfg(not(target_os = "linux"))]
269fn copy_data_large_buf(src: &Path, dst: &Path, src_len: u64, src_mode: u32) -> io::Result<()> {
270    use std::cell::RefCell;
271    use std::io::{Read, Write};
272    const MAX_BUF: usize = 4 * 1024 * 1024; // 4 MB
273    /// Shrink the thread-local buffer when it exceeds this size and the current
274    /// file needs much less, to avoid holding 4 MB per Rayon thread permanently.
275    const SHRINK_THRESHOLD: usize = 512 * 1024; // 512 KB
276
277    thread_local! {
278        static BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
279    }
280
281    // Safe on 32-bit: clamp via u64 before casting to usize.
282    let buf_size = src_len.min(MAX_BUF as u64).max(8192) as usize;
283
284    let mut reader = std::fs::File::open(src)?;
285    let mut opts = std::fs::OpenOptions::new();
286    opts.write(true).create(true).truncate(true);
287    #[cfg(unix)]
288    {
289        use std::os::unix::fs::OpenOptionsExt;
290        opts.mode(src_mode);
291    }
292    #[cfg(not(unix))]
293    let _ = src_mode;
294    let mut writer = opts.open(dst)?;
295
296    BUF.with(|cell| {
297        let mut buf = cell.borrow_mut();
298        // Shrink if buffer is much larger than needed to limit per-thread memory.
299        if buf.len() > SHRINK_THRESHOLD && buf_size < buf.len() / 4 {
300            buf.resize(buf_size, 0);
301            buf.shrink_to_fit();
302        } else if buf.len() < buf_size {
303            buf.resize(buf_size, 0);
304        }
305        loop {
306            let n = reader.read(&mut buf[..buf_size])?;
307            if n == 0 {
308                break;
309            }
310            writer.write_all(&buf[..n])?;
311        }
312        Ok(())
313    })
314}
315
316// ---- Linux single-open cascade copy ----
317//
318// Opens src and dst once, then tries FICLONE → copy_file_range → read/write
319// on the same file descriptors. Eliminates redundant open/close/stat syscalls
320// that the old code paid when FICLONE failed on non-reflink filesystems.
321
322#[cfg(target_os = "linux")]
323fn copy_data_linux(src: &Path, dst: &Path, config: &CpConfig) -> io::Result<()> {
324    use std::os::unix::fs::OpenOptionsExt;
325    use std::os::unix::io::AsRawFd;
326
327    let src_file = std::fs::File::open(src)?;
328    let src_fd = src_file.as_raw_fd();
329
330    // Use fstat on the opened fd (not src_meta) to get the real file size and mode.
331    // src_meta may come from symlink_metadata, giving the symlink path length
332    // instead of the target file size when dereference != Always.
333    let fd_meta = src_file.metadata()?;
334    let len = fd_meta.len();
335
336    let dst_file = std::fs::OpenOptions::new()
337        .write(true)
338        .create(true)
339        .truncate(true)
340        .mode(fd_meta.mode())
341        .open(dst)?;
342    let dst_fd = dst_file.as_raw_fd();
343
344    // Hint sequential access for kernel readahead (benefits copy_file_range and read/write).
345    // posix_fadvise is advisory; failure (e.g. ESPIPE for pipes) is harmless.
346    unsafe {
347        let _ = libc::posix_fadvise(src_fd, 0, 0, libc::POSIX_FADV_SEQUENTIAL);
348    }
349
350    // Step 1: Try FICLONE (instant CoW clone on btrfs/XFS).
351    if matches!(config.reflink, ReflinkMode::Auto | ReflinkMode::Always) {
352        const FICLONE: libc::c_ulong = 0x40049409;
353        let should_try = config.reflink == ReflinkMode::Always
354            || !FICLONE_UNSUPPORTED.load(std::sync::atomic::Ordering::Relaxed);
355
356        if should_try {
357            // SAFETY: src_fd and dst_fd are valid open file descriptors.
358            let ret = unsafe { libc::ioctl(dst_fd, FICLONE, src_fd) };
359            if ret == 0 {
360                return Ok(());
361            }
362            let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0);
363            if config.reflink == ReflinkMode::Always {
364                return Err(io::Error::new(
365                    io::ErrorKind::Unsupported,
366                    format!(
367                        "failed to clone '{}' to '{}': {}",
368                        src.display(),
369                        dst.display(),
370                        io::Error::from_raw_os_error(errno)
371                    ),
372                ));
373            }
374            if matches!(errno, libc::EOPNOTSUPP | libc::ENOTTY | libc::ENOSYS) {
375                FICLONE_UNSUPPORTED.store(true, std::sync::atomic::Ordering::Relaxed);
376            }
377            if errno == libc::EXDEV {
378                // Cross-device: copy_file_range will also fail with EXDEV;
379                // skip directly to read/write (posix_fadvise already issued above).
380                return readwrite_with_buffer(src_file, dst_file, len);
381            }
382            // Auto mode: fall through to copy_file_range on the same fds.
383        }
384    }
385
386    // Step 2: Try copy_file_range (zero-copy in kernel, same fds).
387    let mut remaining = match i64::try_from(len) {
388        Ok(v) => v,
389        // File too large for copy_file_range offset arithmetic; skip to read/write.
390        Err(_) => return readwrite_with_buffer(src_file, dst_file, len),
391    };
392    let mut cfr_failed = false;
393    while remaining > 0 {
394        let to_copy = (remaining as u64).min(isize::MAX as u64) as usize;
395        // SAFETY: src_fd and dst_fd are valid open file descriptors;
396        // null offsets use and update the kernel file position.
397        let ret = unsafe {
398            libc::syscall(
399                libc::SYS_copy_file_range,
400                src_fd,
401                std::ptr::null_mut::<libc::off64_t>(),
402                dst_fd,
403                std::ptr::null_mut::<libc::off64_t>(),
404                to_copy,
405                0u32,
406            )
407        };
408        if ret < 0 {
409            let err = io::Error::last_os_error();
410            if matches!(
411                err.raw_os_error(),
412                Some(libc::EINVAL | libc::ENOSYS | libc::EXDEV)
413            ) {
414                cfr_failed = true;
415                break;
416            }
417            return Err(err);
418        }
419        if ret == 0 {
420            if remaining > 0 {
421                // Source file shrank during copy — report rather than silently truncate.
422                return Err(io::Error::new(
423                    io::ErrorKind::UnexpectedEof,
424                    "source file shrank during copy",
425                ));
426            }
427            break;
428        }
429        remaining -= ret as i64;
430    }
431    if !cfr_failed {
432        return Ok(());
433    }
434
435    // Step 3: Fallback — read/write on the same fds with large buffer.
436    // Reset file positions since copy_file_range may have partially transferred.
437    use std::io::Seek;
438    let mut src_file = src_file;
439    let mut dst_file = dst_file;
440    src_file.seek(std::io::SeekFrom::Start(0))?;
441    dst_file.seek(std::io::SeekFrom::Start(0))?;
442    dst_file.set_len(0)?;
443
444    readwrite_with_buffer(src_file, dst_file, len)
445}
446
447/// Read/write copy with thread-local buffer reuse (shared by all Linux fallback paths).
448#[cfg(target_os = "linux")]
449fn readwrite_with_buffer(
450    mut src_file: std::fs::File,
451    mut dst_file: std::fs::File,
452    len: u64,
453) -> io::Result<()> {
454    use std::cell::RefCell;
455    use std::io::{Read, Write};
456
457    const MAX_BUF: usize = 4 * 1024 * 1024;
458    /// Shrink when buffer is >512KB and 4x larger than needed (matches non-Linux path).
459    const SHRINK_THRESHOLD: usize = 512 * 1024;
460
461    // Clamp while still u64 to avoid 32-bit truncation on large files.
462    let buf_size = (len.min(MAX_BUF as u64) as usize).max(8192);
463
464    thread_local! {
465        static BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
466    }
467    BUF.with(|cell| {
468        let mut buf = cell.borrow_mut();
469        if buf.len() > SHRINK_THRESHOLD && buf_size < buf.len() / 4 {
470            buf.resize(buf_size, 0);
471            buf.shrink_to_fit();
472        } else if buf.len() < buf_size {
473            buf.resize(buf_size, 0);
474        }
475        loop {
476            let n = src_file.read(&mut buf[..buf_size])?;
477            if n == 0 {
478                break;
479            }
480            dst_file.write_all(&buf[..n])?;
481        }
482        Ok(())
483    })
484}
485
486// ---- single-file copy ----
487
488/// Copy a single file (or symlink) from `src` to `dst`.
489pub fn copy_file(src: &Path, dst: &Path, config: &CpConfig) -> io::Result<()> {
490    let src_meta = if config.dereference == DerefMode::Always {
491        std::fs::metadata(src)?
492    } else {
493        std::fs::symlink_metadata(src)?
494    };
495
496    copy_file_with_meta(src, dst, &src_meta, config)
497}
498
499/// Copy a single file using pre-fetched metadata (avoids redundant stat).
500fn copy_file_with_meta(
501    src: &Path,
502    dst: &Path,
503    src_meta: &std::fs::Metadata,
504    config: &CpConfig,
505) -> io::Result<()> {
506    // Handle symlink when not dereferencing.
507    if src_meta.file_type().is_symlink() && config.dereference == DerefMode::Never {
508        let target = std::fs::read_link(src)?;
509        #[cfg(unix)]
510        {
511            std::os::unix::fs::symlink(&target, dst)?;
512        }
513        #[cfg(not(unix))]
514        {
515            // Fallback: try a regular copy (symlinks are not portable).
516            let _ = target;
517            std::fs::copy(src, dst)?;
518        }
519        return Ok(());
520    }
521
522    // Hard link mode.
523    if config.link {
524        std::fs::hard_link(src, dst)?;
525        return Ok(());
526    }
527
528    // Symbolic link mode.
529    if config.symbolic_link {
530        #[cfg(unix)]
531        {
532            std::os::unix::fs::symlink(src, dst)?;
533        }
534        #[cfg(not(unix))]
535        {
536            return Err(io::Error::new(
537                io::ErrorKind::Unsupported,
538                "symbolic links are not supported on this platform",
539            ));
540        }
541        return Ok(());
542    }
543
544    // Linux: single-open cascade (FICLONE → copy_file_range → read/write).
545    #[cfg(target_os = "linux")]
546    {
547        copy_data_linux(src, dst, config)?;
548        preserve_attributes_from_meta(src_meta, dst, config)?;
549        return Ok(());
550    }
551
552    // Non-Linux fallback: large-buffer copy (up to 4MB vs stdlib's 64KB).
553    #[cfg(not(target_os = "linux"))]
554    {
555        #[cfg(unix)]
556        let mode = src_meta.mode();
557        #[cfg(not(unix))]
558        let mode = 0o666u32;
559        copy_data_large_buf(src, dst, src_meta.len(), mode)?;
560        preserve_attributes_from_meta(src_meta, dst, config)?;
561        Ok(())
562    }
563}
564
565// ---- recursive copy ----
566
567/// Recursively copy `src` to `dst`, using parallel file copies within each directory.
568fn copy_recursive(
569    src: &Path,
570    dst: &Path,
571    config: &CpConfig,
572    root_dev: Option<u64>,
573) -> io::Result<()> {
574    let src_meta = std::fs::symlink_metadata(src)?;
575
576    #[cfg(unix)]
577    if config.one_file_system {
578        if let Some(dev) = root_dev {
579            if src_meta.dev() != dev {
580                return Ok(());
581            }
582        }
583    }
584
585    if src_meta.is_dir() {
586        if !dst.exists() {
587            std::fs::create_dir_all(dst)?;
588        }
589
590        #[cfg(unix)]
591        let next_dev = Some(root_dev.unwrap_or(src_meta.dev()));
592        #[cfg(not(unix))]
593        let next_dev: Option<u64> = None;
594
595        // Collect entries and partition into files and directories.
596        let mut files: Vec<(std::path::PathBuf, std::path::PathBuf, std::fs::Metadata)> =
597            Vec::new();
598        let mut dirs: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();
599
600        for entry in std::fs::read_dir(src)? {
601            let entry = entry?;
602            let child_src = entry.path();
603            let child_dst = dst.join(entry.file_name());
604            // Respect dereference mode: follow symlinks when Always.
605            let meta = if config.dereference == DerefMode::Always {
606                std::fs::metadata(&child_src)?
607            } else {
608                std::fs::symlink_metadata(&child_src)?
609            };
610            // Check --one-file-system for all entries (not just directories).
611            #[cfg(unix)]
612            if config.one_file_system {
613                if let Some(dev) = root_dev {
614                    if meta.dev() != dev {
615                        continue;
616                    }
617                }
618            }
619            if meta.is_dir() {
620                dirs.push((child_src, child_dst));
621            } else {
622                files.push((child_src, child_dst, meta));
623            }
624        }
625
626        /// Minimum number of files before we parallelize copies within a directory.
627        /// Rayon dispatch overhead dominates below this threshold (empirical).
628        const PARALLEL_FILE_THRESHOLD: usize = 8;
629
630        // Copy files in parallel using Rayon when there are enough to benefit.
631        if files.len() >= PARALLEL_FILE_THRESHOLD {
632            use rayon::prelude::*;
633            let result: Result<(), io::Error> =
634                files
635                    .par_iter()
636                    .try_for_each(|(child_src, child_dst, meta)| {
637                        copy_file_with_meta(child_src, child_dst, meta, config)
638                    });
639            result?;
640        } else {
641            for (child_src, child_dst, meta) in &files {
642                copy_file_with_meta(child_src, child_dst, meta, config)?;
643            }
644        }
645
646        // Recurse into subdirectories sequentially (they may create dirs that
647        // need to exist before their children can be copied).
648        for (child_src, child_dst) in &dirs {
649            copy_recursive(child_src, child_dst, config, next_dev)?;
650        }
651
652        // Preserve directory attributes after copying contents.
653        preserve_attributes_from_meta(&src_meta, dst, config)?;
654    } else {
655        // If parent directory does not exist, create it.
656        if let Some(parent) = dst.parent() {
657            if !parent.exists() {
658                std::fs::create_dir_all(parent)?;
659            }
660        }
661        copy_file_with_meta(src, dst, &src_meta, config)?;
662    }
663    Ok(())
664}
665
666// ---- main entry point ----
667
668/// Determine the effective destination and perform the copy.
669///
670/// `sources` is the list of source paths; `raw_dest` is the positional destination
671/// (may be `None` when `--target-directory` is used).
672///
673/// Returns a list of per-file error messages (empty on full success) and a bool
674/// indicating whether any error occurred.
675pub fn run_cp(
676    sources: &[String],
677    raw_dest: Option<&str>,
678    config: &CpConfig,
679) -> (Vec<String>, bool) {
680    let mut errors: Vec<String> = Vec::new();
681    let mut had_error = false;
682
683    // Resolve destination directory.
684    let dest_dir: Option<std::path::PathBuf> = config
685        .target_directory
686        .as_deref()
687        .or(raw_dest)
688        .map(std::path::PathBuf::from);
689
690    let dest_dir = match dest_dir {
691        Some(d) => d,
692        None => {
693            errors.push("cp: missing destination operand".to_string());
694            return (errors, true);
695        }
696    };
697
698    // Multiple sources or target is an existing directory => copy into directory.
699    let copy_into_dir = sources.len() > 1 || dest_dir.is_dir() || config.target_directory.is_some();
700
701    // When -T is set, never treat destination as a directory.
702    let copy_into_dir = copy_into_dir && !config.no_target_directory;
703
704    for source in sources {
705        let src = Path::new(source);
706        let dst = if copy_into_dir {
707            let name = src.file_name().unwrap_or(src.as_ref());
708            dest_dir.join(name)
709        } else {
710            dest_dir.clone()
711        };
712
713        if let Err(e) = do_copy(src, &dst, config) {
714            let msg = format!(
715                "cp: cannot copy '{}' to '{}': {}",
716                src.display(),
717                dst.display(),
718                strip_os_error(&e)
719            );
720            errors.push(msg);
721            had_error = true;
722        } else if config.verbose {
723            // Verbose output goes to stderr to match GNU behavior when piped.
724            eprintln!("'{}' -> '{}'", src.display(), dst.display());
725        }
726    }
727
728    (errors, had_error)
729}
730
731/// Core copy dispatcher for a single source -> destination pair.
732fn do_copy(src: &Path, dst: &Path, config: &CpConfig) -> io::Result<()> {
733    let src_meta = if config.dereference == DerefMode::Always {
734        std::fs::metadata(src)?
735    } else {
736        std::fs::symlink_metadata(src)?
737    };
738
739    // Reject directory source without -R.
740    if src_meta.is_dir() && !config.recursive {
741        return Err(io::Error::new(
742            io::ErrorKind::Other,
743            format!("omitting directory '{}'", src.display()),
744        ));
745    }
746
747    // No-clobber: skip if destination exists.
748    if config.no_clobber && dst.exists() {
749        return Ok(());
750    }
751
752    // Update: skip if destination is same age or newer.
753    if config.update && dst.exists() {
754        if let (Ok(src_m), Ok(dst_m)) = (src.metadata(), dst.metadata()) {
755            if let (Ok(src_t), Ok(dst_t)) = (src_m.modified(), dst_m.modified()) {
756                if dst_t >= src_t {
757                    return Ok(());
758                }
759            }
760        }
761    }
762
763    // Interactive: prompt on stderr.
764    if config.interactive && dst.exists() {
765        eprint!("cp: overwrite '{}'? ", dst.display());
766        let mut response = String::new();
767        io::stdin().read_line(&mut response)?;
768        let r = response.trim().to_lowercase();
769        if !(r == "y" || r == "yes") {
770            return Ok(());
771        }
772    }
773
774    // Force: remove existing destination if it cannot be opened for writing.
775    if config.force && dst.exists() {
776        if let Ok(m) = dst.metadata() {
777            if m.permissions().readonly() {
778                std::fs::remove_file(dst)?;
779            }
780        }
781    }
782
783    // Make backup if requested.
784    make_backup(dst, config)?;
785
786    if src_meta.is_dir() {
787        #[cfg(unix)]
788        let root_dev = Some(src_meta.dev());
789        #[cfg(not(unix))]
790        let root_dev: Option<u64> = None;
791        copy_recursive(src, dst, config, root_dev)
792    } else {
793        copy_file(src, dst, config)
794    }
795}
796
797/// Strip the " (os error N)" suffix from an io::Error for GNU-compatible messages.
798fn strip_os_error(e: &io::Error) -> String {
799    if let Some(raw) = e.raw_os_error() {
800        let msg = format!("{}", e);
801        msg.replace(&format!(" (os error {})", raw), "")
802    } else {
803        format!("{}", e)
804    }
805}