Skip to content
44 changes: 27 additions & 17 deletions src/uu/mv/src/hardlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,18 +217,23 @@ impl HardlinkGroupScanner {
fn scan_single_path(&mut self, path: &Path) -> io::Result<()> {
use std::os::unix::fs::MetadataExt;

if path.is_dir() {
let metadata = path.symlink_metadata()?;
let file_type = metadata.file_type();

if file_type.is_symlink() {
// Hardlink preservation does not apply to symlinks.
return Ok(());
}

if file_type.is_dir() {
// Recursively scan directory contents
self.scan_directory_recursive(path)?;
} else {
let metadata = path.metadata()?;
if metadata.nlink() > 1 {
let key = (metadata.dev(), metadata.ino());
self.hardlink_groups
.entry(key)
.or_default()
.push(path.to_path_buf());
}
} else if metadata.nlink() > 1 {
let key = (metadata.dev(), metadata.ino());
self.hardlink_groups
.entry(key)
.or_default()
.push(path.to_path_buf());
}
Ok(())
}
Expand All @@ -242,14 +247,19 @@ impl HardlinkGroupScanner {
let entry = entry?;
let path = entry.path();

if path.is_dir() {
let metadata = path.symlink_metadata()?;
let file_type = metadata.file_type();

if file_type.is_symlink() {
// Skip symlinks to avoid following targets (including dangling links).
continue;
}

if file_type.is_dir() {
self.scan_directory_recursive(&path)?;
} else {
let metadata = path.metadata()?;
if metadata.nlink() > 1 {
let key = (metadata.dev(), metadata.ino());
self.hardlink_groups.entry(key).or_default().push(path);
}
} else if metadata.nlink() > 1 {
let key = (metadata.dev(), metadata.ino());
self.hardlink_groups.entry(key).or_default().push(path);
}
}
Ok(())
Expand Down
135 changes: 132 additions & 3 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,44 @@ fn is_fifo(_filetype: fs::FileType) -> bool {
false
}

#[cfg(unix)]
fn try_preserve_ownership(from_meta: &fs::Metadata, to: &Path, follow_symlinks: bool) {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
use std::os::unix::fs::MetadataExt as _;

let uid = from_meta.uid() as libc::uid_t;
let gid = from_meta.gid() as libc::gid_t;

let Ok(to_cstr) = CString::new(to.as_os_str().as_bytes()) else {
return;
};

unsafe {
if follow_symlinks {
let _ = libc::chown(to_cstr.as_ptr(), uid, gid);
} else {
let _ = libc::lchown(to_cstr.as_ptr(), uid, gid);
}
}
}

#[cfg(unix)]
fn try_preserve_permissions(from_meta: &fs::Metadata, to: &Path) {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};

// Keep mode bits only (file type bits are not allowed in chmod).
let mode = from_meta.mode() & 0o7777;
let _ = fs::set_permissions(to, fs::Permissions::from_mode(mode));
}

#[cfg(unix)]
fn try_preserve_ownership_and_permissions(from_meta: &fs::Metadata, to: &Path) {
// `chown` can clear setuid/setgid bits, so restore the mode afterwards.
try_preserve_ownership(from_meta, to, true);
try_preserve_permissions(from_meta, to);
}

/// A wrapper around `fs::rename`, so that if it fails, we try falling back on
/// copying and removing.
fn rename_with_fallback(
Expand Down Expand Up @@ -892,10 +930,14 @@ fn rename_with_fallback(
/// Replace the destination with a new pipe with the same name as the source.
#[cfg(unix)]
fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> {
let from_meta = from.symlink_metadata()?;
if to.try_exists()? {
fs::remove_file(to)?;
}
make_fifo(to).and_then(|_| fs::remove_file(from))
make_fifo(to).and_then(|_| {
try_preserve_ownership_and_permissions(&from_meta, to);
fs::remove_file(from)
})
}

#[cfg(not(unix))]
Expand All @@ -907,8 +949,12 @@ fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> {
/// symlinks return an error.
#[cfg(unix)]
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
let from_meta = from.symlink_metadata()?;
let path_symlink_points_to = fs::read_link(from)?;
unix::fs::symlink(path_symlink_points_to, to).and_then(|_| fs::remove_file(from))
unix::fs::symlink(path_symlink_points_to, to).and_then(|_| {
try_preserve_ownership(&from_meta, to, false);
fs::remove_file(from)
})
}

#[cfg(windows)]
Expand Down Expand Up @@ -938,6 +984,44 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
))
}

/// Copy the given symlink to the given destination without dereferencing.
/// On Windows, dangling symlinks return an error.
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> {
let from_meta = from.symlink_metadata()?;
let path_symlink_points_to = fs::read_link(from)?;
unix::fs::symlink(path_symlink_points_to, to).map(|_| {
try_preserve_ownership(&from_meta, to, false);
})
}

#[cfg(windows)]
fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> {
let path_symlink_points_to = fs::read_link(from)?;
if path_symlink_points_to.exists() {
if path_symlink_points_to.is_dir() {
windows::fs::symlink_dir(&path_symlink_points_to, to)?;
} else {
windows::fs::symlink_file(&path_symlink_points_to, to)?;
}
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
translate!("mv-error-dangling-symlink"),
))
}
}

