Skip to content
Open
44 changes: 27 additions & 17 deletions src/uu/mv/src/hardlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,18 +204,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 @@ -229,14 +234,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
156 changes: 145 additions & 11 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,72 @@ fn is_fifo(_filetype: fs::FileType) -> bool {
false
}

#[cfg(unix)]
/// Best-effort ownership preservation for `to` using `from_meta`.
///
/// On Unix, this tries to set `uid`/`gid` on `to`. If `follow_symlinks` is
/// true it uses `chown`, otherwise it uses `lchown` so the link itself (not its
/// target) is updated. `chown`/`lchown` failures are non-fatal; permission
/// errors are ignored, and other failures emit a warning because ownership
/// preservation is optional.
fn try_preserve_ownership(from_meta: &fs::Metadata, to: &Path, follow_symlinks: bool) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document this function please

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;
};

let result = unsafe {
if follow_symlinks {
libc::chown(to_cstr.as_ptr(), uid, gid)
} else {
libc::lchown(to_cstr.as_ptr(), uid, gid)
}
};
if result != 0 {
let err = io::Error::last_os_error();
if err.kind() != io::ErrorKind::PermissionDenied {
eprintln!(
"mv: warning: failed to preserve ownership for {}: {err}",
to.quote()
);
}
}
}

#[cfg(unix)]
/// Best-effort permission preservation for `to` using `from_meta`.
///
/// Only the mode bits are applied (`chmod` does not accept file type bits).
/// Failures are non-fatal; permission errors are ignored, and other failures
/// emit a warning because this is optional.
fn try_preserve_permissions(from_meta: &fs::Metadata, to: &Path) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, please document it

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;
if let Err(err) = fs::set_permissions(to, fs::Permissions::from_mode(mode)) {
if err.kind() != io::ErrorKind::PermissionDenied {
eprintln!(
"mv: warning: failed to preserve permissions for {}: {err}",
to.quote()
);
}
}
}

#[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 @@ -879,10 +945,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 @@ -898,25 +968,47 @@ 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<()> {
copy_symlink(from, to)?;
fs::remove_file(from)
}

#[cfg(windows)]
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
copy_symlink(from, to)?;
fs::remove_file(from)
}

#[cfg(not(any(windows, unix)))]
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
copy_symlink(from, to)?;
fs::remove_file(from)
}

/// 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)?;
#[cfg(not(any(target_os = "macos", target_os = "redox")))]
{
let _ = copy_xattrs_if_supported(from, to);
}
fs::remove_file(from)
try_preserve_ownership(&from_meta, to, false);
Ok(())
}

#[cfg(windows)]
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
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)?;
}
fs::remove_file(from)
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
Expand All @@ -926,8 +1018,8 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
}

#[cfg(not(any(windows, unix)))]
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
let path_symlink_points_to = fs::read_link(from)?;
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"),
Expand All @@ -942,6 +1034,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 @@ -987,6 +1082,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 @@ -1050,7 +1148,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 All @@ -1076,6 +1193,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 All @@ -1091,7 +1213,7 @@ fn copy_dir_contents_recursive(
{
if from_path.is_symlink() {
// Copy a symlink file (no-follow).
rename_symlink_fallback(&from_path, &to_path)?;
copy_symlink(&from_path, &to_path)?;
} else {
// Copy a regular file.
fs::copy(&from_path, &to_path)?;
Expand Down Expand Up @@ -1127,6 +1249,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 @@ -1138,10 +1262,10 @@ fn copy_file_with_hardlinks_helper(
return Ok(());
}

if from.is_symlink() {
if from_meta.file_type().is_symlink() {
// Copy a symlink file (no-follow).
rename_symlink_fallback(from, to)?;
} else if is_fifo(from.symlink_metadata()?.file_type()) {
copy_symlink(from, to)?;
} else if is_fifo(from_meta.file_type()) {
make_fifo(to)?;
} else {
// Copy a regular file.
Expand All @@ -1153,6 +1277,8 @@ fn copy_file_with_hardlinks_helper(
}
}

try_preserve_ownership_and_permissions(&from_meta, to);

Ok(())
}

Expand All @@ -1162,6 +1288,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 @@ -1200,6 +1329,11 @@ fn rename_file_fallback(
let _ = copy_xattrs_if_supported(from, to);
}

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

fs::remove_file(from)
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
Ok(())
Expand Down
Loading
Loading