1
Fork 0
mirror of https://github.com/RGBCube/uutils-coreutils synced 2025-07-27 02:57:44 +00:00

mv: improve the hardlink support (#8296)

* mv: implement hardlink support

Should fix GNU tests/mv/part-hardlink.sh tests/mv/hard-link-1.sh

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>

* mv: fix the GNU test - tests/mv/part-fail

* make it pass on windows

---------

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
This commit is contained in:
Sylvestre Ledru 2025-07-03 16:28:09 +02:00 committed by GitHub
parent 854d9af125
commit e9b24b4bc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1238 additions and 62 deletions

346
src/uu/mv/src/hardlink.rs Normal file
View file

@ -0,0 +1,346 @@
// 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 hardlinked
//! Hardlink preservation utilities for mv operations
//!
//! This module provides functionality to preserve hardlink relationships
//! when moving files across different filesystems/partitions.
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
/// Tracks hardlinks during cross-partition moves to preserve them
#[derive(Debug, Default)]
pub struct HardlinkTracker {
/// Maps (device, inode) -> destination path for the first occurrence
inode_map: HashMap<(u64, u64), PathBuf>,
}
/// Pre-scans files to identify hardlink groups with optimized memory usage
#[derive(Debug, Default)]
pub struct HardlinkGroupScanner {
/// Maps (device, inode) -> list of source paths that are hardlinked together
hardlink_groups: HashMap<(u64, u64), Vec<PathBuf>>,
/// List of source files/directories being moved (for destination mapping)
source_files: Vec<PathBuf>,
/// Whether scanning has been performed
scanned: bool,
}
/// Configuration options for hardlink preservation
#[derive(Debug, Clone, Default)]
pub struct HardlinkOptions {
/// Whether to show verbose output about hardlink operations
pub verbose: bool,
}
/// Result type for hardlink operations
pub type HardlinkResult<T> = Result<T, HardlinkError>;
/// Errors that can occur during hardlink operations
#[derive(Debug)]
pub enum HardlinkError {
Io(io::Error),
Scan(String),
Preservation { source: PathBuf, target: PathBuf },
Metadata { path: PathBuf, error: io::Error },
}
impl std::fmt::Display for HardlinkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HardlinkError::Io(e) => write!(f, "I/O error during hardlink operation: {}", e),
HardlinkError::Scan(msg) => {
write!(f, "Failed to scan files for hardlinks: {}", msg)
}
HardlinkError::Preservation { source, target } => {
write!(
f,
"Failed to preserve hardlink: {} -> {}",
source.display(),
target.display()
)
}
HardlinkError::Metadata { path, error } => {
write!(f, "Metadata access error for {}: {}", path.display(), error)
}
}
}
}
impl std::error::Error for HardlinkError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
HardlinkError::Io(e) => Some(e),
HardlinkError::Metadata { error, .. } => Some(error),
_ => None,
}
}
}
impl From<io::Error> for HardlinkError {
fn from(error: io::Error) -> Self {
HardlinkError::Io(error)
}
}
impl From<HardlinkError> for io::Error {
fn from(error: HardlinkError) -> Self {
match error {
HardlinkError::Io(e) => e,
HardlinkError::Scan(msg) => io::Error::other(msg),
HardlinkError::Preservation { source, target } => io::Error::other(format!(
"Failed to preserve hardlink: {} -> {}",
source.display(),
target.display()
)),
HardlinkError::Metadata { path, error } => io::Error::other(format!(
"Metadata access error for {}: {}",
path.display(),
error
)),
}
}
}
impl HardlinkTracker {
pub fn new() -> Self {
Self::default()
}
/// Check if a file is a hardlink we've seen before, and return the target path if so
pub fn check_hardlink(
&mut self,
source: &Path,
dest: &Path,
scanner: &HardlinkGroupScanner,
options: &HardlinkOptions,
) -> HardlinkResult<Option<PathBuf>> {
use std::os::unix::fs::MetadataExt;
let metadata = match source.metadata() {
Ok(meta) => meta,
Err(e) => {
// Gracefully handle metadata errors by logging and continuing without hardlink tracking
if options.verbose {
eprintln!(
"warning: cannot get metadata for {}: {}",
source.display(),
e
);
}
return Ok(None);
}
};
let key = (metadata.dev(), metadata.ino());
// Check if we've already processed a file with this inode
if let Some(existing_path) = self.inode_map.get(&key) {
// Check if this file is part of a hardlink group from the scanner
let has_hardlinks = scanner
.hardlink_groups
.get(&key)
.map(|group| group.len() > 1)
.unwrap_or(false);
if has_hardlinks {
if options.verbose {
eprintln!(
"preserving hardlink {} -> {} (hardlinked)",
source.display(),
existing_path.display()
);
}
return Ok(Some(existing_path.clone()));
}
}
// This is the first time we see this file, record its destination
self.inode_map.insert(key, dest.to_path_buf());
Ok(None)
}
}
impl HardlinkGroupScanner {
pub fn new() -> Self {
Self::default()
}
/// Scan files and group them by hardlinks, including recursive directory scanning
pub fn scan_files(
&mut self,
files: &[PathBuf],
options: &HardlinkOptions,
) -> HardlinkResult<()> {
if self.scanned {
return Ok(());
}
// Store the source files for destination mapping
self.source_files = files.to_vec();
for file in files {
if let Err(e) = self.scan_single_path(file) {
if options.verbose {
// Only show warnings for verbose mode
eprintln!("warning: failed to scan {}: {}", file.display(), e);
}
// For non-verbose mode, silently continue for missing files
// This provides graceful degradation - we'll lose hardlink info for this file
// but can still preserve hardlinks for other files
continue;
}
}
self.scanned = true;
if options.verbose {
let stats = self.stats();
if stats.total_groups > 0 {
eprintln!(
"found {} hardlink groups with {} total files",
stats.total_groups, stats.total_files
);
}
}
Ok(())
}
/// Scan a single path (file or directory)
fn scan_single_path(&mut self, path: &Path) -> io::Result<()> {
use std::os::unix::fs::MetadataExt;
if path.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());
}
}
Ok(())
}
/// Recursively scan a directory for hardlinked files
fn scan_directory_recursive(&mut self, dir: &Path) -> io::Result<()> {
use std::os::unix::fs::MetadataExt;
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.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);
}
}
}
Ok(())
}
#[cfg(not(unix))]
pub fn scan_files(
&mut self,
files: &[PathBuf],
_options: &HardlinkOptions,
) -> HardlinkResult<()> {
self.source_files = files.to_vec();
self.scanned = true;
Ok(())
}
#[cfg(not(unix))]
pub fn stats(&self) -> ScannerStats {
ScannerStats {
total_groups: 0,
total_files: 0,
}
}
/// Get statistics about scanned hardlinks
#[cfg(unix)]
pub fn stats(&self) -> ScannerStats {
let total_groups = self.hardlink_groups.len();
let total_files = self.hardlink_groups.values().map(|group| group.len()).sum();
ScannerStats {
total_groups,
total_files,
}
}
}
/// Statistics about hardlink scanning
#[derive(Debug, Clone)]
pub struct ScannerStats {
pub total_groups: usize,
pub total_files: usize,
}
/// Create a new hardlink tracker and scanner pair
pub fn create_hardlink_context() -> (HardlinkTracker, HardlinkGroupScanner) {
(HardlinkTracker::new(), HardlinkGroupScanner::new())
}
/// Convenient function to execute operations with proper hardlink context handling
pub fn with_optional_hardlink_context<F, R>(
tracker: Option<&mut HardlinkTracker>,
scanner: Option<&HardlinkGroupScanner>,
operation: F,
) -> R
where
F: FnOnce(&mut HardlinkTracker, &HardlinkGroupScanner) -> R,
{
match (tracker, scanner) {
(Some(tracker), Some(scanner)) => operation(tracker, scanner),
_ => {
let (mut dummy_tracker, dummy_scanner) = create_hardlink_context();
operation(&mut dummy_tracker, &dummy_scanner)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hardlink_tracker_creation() {
let _tracker = HardlinkTracker::new();
// Just test that creation works
}
#[test]
fn test_scanner_creation() {
let scanner = HardlinkGroupScanner::new();
let stats = scanner.stats();
assert_eq!(stats.total_groups, 0);
assert_eq!(stats.total_files, 0);
}
#[test]
fn test_create_hardlink_context() {
let (_tracker, scanner) = create_hardlink_context();
let stats = scanner.stats();
assert_eq!(stats.total_groups, 0);
assert_eq!(stats.total_files, 0);
}
}

View file

@ -6,6 +6,8 @@
// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized // spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized
mod error; mod error;
#[cfg(unix)]
mod hardlink;
use clap::builder::ValueParser; use clap::builder::ValueParser;
use clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind}; use clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind};
@ -24,6 +26,11 @@ use std::os::unix::fs::FileTypeExt;
use std::os::windows; use std::os::windows;
use std::path::{Path, PathBuf, absolute}; use std::path::{Path, PathBuf, absolute};
#[cfg(unix)]
use crate::hardlink::{
HardlinkGroupScanner, HardlinkOptions, HardlinkTracker, create_hardlink_context,
with_optional_hardlink_context,
};
use uucore::backup_control::{self, source_is_target_backup}; use uucore::backup_control::{self, source_is_target_backup};
use uucore::display::Quotable; use uucore::display::Quotable;
use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code}; use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code};
@ -42,10 +49,7 @@ use uucore::update_control;
pub use uucore::{backup_control::BackupMode, update_control::UpdateMode}; pub use uucore::{backup_control::BackupMode, update_control::UpdateMode};
use uucore::{format_usage, prompt_yes, show}; use uucore::{format_usage, prompt_yes, show};
use fs_extra::dir::{ use fs_extra::dir::get_size as dir_get_size;
CopyOptions as DirCopyOptions, TransitProcess, TransitProcessResult, get_size as dir_get_size,
move_dir, move_dir_with_progress,
};
use crate::error::MvError; use crate::error::MvError;
use uucore::locale::{get_message, get_message_with_args}; use uucore::locale::{get_message, get_message_with_args};
@ -357,7 +361,22 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
if target_is_dir { if target_is_dir {
if opts.no_target_dir { if opts.no_target_dir {
if source.is_dir() { if source.is_dir() {
rename(source, target, opts, None).map_err_context(|| { #[cfg(unix)]
let (mut hardlink_tracker, hardlink_scanner) = create_hardlink_context();
#[cfg(unix)]
let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
#[cfg(not(unix))]
let hardlink_params = (None, None);
rename(
source,
target,
opts,
None,
hardlink_params.0,
hardlink_params.1,
)
.map_err_context(|| {
get_message_with_args( get_message_with_args(
"mv-error-cannot-move", "mv-error-cannot-move",
HashMap::from([ HashMap::from([
@ -394,7 +413,22 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
) )
.into()) .into())
} else { } else {
rename(source, target, opts, None).map_err(|e| USimpleError::new(1, format!("{e}"))) #[cfg(unix)]
let (mut hardlink_tracker, hardlink_scanner) = create_hardlink_context();
#[cfg(unix)]
let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
#[cfg(not(unix))]
let hardlink_params = (None, None);
rename(
source,
target,
opts,
None,
hardlink_params.0,
hardlink_params.1,
)
.map_err(|e| USimpleError::new(1, format!("{e}")))
} }
} }
@ -516,6 +550,33 @@ pub fn mv(files: &[OsString], opts: &Options) -> UResult<()> {
fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) -> UResult<()> { fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) -> UResult<()> {
// remember the moved destinations for further usage // remember the moved destinations for further usage
let mut moved_destinations: HashSet<PathBuf> = HashSet::with_capacity(files.len()); let mut moved_destinations: HashSet<PathBuf> = HashSet::with_capacity(files.len());
// Create hardlink tracking context
#[cfg(unix)]
let (mut hardlink_tracker, hardlink_scanner) = {
let (tracker, mut scanner) = create_hardlink_context();
// Use hardlink options
let hardlink_options = HardlinkOptions {
verbose: options.verbose || options.debug,
};
// Pre-scan files if needed
if let Err(e) = scanner.scan_files(files, &hardlink_options) {
if hardlink_options.verbose {
eprintln!("mv: warning: failed to scan files for hardlinks: {}", e);
eprintln!("mv: continuing without hardlink preservation");
} else {
// Show warning in non-verbose mode for serious errors
eprintln!(
"mv: warning: hardlink scanning failed, continuing without hardlink preservation"
);
}
// Continue without hardlink tracking on scan failure
// This provides graceful degradation rather than failing completely
}
(tracker, scanner)
};
if !target_dir.is_dir() { if !target_dir.is_dir() {
return Err(MvError::NotADirectory(target_dir.quote().to_string()).into()); return Err(MvError::NotADirectory(target_dir.quote().to_string()).into());
@ -550,7 +611,8 @@ fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options)
} }
if let Some(ref pb) = count_progress { if let Some(ref pb) = count_progress {
pb.set_message(sourcepath.to_string_lossy().to_string()); let msg = format!("{} (scanning hardlinks)", sourcepath.to_string_lossy());
pb.set_message(msg);
} }
let targetpath = match sourcepath.file_name() { let targetpath = match sourcepath.file_name() {
@ -583,7 +645,19 @@ fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options)
continue; continue;
} }
match rename(sourcepath, &targetpath, options, multi_progress.as_ref()) { #[cfg(unix)]
let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
#[cfg(not(unix))]
let hardlink_params = (None, None);
match rename(
sourcepath,
&targetpath,
options,
multi_progress.as_ref(),
hardlink_params.0,
hardlink_params.1,
) {
Err(e) if e.to_string().is_empty() => set_exit_code(1), Err(e) if e.to_string().is_empty() => set_exit_code(1),
Err(e) => { Err(e) => {
let e = e.map_err_context(|| { let e = e.map_err_context(|| {
@ -615,6 +689,10 @@ fn rename(
to: &Path, to: &Path,
opts: &Options, opts: &Options,
multi_progress: Option<&MultiProgress>, multi_progress: Option<&MultiProgress>,
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
#[cfg(not(unix))] _hardlink_tracker: Option<()>,
#[cfg(not(unix))] _hardlink_scanner: Option<()>,
) -> io::Result<()> { ) -> io::Result<()> {
let mut backup_path = None; let mut backup_path = None;
@ -675,7 +753,8 @@ fn rename(
backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix); backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix);
if let Some(ref backup_path) = backup_path { if let Some(ref backup_path) = backup_path {
rename_with_fallback(to, backup_path, multi_progress)?; // For backup renames, we don't need to track hardlinks as we're just moving the existing file
rename_with_fallback(to, backup_path, multi_progress, None, None)?;
} }
} }
@ -693,7 +772,14 @@ fn rename(
} }
} }
rename_with_fallback(from, to, multi_progress)?; #[cfg(unix)]
{
rename_with_fallback(from, to, multi_progress, hardlink_tracker, hardlink_scanner)?;
}
#[cfg(not(unix))]
{
rename_with_fallback(from, to, multi_progress, None, None)?;
}
if opts.verbose { if opts.verbose {
let message = match backup_path { let message = match backup_path {
@ -740,6 +826,10 @@ fn rename_with_fallback(
from: &Path, from: &Path,
to: &Path, to: &Path,
multi_progress: Option<&MultiProgress>, multi_progress: Option<&MultiProgress>,
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
#[cfg(not(unix))] _hardlink_tracker: Option<()>,
#[cfg(not(unix))] _hardlink_scanner: Option<()>,
) -> io::Result<()> { ) -> io::Result<()> {
fs::rename(from, to).or_else(|err| { fs::rename(from, to).or_else(|err| {
#[cfg(windows)] #[cfg(windows)]
@ -762,11 +852,35 @@ fn rename_with_fallback(
if file_type.is_symlink() { if file_type.is_symlink() {
rename_symlink_fallback(from, to) rename_symlink_fallback(from, to)
} else if file_type.is_dir() { } else if file_type.is_dir() {
rename_dir_fallback(from, to, multi_progress) #[cfg(unix)]
{
with_optional_hardlink_context(
hardlink_tracker,
hardlink_scanner,
|tracker, scanner| {
rename_dir_fallback(from, to, multi_progress, Some(tracker), Some(scanner))
},
)
}
#[cfg(not(unix))]
{
rename_dir_fallback(from, to, multi_progress)
}
} else if is_fifo(file_type) { } else if is_fifo(file_type) {
rename_fifo_fallback(from, to) rename_fifo_fallback(from, to)
} else { } else {
rename_file_fallback(from, to) #[cfg(unix)]
{
with_optional_hardlink_context(
hardlink_tracker,
hardlink_scanner,
|tracker, scanner| rename_file_fallback(from, to, Some(tracker), Some(scanner)),
)
}
#[cfg(not(unix))]
{
rename_file_fallback(from, to)
}
} }
}) })
} }
@ -824,6 +938,8 @@ fn rename_dir_fallback(
from: &Path, from: &Path,
to: &Path, to: &Path,
multi_progress: Option<&MultiProgress>, multi_progress: Option<&MultiProgress>,
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
) -> io::Result<()> { ) -> io::Result<()> {
// We remove the destination directory if it exists to match the // We remove the destination directory if it exists to match the
// behavior of `fs::rename`. As far as I can tell, `fs_extra`'s // behavior of `fs::rename`. As far as I can tell, `fs_extra`'s
@ -831,13 +947,6 @@ fn rename_dir_fallback(
if to.exists() { if to.exists() {
fs::remove_dir_all(to)?; fs::remove_dir_all(to)?;
} }
let options = DirCopyOptions {
// From the `fs_extra` documentation:
// "Recursively copy a directory with a new name or place it
// inside the destination. (same behaviors like cp -r in Unix)"
copy_inside: true,
..DirCopyOptions::new()
};
// Calculate total size of directory // Calculate total size of directory
// Silently degrades: // Silently degrades:
@ -859,55 +968,194 @@ fn rename_dir_fallback(
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| HashMap::new()); let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| HashMap::new());
let result = if let Some(ref pb) = progress_bar { // Use directory copying (with or without hardlink support)
move_dir_with_progress(from, to, &options, |process_info: TransitProcess| { let result = copy_dir_contents(
pb.set_position(process_info.copied_bytes); from,
pb.set_message(process_info.file_name); to,
TransitProcessResult::ContinueOrAbort #[cfg(unix)]
}) hardlink_tracker,
} else { #[cfg(unix)]
move_dir(from, to, &options) hardlink_scanner,
}; progress_bar.as_ref(),
);
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
fsxattr::apply_xattrs(to, xattrs)?; fsxattr::apply_xattrs(to, xattrs)?;
match result { result?;
Err(err) => match err.kind {
fs_extra::error::ErrorKind::PermissionDenied => Err(io::Error::new( // Remove the source directory after successful copy
io::ErrorKind::PermissionDenied, fs::remove_dir_all(from)?;
get_message("mv-error-permission-denied"),
)), Ok(())
_ => Err(io::Error::other(format!("{err:?}"))),
},
_ => Ok(()),
}
} }
fn rename_file_fallback(from: &Path, to: &Path) -> io::Result<()> { /// Copy directory recursively, optionally preserving hardlinks
fn copy_dir_contents(
from: &Path,
to: &Path,
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
progress_bar: Option<&ProgressBar>,
) -> io::Result<()> {
// Create the destination directory
fs::create_dir_all(to)?;
// Recursively copy contents
#[cfg(unix)]
{
if let (Some(tracker), Some(scanner)) = (hardlink_tracker, hardlink_scanner) {
copy_dir_contents_recursive(from, to, tracker, scanner, progress_bar)?;
}
}
#[cfg(not(unix))]
{
copy_dir_contents_recursive(from, to, progress_bar)?;
}
Ok(())
}
fn copy_dir_contents_recursive(
from_dir: &Path,
to_dir: &Path,
#[cfg(unix)] hardlink_tracker: &mut HardlinkTracker,
#[cfg(unix)] hardlink_scanner: &HardlinkGroupScanner,
progress_bar: Option<&ProgressBar>,
) -> io::Result<()> {
let entries = fs::read_dir(from_dir)?;
for entry in entries {
let entry = entry?;
let from_path = entry.path();
let file_name = from_path.file_name().unwrap();
let to_path = to_dir.join(file_name);
if let Some(pb) = progress_bar {
pb.set_message(from_path.to_string_lossy().to_string());
}
if from_path.is_dir() {
// Recursively copy subdirectory
fs::create_dir_all(&to_path)?;
copy_dir_contents_recursive(
&from_path,
&to_path,
#[cfg(unix)]
hardlink_tracker,
#[cfg(unix)]
hardlink_scanner,
progress_bar,
)?;
} else {
// Copy file with or without hardlink support based on platform
#[cfg(unix)]
{
copy_file_with_hardlinks_helper(
&from_path,
&to_path,
hardlink_tracker,
hardlink_scanner,
)?;
}
#[cfg(not(unix))]
{
fs::copy(&from_path, &to_path)?;
}
}
if let Some(pb) = progress_bar {
if let Ok(metadata) = from_path.metadata() {
pb.inc(metadata.len());
}
}
}
Ok(())
}
#[cfg(unix)]
fn copy_file_with_hardlinks_helper(
from: &Path,
to: &Path,
hardlink_tracker: &mut HardlinkTracker,
hardlink_scanner: &HardlinkGroupScanner,
) -> io::Result<()> {
// Check if this file should be a hardlink to an already-copied file
use crate::hardlink::HardlinkOptions;
let hardlink_options = HardlinkOptions::default();
// Create a hardlink instead of copying
if let Some(existing_target) =
hardlink_tracker.check_hardlink(from, to, hardlink_scanner, &hardlink_options)?
{
fs::hard_link(&existing_target, to)?;
return Ok(());
}
// Regular file copy
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
{
fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?;
}
#[cfg(any(target_os = "macos", target_os = "redox"))]
{
fs::copy(from, to)?;
}
Ok(())
}
fn rename_file_fallback(
from: &Path,
to: &Path,
#[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
#[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
) -> io::Result<()> {
// Remove existing target file if it exists
if to.is_symlink() { if to.is_symlink() {
fs::remove_file(to).map_err(|err| { fs::remove_file(to).map_err(|err| {
let to = to.to_string_lossy(); let inter_device_msg = get_message_with_args(
let from = from.to_string_lossy(); "mv-error-inter-device-move-failed",
io::Error::new( HashMap::from([
err.kind(), ("from".to_string(), from.display().to_string()),
get_message_with_args( ("to".to_string(), to.display().to_string()),
"mv-error-inter-device-move-failed", ("err".to_string(), err.to_string()),
HashMap::from([ ]),
("from".to_string(), from.to_string()), );
("to".to_string(), to.to_string()), io::Error::new(err.kind(), inter_device_msg)
("err".to_string(), err.to_string()),
]),
),
)
})?; })?;
} else if to.exists() {
// For non-symlinks, just remove the file without special error handling
fs::remove_file(to)?;
} }
// Check if this file is part of a hardlink group and if so, create a hardlink instead of copying
#[cfg(unix)]
{
if let (Some(tracker), Some(scanner)) = (hardlink_tracker, hardlink_scanner) {
use crate::hardlink::HardlinkOptions;
let hardlink_options = HardlinkOptions::default();
if let Some(existing_target) =
tracker.check_hardlink(from, to, scanner, &hardlink_options)?
{
// Create a hardlink to the first moved file instead of copying
fs::hard_link(&existing_target, to)?;
fs::remove_file(from)?;
return Ok(());
}
}
}
// Regular file copy
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
fs::copy(from, to) fs::copy(from, to)
.and_then(|_| fsxattr::copy_xattrs(&from, &to)) .and_then(|_| fsxattr::copy_xattrs(&from, &to))
.and_then(|_| fs::remove_file(from))?; .and_then(|_| fs::remove_file(from))
.map_err(|err| io::Error::new(err.kind(), get_message("mv-error-permission-denied")))?;
#[cfg(any(target_os = "macos", target_os = "redox", not(unix)))] #[cfg(any(target_os = "macos", target_os = "redox", not(unix)))]
fs::copy(from, to).and_then(|_| fs::remove_file(from))?; fs::copy(from, to)
.and_then(|_| fs::remove_file(from))
.map_err(|err| io::Error::new(err.kind(), get_message("mv-error-permission-denied")))?;
Ok(()) Ok(())
} }

View file

@ -3,7 +3,8 @@
// For the full copyright and license information, please view the LICENSE // For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code. // file that was distributed with this source code.
// //
// spell-checker:ignore mydir // spell-checker:ignore mydir hardlinked tmpfs
use filetime::FileTime; use filetime::FileTime;
use rstest::rstest; use rstest::rstest;
use std::io::Write; use std::io::Write;
@ -1845,24 +1846,17 @@ mod inter_partition_copying {
let scene = TestScenario::new(util_name!()); let scene = TestScenario::new(util_name!());
let at = &scene.fixtures; let at = &scene.fixtures;
// create a file in the current partition.
at.write("src", "src contents"); at.write("src", "src contents");
// create a folder in another partition.
let other_fs_tempdir = let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory"); TempDir::new_in("/dev/shm/").expect("Unable to create temp directory");
// create a file inside that folder.
let other_fs_file_path = other_fs_tempdir.path().join("other_fs_file"); let other_fs_file_path = other_fs_tempdir.path().join("other_fs_file");
write(&other_fs_file_path, "other fs file contents") write(&other_fs_file_path, "other fs file contents")
.expect("Unable to write to other_fs_file"); .expect("Unable to write to other_fs_file");
// create a symlink to the file inside the same directory.
let symlink_path = other_fs_tempdir.path().join("symlink_to_file"); let symlink_path = other_fs_tempdir.path().join("symlink_to_file");
symlink(&other_fs_file_path, &symlink_path).expect("Unable to create symlink_to_file"); symlink(&other_fs_file_path, &symlink_path).expect("Unable to create symlink_to_file");
// disable write for the target folder so that when mv tries to remove the
// the destination symlink inside the target directory it would fail.
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o555)) set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o555))
.expect("Unable to set permissions for temp directory"); .expect("Unable to set permissions for temp directory");
@ -1875,6 +1869,459 @@ mod inter_partition_copying {
.stderr_contains("inter-device move failed:") .stderr_contains("inter-device move failed:")
.stderr_contains("Permission denied"); .stderr_contains("Permission denied");
} }
// Test that hardlinks are preserved when moving files across partitions
#[test]
#[cfg(unix)]
pub(crate) fn test_mv_preserves_hardlinks_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("file1", "test content");
at.hard_link("file1", "file2");
let metadata1 = metadata(at.plus("file1")).expect("Failed to get metadata for file1");
let metadata2 = metadata(at.plus("file2")).expect("Failed to get metadata for file2");
assert_eq!(
metadata1.ino(),
metadata2.ino(),
"Files should have same inode before move"
);
assert_eq!(
metadata1.nlink(),
2,
"Files should have nlink=2 before move"
);
// Create a target directory in another partition (using /dev/shm which is typically tmpfs)
let other_fs_tempdir = TempDir::new_in("/dev/shm/")
.expect("Unable to create temp directory in /dev/shm - test requires tmpfs");
scene
.ucmd()
.arg("file1")
.arg("file2")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
assert!(!at.file_exists("file1"), "file1 should not exist in source");
assert!(!at.file_exists("file2"), "file2 should not exist in source");
let moved_file1 = other_fs_tempdir.path().join("file1");
let moved_file2 = other_fs_tempdir.path().join("file2");
assert!(moved_file1.exists(), "file1 should exist in destination");
assert!(moved_file2.exists(), "file2 should exist in destination");
let moved_metadata1 =
metadata(&moved_file1).expect("Failed to get metadata for moved file1");
let moved_metadata2 =
metadata(&moved_file2).expect("Failed to get metadata for moved file2");
assert_eq!(
moved_metadata1.ino(),
moved_metadata2.ino(),
"Files should have same inode after cross-partition move (hardlinks preserved)"
);
assert_eq!(
moved_metadata1.nlink(),
2,
"Files should have nlink=2 after cross-partition move"
);
// Verify content is preserved
assert_eq!(
std::fs::read_to_string(&moved_file1).expect("Failed to read moved file1"),
"test content"
);
assert_eq!(
std::fs::read_to_string(&moved_file2).expect("Failed to read moved file2"),
"test content"
);
}
// Test that hardlinks are preserved even with multiple sets of hardlinked files
#[test]
#[cfg(unix)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)]
pub(crate) fn test_mv_preserves_multiple_hardlink_groups_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("group1_file1", "content group 1");
at.hard_link("group1_file1", "group1_file2");
at.write("group2_file1", "content group 2");
at.hard_link("group2_file1", "group2_file2");
at.write("single_file", "single file content");
let g1f1_meta = metadata(at.plus("group1_file1")).unwrap();
let g1f2_meta = metadata(at.plus("group1_file2")).unwrap();
let g2f1_meta = metadata(at.plus("group2_file1")).unwrap();
let g2f2_meta = metadata(at.plus("group2_file2")).unwrap();
let single_meta = metadata(at.plus("single_file")).unwrap();
assert_eq!(
g1f1_meta.ino(),
g1f2_meta.ino(),
"Group 1 files should have same inode"
);
assert_eq!(
g2f1_meta.ino(),
g2f2_meta.ino(),
"Group 2 files should have same inode"
);
assert_ne!(
g1f1_meta.ino(),
g2f1_meta.ino(),
"Different groups should have different inodes"
);
assert_eq!(single_meta.nlink(), 1, "Single file should have nlink=1");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("group1_file1")
.arg("group1_file2")
.arg("group2_file1")
.arg("group2_file2")
.arg("single_file")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
// Verify hardlinks are preserved for both groups
let moved_g1f1 = other_fs_tempdir.path().join("group1_file1");
let moved_g1f2 = other_fs_tempdir.path().join("group1_file2");
let moved_g2f1 = other_fs_tempdir.path().join("group2_file1");
let moved_g2f2 = other_fs_tempdir.path().join("group2_file2");
let moved_single = other_fs_tempdir.path().join("single_file");
let moved_g1f1_meta = metadata(&moved_g1f1).unwrap();
let moved_g1f2_meta = metadata(&moved_g1f2).unwrap();
let moved_g2f1_meta = metadata(&moved_g2f1).unwrap();
let moved_g2f2_meta = metadata(&moved_g2f2).unwrap();
let moved_single_meta = metadata(&moved_single).unwrap();
assert_eq!(
moved_g1f1_meta.ino(),
moved_g1f2_meta.ino(),
"Group 1 files should still be hardlinked after move"
);
assert_eq!(
moved_g1f1_meta.nlink(),
2,
"Group 1 files should have nlink=2"
);
assert_eq!(
moved_g2f1_meta.ino(),
moved_g2f2_meta.ino(),
"Group 2 files should still be hardlinked after move"
);
assert_eq!(
moved_g2f1_meta.nlink(),
2,
"Group 2 files should have nlink=2"
);
assert_ne!(
moved_g1f1_meta.ino(),
moved_g2f1_meta.ino(),
"Different groups should still have different inodes"
);
assert_eq!(
moved_single_meta.nlink(),
1,
"Single file should still have nlink=1"
);
assert_eq!(
std::fs::read_to_string(&moved_g1f1).unwrap(),
"content group 1"
);
assert_eq!(
std::fs::read_to_string(&moved_g1f2).unwrap(),
"content group 1"
);
assert_eq!(
std::fs::read_to_string(&moved_g2f1).unwrap(),
"content group 2"
);
assert_eq!(
std::fs::read_to_string(&moved_g2f2).unwrap(),
"content group 2"
);
assert_eq!(
std::fs::read_to_string(&moved_single).unwrap(),
"single file content"
);
}
// Test the exact GNU test scenario: hardlinks within directories being moved
#[test]
#[cfg(unix)]
pub(crate) fn test_mv_preserves_hardlinks_in_directories_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("f", "file content");
at.hard_link("f", "g");
at.mkdir("a");
at.mkdir("b");
at.write("a/1", "directory file content");
at.hard_link("a/1", "b/1");
let f_meta = metadata(at.plus("f")).unwrap();
let g_meta = metadata(at.plus("g")).unwrap();
let a1_meta = metadata(at.plus("a/1")).unwrap();
let b1_meta = metadata(at.plus("b/1")).unwrap();
assert_eq!(
f_meta.ino(),
g_meta.ino(),
"f and g should have same inode before move"
);
assert_eq!(f_meta.nlink(), 2, "f should have nlink=2 before move");
assert_eq!(
a1_meta.ino(),
b1_meta.ino(),
"a/1 and b/1 should have same inode before move"
);
assert_eq!(a1_meta.nlink(), 2, "a/1 should have nlink=2 before move");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("f")
.arg("g")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
scene
.ucmd()
.arg("a")
.arg("b")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
let moved_f = other_fs_tempdir.path().join("f");
let moved_g = other_fs_tempdir.path().join("g");
let moved_f_metadata = metadata(&moved_f).unwrap();
let moved_second_file_metadata = metadata(&moved_g).unwrap();
assert_eq!(
moved_f_metadata.ino(),
moved_second_file_metadata.ino(),
"f and g should have same inode after cross-partition move"
);
assert_eq!(
moved_f_metadata.nlink(),
2,
"f should have nlink=2 after move"
);
// Verify directory files' hardlinks are preserved (the main test)
let moved_dir_a_file = other_fs_tempdir.path().join("a/1");
let moved_dir_second_file = other_fs_tempdir.path().join("b/1");
let moved_dir_a_file_metadata = metadata(&moved_dir_a_file).unwrap();
let moved_dir_second_file_metadata = metadata(&moved_dir_second_file).unwrap();
assert_eq!(
moved_dir_a_file_metadata.ino(),
moved_dir_second_file_metadata.ino(),
"a/1 and b/1 should have same inode after cross-partition directory move (hardlinks preserved)"
);
assert_eq!(
moved_dir_a_file_metadata.nlink(),
2,
"a/1 should have nlink=2 after move"
);
assert_eq!(std::fs::read_to_string(&moved_f).unwrap(), "file content");
assert_eq!(std::fs::read_to_string(&moved_g).unwrap(), "file content");
assert_eq!(
std::fs::read_to_string(&moved_dir_a_file).unwrap(),
"directory file content"
);
assert_eq!(
std::fs::read_to_string(&moved_dir_second_file).unwrap(),
"directory file content"
);
}
// Test complex scenario with multiple hardlink groups across nested directories
#[test]
#[cfg(unix)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)]
pub(crate) fn test_mv_preserves_complex_hardlinks_across_nested_directories() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.mkdir("dir1");
at.mkdir("dir1/subdir1");
at.mkdir("dir1/subdir2");
at.mkdir("dir2");
at.mkdir("dir2/subdir1");
at.write("dir1/subdir1/file_a", "content A");
at.hard_link("dir1/subdir1/file_a", "dir1/subdir2/file_a_link1");
at.hard_link("dir1/subdir1/file_a", "dir2/subdir1/file_a_link2");
at.write("dir1/file_b", "content B");
at.hard_link("dir1/file_b", "dir2/file_b_link");
at.write("dir1/subdir1/nested_file", "nested content");
at.hard_link("dir1/subdir1/nested_file", "dir1/subdir2/nested_file_link");
let orig_file_a_metadata = metadata(at.plus("dir1/subdir1/file_a")).unwrap();
let orig_file_a_link1_metadata = metadata(at.plus("dir1/subdir2/file_a_link1")).unwrap();
let orig_file_a_link2_metadata = metadata(at.plus("dir2/subdir1/file_a_link2")).unwrap();
assert_eq!(orig_file_a_metadata.ino(), orig_file_a_link1_metadata.ino());
assert_eq!(orig_file_a_metadata.ino(), orig_file_a_link2_metadata.ino());
assert_eq!(
orig_file_a_metadata.nlink(),
3,
"file_a group should have nlink=3"
);
let orig_file_b_metadata = metadata(at.plus("dir1/file_b")).unwrap();
let orig_file_b_link_metadata = metadata(at.plus("dir2/file_b_link")).unwrap();
assert_eq!(orig_file_b_metadata.ino(), orig_file_b_link_metadata.ino());
assert_eq!(
orig_file_b_metadata.nlink(),
2,
"file_b group should have nlink=2"
);
let nested_meta = metadata(at.plus("dir1/subdir1/nested_file")).unwrap();
let nested_link_meta = metadata(at.plus("dir1/subdir2/nested_file_link")).unwrap();
assert_eq!(nested_meta.ino(), nested_link_meta.ino());
assert_eq!(
nested_meta.nlink(),
2,
"nested file group should have nlink=2"
);
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("dir1")
.arg("dir2")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
let moved_file_a = other_fs_tempdir.path().join("dir1/subdir1/file_a");
let moved_file_a_link1 = other_fs_tempdir.path().join("dir1/subdir2/file_a_link1");
let moved_file_a_link2 = other_fs_tempdir.path().join("dir2/subdir1/file_a_link2");
let final_file_a_metadata = metadata(&moved_file_a).unwrap();
let final_file_a_link1_metadata = metadata(&moved_file_a_link1).unwrap();
let final_file_a_link2_metadata = metadata(&moved_file_a_link2).unwrap();
assert_eq!(
final_file_a_metadata.ino(),
final_file_a_link1_metadata.ino(),
"file_a hardlinks should be preserved"
);
assert_eq!(
final_file_a_metadata.ino(),
final_file_a_link2_metadata.ino(),
"file_a hardlinks should be preserved across directories"
);
assert_eq!(
final_file_a_metadata.nlink(),
3,
"file_a group should still have nlink=3"
);
let moved_file_b = other_fs_tempdir.path().join("dir1/file_b");
let moved_file_b_hardlink = other_fs_tempdir.path().join("dir2/file_b_link");
let final_file_b_metadata = metadata(&moved_file_b).unwrap();
let final_file_b_hardlink_metadata = metadata(&moved_file_b_hardlink).unwrap();
assert_eq!(
final_file_b_metadata.ino(),
final_file_b_hardlink_metadata.ino(),
"file_b hardlinks should be preserved"
);
assert_eq!(
final_file_b_metadata.nlink(),
2,
"file_b group should still have nlink=2"
);
let moved_nested = other_fs_tempdir.path().join("dir1/subdir1/nested_file");
let moved_nested_link = other_fs_tempdir
.path()
.join("dir1/subdir2/nested_file_link");
let moved_nested_meta = metadata(&moved_nested).unwrap();
let moved_nested_link_meta = metadata(&moved_nested_link).unwrap();
assert_eq!(
moved_nested_meta.ino(),
moved_nested_link_meta.ino(),
"nested file hardlinks should be preserved"
);
assert_eq!(
moved_nested_meta.nlink(),
2,
"nested file group should still have nlink=2"
);
assert_eq!(std::fs::read_to_string(&moved_file_a).unwrap(), "content A");
assert_eq!(
std::fs::read_to_string(&moved_file_a_link1).unwrap(),
"content A"
);
assert_eq!(
std::fs::read_to_string(&moved_file_a_link2).unwrap(),
"content A"
);
assert_eq!(std::fs::read_to_string(&moved_file_b).unwrap(), "content B");
assert_eq!(
std::fs::read_to_string(&moved_file_b_hardlink).unwrap(),
"content B"
);
assert_eq!(
std::fs::read_to_string(&moved_nested).unwrap(),
"nested content"
);
assert_eq!(
std::fs::read_to_string(&moved_nested_link).unwrap(),
"nested content"
);
}
} }
#[test] #[test]
@ -1892,6 +2339,97 @@ fn test_mv_error_msg_with_multiple_sources_that_does_not_exist() {
.stderr_contains("mv: cannot stat 'b/': No such file or directory"); .stderr_contains("mv: cannot stat 'b/': No such file or directory");
} }
// Tests for hardlink preservation (now always enabled)
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_hardlink_preservation() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1", "test content");
at.hard_link("file1", "file2");
at.mkdir("target");
ucmd.arg("file1")
.arg("file2")
.arg("target")
.succeeds()
.no_stderr();
assert!(at.file_exists("target/file1"));
assert!(at.file_exists("target/file2"));
}
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_hardlink_progress_indication() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1", "content1");
at.write("file2", "content2");
at.hard_link("file1", "file1_link");
at.mkdir("target");
// Test with progress bar and verbose mode
ucmd.arg("--progress")
.arg("--verbose")
.arg("file1")
.arg("file1_link")
.arg("file2")
.arg("target")
.succeeds();
// Verify all files were moved
assert!(at.file_exists("target/file1"));
assert!(at.file_exists("target/file1_link"));
assert!(at.file_exists("target/file2"));
}
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_mixed_hardlinks_and_regular_files() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
let (at, mut ucmd) = at_and_ucmd!();
// Create a mix of hardlinked and regular files
at.write("hardlink1", "hardlink content");
at.hard_link("hardlink1", "hardlink2");
at.write("regular1", "regular content");
at.write("regular2", "regular content 2");
at.mkdir("target");
// Move all files (hardlinks automatically preserved)
ucmd.arg("hardlink1")
.arg("hardlink2")
.arg("regular1")
.arg("regular2")
.arg("target")
.succeeds();
// Verify all files moved
assert!(at.file_exists("target/hardlink1"));
assert!(at.file_exists("target/hardlink2"));
assert!(at.file_exists("target/regular1"));
assert!(at.file_exists("target/regular2"));
// Verify hardlinks are preserved (on same filesystem)
let h1_meta = metadata(at.plus("target/hardlink1")).unwrap();
let h2_meta = metadata(at.plus("target/hardlink2")).unwrap();
let r1_meta = metadata(at.plus("target/regular1")).unwrap();
let r2_meta = metadata(at.plus("target/regular2")).unwrap();
// Hardlinked files should have same inode if on same filesystem
if h1_meta.dev() == h2_meta.dev() {
assert_eq!(h1_meta.ino(), h2_meta.ino());
}
// Regular files should have different inodes
assert_ne!(r1_meta.ino(), r2_meta.ino());
}
#[cfg(not(windows))] #[cfg(not(windows))]
#[ignore = "requires access to a different filesystem"] #[ignore = "requires access to a different filesystem"]
#[test] #[test]
@ -1906,3 +2444,47 @@ fn test_special_file_different_filesystem() {
assert!(Path::new("/dev/shm/tmp/f").exists()); assert!(Path::new("/dev/shm/tmp/f").exists());
std::fs::remove_dir_all("/dev/shm/tmp").unwrap(); std::fs::remove_dir_all("/dev/shm/tmp").unwrap();
} }
/// Test cross-device move with permission denied error
/// This test mimics the scenario from the GNU part-fail test where
/// a cross-device move fails due to permission errors when removing the target file
#[test]
#[cfg(target_os = "linux")]
fn test_mv_cross_device_permission_denied() {
use std::fs::{set_permissions, write};
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("k", "source content");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
let target_file_path = other_fs_tempdir.path().join("k");
write(&target_file_path, "target content").expect("Unable to write target file");
// Remove write permissions from the directory to cause permission denied
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o555))
.expect("Unable to set directory permissions");
// Attempt to move file to the other filesystem
// This should fail with a permission denied error
let result = scene
.ucmd()
.arg("-f")
.arg("k")
.arg(target_file_path.to_str().unwrap())
.fails();
// Check that it contains permission denied and references the file
// The exact format may vary but should contain these key elements
let stderr = result.stderr_str();
assert!(stderr.contains("Permission denied") || stderr.contains("permission denied"));
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o755))
.expect("Unable to restore directory permissions");
}