#[cfg(not(any(windows, unix)))]
fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> {
let _ = (from, to);
Err(io::Error::new(
io::ErrorKind::Other,
translate!("mv-error-no-symlink-support"),
))
}

fn rename_dir_fallback(
from: &Path,
to: &Path,
Expand All @@ -946,6 +1030,9 @@ fn rename_dir_fallback(
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
) -> io::Result<()> {
#[cfg(unix)]
let from_meta = from.symlink_metadata()?;

// We remove the destination directory if it exists to match the
// behavior of `fs::rename`. As far as I can tell, `fs_extra`'s
// `move_dir` would otherwise behave differently.
Expand Down Expand Up @@ -991,6 +1078,9 @@ fn rename_dir_fallback(

result?;

#[cfg(unix)]
try_preserve_ownership_and_permissions(&from_meta, to);

// Remove the source directory after successful copy
fs::remove_dir_all(from)?;

Expand Down Expand Up @@ -1056,7 +1146,26 @@ fn copy_dir_contents_recursive(
pb.set_message(from_path.to_string_lossy().to_string());
}

if from_path.is_dir() {
let entry_type = entry.file_type()?;

if entry_type.is_symlink() {
copy_symlink(&from_path, &to_path)?;

// Print verbose message for symlink
if verbose {
let message = translate!(
"mv-verbose-renamed",
"from" => from_path.quote(),
"to" => to_path.quote()
);
match display_manager {
Some(pb) => pb.suspend(|| {
println!("{message}");
}),
None => println!("{message}"),
}
}
} else if entry_type.is_dir() {
// Recursively copy subdirectory
fs::create_dir_all(&to_path)?;

Expand Down Expand Up @@ -1086,6 +1195,11 @@ fn copy_dir_contents_recursive(
progress_bar,
display_manager,
)?;

#[cfg(unix)]
if let Ok(from_meta) = fs::symlink_metadata(&from_path) {
try_preserve_ownership_and_permissions(&from_meta, &to_path);
}
} else {
// Copy file with or without hardlink support based on platform
#[cfg(unix)]
Expand Down Expand Up @@ -1131,6 +1245,8 @@ fn copy_file_with_hardlinks_helper(
hardlink_tracker: &mut HardlinkTracker,
hardlink_scanner: &HardlinkGroupScanner,
) -> io::Result<()> {
let from_meta = from.symlink_metadata()?;

// Check if this file should be a hardlink to an already-copied file
use crate::hardlink::HardlinkOptions;
let hardlink_options = HardlinkOptions::default();
Expand All @@ -1152,6 +1268,8 @@ fn copy_file_with_hardlinks_helper(
fs::copy(from, to)?;
}

try_preserve_ownership_and_permissions(&from_meta, to);

Ok(())
}

Expand All @@ -1161,6 +1279,9 @@ fn rename_file_fallback(
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
) -> io::Result<()> {
#[cfg(unix)]
let from_meta = from.symlink_metadata()?;

// Remove existing target file if it exists
if to.is_symlink() {
fs::remove_file(to).map_err(|err| {
Expand Down Expand Up @@ -1193,10 +1314,18 @@ fn rename_file_fallback(
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
fs::copy(from, to)
.and_then(|_| fsxattr::copy_xattrs(&from, &to))
.map(|_| {
#[cfg(unix)]
try_preserve_ownership_and_permissions(&from_meta, to);
})
.and_then(|_| fs::remove_file(from))
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
#[cfg(any(target_os = "macos", target_os = "redox", not(unix)))]
fs::copy(from, to)
.map(|_| {
#[cfg(unix)]
try_preserve_ownership_and_permissions(&from_meta, to);
})
.and_then(|_| fs::remove_file(from))
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
Ok(())
Expand Down
Loading
Loading