#![allow(clippy::too_many_arguments)]
use clihelp::{HelpPage, Row, Section};
use std::ffi::CString;
use std::fs::{self, File};
use std::io::{self, IsTerminal, Write};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process;
const VERSION: &str = "0.2.0";
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683e_u32 as i64;
const XFS_SUPER_MAGIC: i64 = 0x5846_5342;
const BCACHEFS_SUPER_MAGIC: i64 = 0xca45_1a4e_u32 as i64;
const OCFS2_SUPER_MAGIC: i64 = 0x7461_636f;
const FICLONE: libc::c_ulong = 0x4004_9409;
fn row(short: &'static str, long: &'static str, desc: &'static str) -> Row {
Row::new(short, long, desc)
}
fn row_val(
short: &'static str,
long: &'static str,
placeholder: &'static str,
desc: &'static str,
) -> Row {
Row::with_value(short, long, placeholder, desc)
}
fn options_rows() -> Vec<Row> {
vec![
row("-a", "--archive", "same as -dR --preserve=all"),
row("", "--attributes-only", "don't copy the file data, just the attributes"),
row_val("", "--backup", "[=CONTROL]", "make a backup of each existing destination file"),
row("-b", "", "like --backup but does not accept an argument"),
row("", "--copy-contents", "copy contents of special files when recursive"),
row("-d", "", "same as --no-dereference --preserve=links"),
row("", "--debug", "explain how a file is copied. Implies -v"),
row("-f", "--force", "if an existing destination file cannot be opened, remove it and try again"),
row("-i", "--interactive", "prompt before overwrite (overrides a previous -n option)"),
row("-H", "", "follow command-line symbolic links in SOURCE"),
row("-L", "--dereference", "always follow symbolic links in SOURCE"),
row("-P", "--no-dereference", "never follow symbolic links in SOURCE"),
row("", "--keep-directory-symlink", "follow existing symlinks to directories"),
row("-l", "--link", "hard link files instead of copying"),
row("-n", "--no-clobber", "(deprecated) silently skip existing files. See also --update"),
row("-p", "", "same as --preserve=mode,ownership,timestamps"),
row_val("", "--preserve", "[=ATTR_LIST]", "preserve the specified attributes"),
row_val("", "--no-preserve", "=ATTR_LIST", "don't preserve the specified attributes"),
row("", "--parents", "use full source file name under DIRECTORY"),
row("-R, -r", "--recursive", "copy directories recursively"),
row("", "--remove-destination", "remove each existing destination file before attempting to open it"),
row_val("", "--sparse", "=WHEN", "control creation of sparse files. See below"),
row("", "--strip-trailing-slashes", "remove any trailing slashes from each SOURCE argument"),
row("-s", "--symbolic-link", "make symbolic links instead of copying"),
row_val("-S", "--suffix", "=SUFFIX", "override the usual backup suffix"),
row_val("-t", "--target-directory", "=DIRECTORY", "copy all SOURCE arguments into DIRECTORY"),
row("-T", "--no-target-directory", "treat DEST as a normal file"),
row_val("", "--update", "[=UPDATE]", "control which existing files are updated"),
row("-u", "", "equivalent to --update[=older]. See below"),
row("-v", "--verbose", "explain what is being done"),
row("-x", "--one-file-system", "stay on this file system"),
row("-Z", "", "set SELinux security context of destination file to default type"),
row_val("", "--context", "[=CTX]", "like -Z, or if CTX is specified then set the SELinux or SMACK security context to CTX"),
row("-h", "--help", "display this help and exit"),
row("", "--version", "output version information and exit"),
]
}
fn print_help() {
print_help_body(io::stdout().is_terminal());
}
fn print_help_body(on: bool) {
let page = HelpPage::new(format!("fastcp {VERSION} — fast cp with smart CoW/reflink detection"))
.usage("fastcp [OPTION]... [-T] SOURCE DEST")
.usage("fastcp [OPTION]... SOURCE... DIRECTORY")
.usage("fastcp [OPTION]... -t DIRECTORY SOURCE...")
.blurb("Copy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.")
.section(Section::with_note(
"OPTIONS",
"Mandatory arguments to long options are mandatory for short options too.",
options_rows(),
));
let mut out = page.render(on);
out.push_str(&format!("{}\n", clihelp::header("NOTES", on)));
out.push_str(" ATTR_LIST is a comma-separated list of attributes. Attributes are 'mode' for\n");
out.push_str(" permissions (including any ACL and xattr permissions), 'ownership' for user\n");
out.push_str(" and group, 'timestamps' for file timestamps, 'links' for hard links, 'context'\n");
out.push_str(" for security context, 'xattr' for extended attributes, and 'all' for all\n");
out.push_str(" attributes.\n\n");
out.push_str(" By default, sparse SOURCE files are detected by a crude heuristic and the\n");
out.push_str(" corresponding DEST file is made sparse as well. That is the behavior\n");
out.push_str(" selected by --sparse=auto. Specify --sparse=always to create a sparse DEST\n");
out.push_str(" file whenever the SOURCE file contains a long enough sequence of zero bytes.\n");
out.push_str(" Use --sparse=never to inhibit creation of sparse files.\n\n");
out.push_str(" UPDATE controls which existing files in the destination are replaced.\n");
out.push_str(" 'all' is the default operation when an --update option is not specified,\n");
out.push_str(" and results in all existing files in the destination being replaced.\n");
out.push_str(" 'none' is like the --no-clobber option, in that no files in the\n");
out.push_str(" destination are replaced, and skipped files do not induce a failure.\n");
out.push_str(" 'none-fail' also ensures no files are replaced in the destination,\n");
out.push_str(" but any skipped files are diagnosed and induce a failure.\n");
out.push_str(" 'older' is the default operation when --update is specified, and results\n");
out.push_str(" in files being replaced if they're older than the corresponding source file.\n\n");
out.push_str(" The backup suffix is '~', unless set with --suffix or SIMPLE_BACKUP_SUFFIX.\n");
out.push_str(" The version control method may be selected via the --backup option or through\n");
out.push_str(" the VERSION_CONTROL environment variable. Here are the values:\n\n");
out.push_str(" none, off never make backups (even if --backup is given)\n");
out.push_str(" numbered, t make numbered backups\n");
out.push_str(" existing, nil numbered if numbered backups exist, simple otherwise\n");
out.push_str(" simple, never always make simple backups\n\n");
out.push_str(" As a special case, cp makes a backup of SOURCE when the force and backup\n");
out.push_str(" options are given and SOURCE and DEST are the same name for an existing,\n");
out.push_str(" regular file.\n");
print!("{out}");
}
fn main() {
let real_cp = find_real_cp();
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
exec_cp(&real_cp, &args, None);
}
if args.iter().any(|a| a == "--help" || a == "-h") {
print_help();
process::exit(0);
}
if args.iter().any(|a| a == "--version") {
println!("fastcp {VERSION}");
process::exit(0);
}
args.retain(|a| a != "--reflink" && !a.starts_with("--reflink="));
let (sources, dest) = match parse_cp_args(&args) {
Some(v) => v,
None => exec_cp(&real_cp, &args, None),
};
let dest_dev = match resolve_dev(&dest) {
Some(d) => d,
None => exec_cp(&real_cp, &args, None),
};
let all_same_device = sources.iter().all(|src| {
resolve_dev(src).map_or(false, |d| d == dest_dev)
});
if !all_same_device {
exec_cp(&real_cp, &args, None);
}
let cache_dir = config_dir();
let cache_file = cache_file_for_dev(&cache_dir, dest_dev);
let cow_supported = match read_cache(&cache_file) {
Some(v) => v,
None => {
let v = detect_cow_support(&dest, dest_dev);
write_cache(&cache_dir, &cache_file, dest_dev, v, &dest);
v
}
};
if cow_supported {
exec_cp(&real_cp, &args, Some("--reflink=always"));
} else {
exec_cp(&real_cp, &args, None);
}
}
fn exec_cp(cp: &Path, user_args: &[String], extra: Option<&str>) -> ! {
let cp_cstr = path_to_cstring(cp);
let mut cargs: Vec<CString> = Vec::with_capacity(user_args.len() + 2);
cargs.push(cp_cstr.clone());
if let Some(flag) = extra {
cargs.push(CString::new(flag).unwrap());
}
for a in user_args {
cargs.push(CString::new(a.as_bytes()).unwrap_or_else(|_| CString::new(".").unwrap()));
}
let mut ptrs: Vec<*const libc::c_char> = cargs.iter().map(|s| s.as_ptr()).collect();
ptrs.push(std::ptr::null());
unsafe { libc::execv(cp_cstr.as_ptr(), ptrs.as_ptr()) };
eprintln!("fastcp: execv failed: {}", io::Error::last_os_error());
process::exit(127)
}
fn path_to_cstring(p: &Path) -> CString {
CString::new(p.as_os_str().as_bytes()).unwrap_or_else(|_| {
eprintln!("fastcp: path contains a null byte");
process::exit(1)
})
}
fn find_real_cp() -> PathBuf {
let self_canon = std::env::current_exe()
.ok()
.and_then(|p| fs::canonicalize(p).ok())
.unwrap_or_default();
for candidate in ["/usr/bin/cp", "/bin/cp"] {
let p = Path::new(candidate);
if !p.exists() { continue; }
let canon = fs::canonicalize(p).unwrap_or_else(|_| p.to_owned());
if canon != self_canon { return p.to_owned(); }
}
if let Ok(path_var) = std::env::var("PATH") {
for dir in path_var.split(':') {
let p = PathBuf::from(dir).join("cp");
if !p.exists() { continue; }
let canon = fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
if canon != self_canon { return p; }
}
}
eprintln!("fastcp: cp not found");
process::exit(127)
}
fn parse_cp_args(args: &[String]) -> Option<(Vec<PathBuf>, PathBuf)> {
let mut positional: Vec<PathBuf> = Vec::new();
let mut target_dir: Option<PathBuf> = None;
let mut dashdash = false;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if dashdash {
positional.push(PathBuf::from(arg));
i += 1;
continue;
}
match arg.as_str() {
"--" => {
dashdash = true;
}
"--help" | "--version" => return None,
s if s.starts_with("--target-directory=") => {
target_dir = Some(PathBuf::from(&s["--target-directory=".len()..]));
}
"--suffix" | "--backup" | "--sparse" | "--no-preserve"
| "--preserve" | "--context" | "--scontext" => {
i += 1;
}
"-t" => {
i += 1;
target_dir = Some(PathBuf::from(args.get(i)?));
}
s if s.starts_with('-') && !s.starts_with("--") && s.len() > 1 => {
let bytes = s[1..].as_bytes();
let mut j = 0;
while j < bytes.len() {
match bytes[j] {
b't' => {
let rest: String = bytes[j + 1..].iter().map(|&b| b as char).collect();
if !rest.is_empty() {
target_dir = Some(PathBuf::from(&rest));
} else {
i += 1;
target_dir = Some(PathBuf::from(args.get(i)?));
}
break;
}
b'S' => {
let rest: String = bytes[j + 1..].iter().map(|&b| b as char).collect();
if rest.is_empty() { i += 1; }
break;
}
_ => {}
}
j += 1;
}
}
_ => {
positional.push(PathBuf::from(arg));
}
}
i += 1;
}
if let Some(td) = target_dir {
if positional.is_empty() { return None; }
return Some((positional, td));
}
if positional.len() < 2 { return None; }
let dest = positional.pop().unwrap();
Some((positional, dest))
}
fn resolve_dev(path: &Path) -> Option<u64> {
if let Ok(meta) = fs::metadata(path) {
return Some(meta.dev());
}
let parent = path.parent().filter(|p| !p.as_os_str().is_empty())?;
fs::metadata(parent).ok().map(|m| m.dev())
}
fn major_minor(dev: u64) -> (u32, u32) {
let major = (((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff_u64)) as u32;
let minor = ((dev & 0xff) | ((dev >> 12) & !0xff_u64)) as u32;
(major, minor)
}
fn find_mountpoint(dev: u64) -> Option<PathBuf> {
let mounts = fs::read_to_string("/proc/mounts").ok()?;
for line in mounts.lines().rev() {
let mut parts = line.split_whitespace();
let _device = parts.next()?;
let mp = Path::new(parts.next()?);
if fs::metadata(mp).map(|m| m.dev() == dev).unwrap_or(false) {
return Some(mp.to_owned());
}
}
None
}
fn config_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config/fastcp")
}
fn cache_file_for_dev(cache_dir: &Path, dev: u64) -> PathBuf {
let (major, minor) = major_minor(dev);
cache_dir.join(format!("{major}-{minor}"))
}
fn read_cache(path: &Path) -> Option<bool> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
let t = line.trim();
if t.starts_with('#') || t.is_empty() { continue; }
return match t {
"1" => Some(true),
"0" => Some(false),
_ => None,
};
}
None
}
fn write_cache(cache_dir: &Path, cache_file: &Path, dev: u64, supported: bool, path: &Path) {
if fs::create_dir_all(cache_dir).is_err() { return; }
let (major, minor) = major_minor(dev);
let label = device_label(dev, path);
let fstype = fstype_name_for_dev(dev).unwrap_or_else(|| "unknown".into());
let verdict = if supported { "yes" } else { "no" };
let flag = if supported { '1' } else { '0' };
let content = format!(
"# fastcp cache — generated automatically, safe to delete to re-detect\n\
# device: {major}:{minor} ({label})\n\
# fstype: {fstype}\n\
# CoW/reflink: {verdict}\n\
{flag}\n"
);
let _ = fs::write(cache_file, content);
}
fn device_label(dev: u64, fallback: &Path) -> String {
if let Ok(mounts) = fs::read_to_string("/proc/mounts") {
for line in mounts.lines() {
let mut parts = line.split_whitespace();
let device = match parts.next() { Some(d) => d, None => continue };
let _mp = parts.next();
let fstype = parts.next().unwrap_or("?");
if fs::metadata(device).map(|m| m.rdev() == dev).unwrap_or(false) {
return format!("{device} ({fstype})");
}
}
}
fallback.to_string_lossy().into_owned()
}
fn fstype_name_for_dev(dev: u64) -> Option<String> {
let mounts = fs::read_to_string("/proc/mounts").ok()?;
for line in mounts.lines() {
let mut parts = line.split_whitespace();
let device = parts.next()?;
let _mp = parts.next();
let fstype = parts.next()?;
if fs::metadata(device).map(|m| m.rdev() == dev).unwrap_or(false) {
return Some(fstype.to_string());
}
}
None
}
fn detect_cow_support(path: &Path, dev: u64) -> bool {
match statfs_type(path) {
Some(t) if t == BTRFS_SUPER_MAGIC => detect_btrfs_cow(dev, path),
Some(t) if t == XFS_SUPER_MAGIC => detect_xfs_reflink(dev),
Some(t) if t == BCACHEFS_SUPER_MAGIC => true,
Some(t) if t == OCFS2_SUPER_MAGIC => true,
_ => probe_ficlone(path),
}
}
fn statfs_type(path: &Path) -> Option<i64> {
let cpath = CString::new(path.as_os_str().as_bytes()).ok()?;
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statfs(cpath.as_ptr(), &mut buf) } != 0 {
return None;
}
#[allow(clippy::unnecessary_cast)]
Some(buf.f_type as i64)
}
fn detect_btrfs_cow(dev: u64, path: &Path) -> bool {
let mounts = match fs::read_to_string("/proc/mounts") {
Ok(s) => s,
Err(_) => return true,
};
for line in mounts.lines() {
let mut parts = line.split_whitespace();
let device = match parts.next() { Some(d) => d, None => continue };
let _mp = parts.next();
let _fstype = parts.next();
let options = match parts.next() { Some(o) => o, None => continue };
let is_our_device = fs::metadata(device)
.map(|m| m.rdev() == dev)
.unwrap_or(false);
if is_our_device {
if options.split(',').any(|o| o == "nodatacow") {
return false;
}
return true;
}
}
probe_ficlone(path)
}
fn detect_xfs_reflink(dev: u64) -> bool {
let mountpoint = match find_mountpoint(dev) {
Some(mp) => mp,
None => return false,
};
let out = process::Command::new("xfs_info")
.arg(&mountpoint)
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains("reflink=1"),
Err(_) => false,
}
}
fn probe_ficlone(path: &Path) -> bool {
let dir = if path.is_dir() {
path.to_owned()
} else {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_owned())
.unwrap_or_else(|| PathBuf::from("."))
};
let pid = process::id();
let src_p = dir.join(format!(".fastcp_probe_{pid}_src"));
let dst_p = dir.join(format!(".fastcp_probe_{pid}_dst"));
let result = probe_ficlone_inner(&src_p, &dst_p);
let _ = fs::remove_file(&src_p);
let _ = fs::remove_file(&dst_p);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn major_minor_matches_kernel_macros() {
let dev = (8u64 << 8) | 1;
assert_eq!(major_minor(dev), (8, 1));
let dev = (259u64 << 8) | 2;
assert_eq!(major_minor(dev), (259, 2));
}
#[test]
fn nodatacow_detected_in_mount_options() {
let opts = "rw,noatime,compress=zstd:3,space_cache=v2,nodatacow,ssd";
assert!(opts.split(',').any(|o| o == "nodatacow"));
let opts_without = "rw,relatime,compress=zstd:3,ssd,space_cache=v2";
assert!(!opts_without.split(',').any(|o| o == "nodatacow"));
}
#[test]
fn cache_roundtrip() {
let dir = std::env::temp_dir().join(format!("fastcp_test_{}", process::id()));
let _ = fs::create_dir_all(&dir);
let f = dir.join("254-0");
assert_eq!(read_cache(&f), None);
fs::write(&f, "# comment\n1\n").unwrap();
assert_eq!(read_cache(&f), Some(true));
fs::write(&f, "# comment\n0\n").unwrap();
assert_eq!(read_cache(&f), Some(false));
fs::write(&f, "# comment\nbogus\n").unwrap();
assert_eq!(read_cache(&f), None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn parse_simple_two_arg() {
let args: Vec<String> = vec!["a.txt".into(), "b.txt".into()];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("a.txt")]);
assert_eq!(dst, PathBuf::from("b.txt"));
}
#[test]
fn parse_multi_source_into_dir() {
let args: Vec<String> = vec!["a.txt".into(), "b.txt".into(), "dest_dir".into()];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")]);
assert_eq!(dst, PathBuf::from("dest_dir"));
}
#[test]
fn parse_target_directory_long_form() {
let args: Vec<String> = vec![
"--target-directory=/tmp/out".into(),
"a.txt".into(),
"b.txt".into(),
];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")]);
assert_eq!(dst, PathBuf::from("/tmp/out"));
}
#[test]
fn parse_target_directory_short_form() {
let args: Vec<String> = vec!["-t".into(), "/tmp/out".into(), "a.txt".into()];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("a.txt")]);
assert_eq!(dst, PathBuf::from("/tmp/out"));
}
#[test]
fn parse_bundled_short_flags_with_t() {
let args: Vec<String> = vec!["-rvt".into(), "/tmp/out".into(), "a.txt".into()];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("a.txt")]);
assert_eq!(dst, PathBuf::from("/tmp/out"));
}
#[test]
fn parse_dashdash_stops_option_parsing() {
let args: Vec<String> = vec!["--".into(), "-weird-name".into(), "dest".into()];
let (src, dst) = parse_cp_args(&args).unwrap();
assert_eq!(src, vec![PathBuf::from("-weird-name")]);
assert_eq!(dst, PathBuf::from("dest"));
}
#[test]
fn parse_rejects_single_arg() {
let args: Vec<String> = vec!["onlyone.txt".into()];
assert!(parse_cp_args(&args).is_none());
}
}
fn probe_ficlone_inner(src_p: &Path, dst_p: &Path) -> bool {
let mut src_f = match File::create(src_p) {
Ok(f) => f,
Err(_) => return false,
};
if src_f.write_all(b"\x00").is_err() { return false; }
if src_f.sync_data().is_err() { return false; }
drop(src_f);
let src_f = match File::open(src_p) {
Ok(f) => f,
Err(_) => return false,
};
let dst_f = match File::create(dst_p) {
Ok(f) => f,
Err(_) => return false,
};
let ret = unsafe {
libc::ioctl(dst_f.as_raw_fd(), FICLONE, src_f.as_raw_fd())
};
ret == 0
}