Skip to content

Commit 0390f74

Browse files
committed
fixes #8220
implements the --exchange flag for linux systems with testing
1 parent 8bbce0d commit 0390f74

File tree

4 files changed

+269
-0
lines changed

4 files changed

+269
-0
lines changed

src/uu/mv/locales/en-US.ftl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ mv-error-not-directory = target {$path}: Not a directory
2626
mv-error-target-not-directory = target directory {$path}: Not a directory
2727
mv-error-failed-access-not-directory = failed to access {$path}: Not a directory
2828
mv-error-backup-with-no-clobber = cannot combine --backup with -n/--no-clobber or --update=none-fail
29+
mv-error-exchange-needs-two-files = --exchange requires exactly two files to exchange
30+
mv-error-exchange-conflicts-with-target-directory = --exchange conflicts with --target-directory
31+
mv-error-exchange-conflicts-with-backup = --exchange conflicts with backup options
32+
mv-error-exchange-conflicts-with-update = --exchange conflicts with update options
33+
mv-error-exchange-not-supported = --exchange is not supported on this system
34+
mv-error-exchange-cross-device = --exchange requires both files to be on the same filesystem
2935
mv-error-extra-operand = mv: extra operand {$operand}
3036
mv-error-backup-might-destroy-source = backing up {$target} might destroy source; {$source} not moved
3137
mv-error-will-not-overwrite-just-created = will not overwrite just-created '{$target}' with '{$source}'
@@ -48,6 +54,7 @@ mv-help-verbose = explain what is being done
4854
mv-help-progress = Display a progress bar.
4955
Note: this feature is not supported by GNU coreutils.
5056
mv-help-debug = explain how a file is copied. Implies -v
57+
mv-help-exchange = exchange two files atomically (Linux only)
5158
5259
# Verbose messages
5360
mv-verbose-renamed = renamed {$from} -> {$to}

src/uu/mv/locales/fr-FR.ftl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ mv-error-not-directory = cible {$path} : N'est pas un répertoire
2626
mv-error-target-not-directory = répertoire cible {$path} : N'est pas un répertoire
2727
mv-error-failed-access-not-directory = impossible d'accéder à {$path} : N'est pas un répertoire
2828
mv-error-backup-with-no-clobber = impossible de combiner --backup avec -n/--no-clobber ou --update=none-fail
29+
mv-error-exchange-needs-two-files = --exchange nécessite exactement deux fichiers à échanger
30+
mv-error-exchange-conflicts-with-target-directory = --exchange est en conflit avec --target-directory
31+
mv-error-exchange-conflicts-with-backup = --exchange est en conflit avec les options de sauvegarde
32+
mv-error-exchange-conflicts-with-update = --exchange est en conflit avec les options de mise à jour
33+
mv-error-exchange-not-supported = --exchange n'est pas pris en charge sur ce système
34+
mv-error-exchange-cross-device = --exchange nécessite que les deux fichiers soient sur le même système de fichiers
2935
mv-error-extra-operand = mv : opérande supplémentaire {$operand}
3036
mv-error-backup-might-destroy-source = sauvegarder {$target} pourrait détruire la source ; {$source} non déplacé
3137
mv-error-will-not-overwrite-just-created = ne va pas écraser le fichier qui vient d'être créé '{$target}' avec '{$source}'
@@ -48,6 +54,7 @@ mv-help-verbose = expliquer ce qui est fait
4854
mv-help-progress = Afficher une barre de progression.
4955
Note : cette fonctionnalité n'est pas supportée par GNU coreutils.
5056
mv-help-debug = expliquer comment un fichier est copié. Implique -v
57+
mv-help-exchange = échanger deux fichiers de manière atomique (Linux uniquement)
5158
5259
# Messages verbeux
5360
mv-verbose-renamed = renommé {$from} -> {$to}

src/uu/mv/src/mv.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub struct Options {
9797

9898
/// `--debug`
9999
pub debug: bool,
100+
101+
/// `--exchange`
102+
pub exchange: bool,
100103
}
101104

102105
impl Default for Options {
@@ -112,6 +115,7 @@ impl Default for Options {
112115
strip_slashes: false,
113116
progress_bar: false,
114117
debug: false,
118+
exchange: false,
115119
}
116120
}
117121
}
@@ -138,6 +142,7 @@ static OPT_VERBOSE: &str = "verbose";
138142
static OPT_PROGRESS: &str = "progress";
139143
static ARG_FILES: &str = "files";
140144
static OPT_DEBUG: &str = "debug";
145+
static OPT_EXCHANGE: &str = "exchange";
141146

142147
#[uucore::main]
143148
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
@@ -175,6 +180,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
175180
));
176181
}
177182

183+
// Validate exchange flag
184+
if matches.get_flag(OPT_EXCHANGE) {
185+
if files.len() != 2 {
186+
return Err(UUsageError::new(
187+
1,
188+
get_message("mv-error-exchange-needs-two-files"),
189+
));
190+
}
191+
if matches.contains_id(OPT_TARGET_DIRECTORY) {
192+
return Err(UUsageError::new(
193+
1,
194+
get_message("mv-error-exchange-conflicts-with-target-directory"),
195+
));
196+
}
197+
if backup_mode != BackupMode::None {
198+
return Err(UUsageError::new(
199+
1,
200+
get_message("mv-error-exchange-conflicts-with-backup"),
201+
));
202+
}
203+
if update_mode != UpdateMode::All {
204+
return Err(UUsageError::new(
205+
1,
206+
get_message("mv-error-exchange-conflicts-with-update"),
207+
));
208+
}
209+
}
210+
178211
let backup_suffix = backup_control::determine_backup_suffix(&matches);
179212

180213
let target_dir = matches
@@ -198,6 +231,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
198231
strip_slashes: matches.get_flag(OPT_STRIP_TRAILING_SLASHES),
199232
progress_bar: matches.get_flag(OPT_PROGRESS),
200233
debug: matches.get_flag(OPT_DEBUG),
234+
exchange: matches.get_flag(OPT_EXCHANGE),
201235
};
202236

203237
mv(&files[..], &opts)
@@ -294,6 +328,12 @@ pub fn uu_app() -> Command {
294328
.help(get_message("mv-help-debug"))
295329
.action(ArgAction::SetTrue),
296330
)
331+
.arg(
332+
Arg::new(OPT_EXCHANGE)
333+
.long(OPT_EXCHANGE)
334+
.help(get_message("mv-help-exchange"))
335+
.action(ArgAction::SetTrue),
336+
)
297337
}
298338

299339
fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode {
@@ -311,6 +351,73 @@ fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode {
311351
}
312352
}
313353

354+
/// Atomically exchange two files using renameat2 with RENAME_EXCHANGE
355+
#[cfg(target_os = "linux")]
356+
fn exchange_files(path1: &Path, path2: &Path, opts: &Options) -> UResult<()> {
357+
use std::ffi::CString;
358+
use std::os::unix::ffi::OsStrExt;
359+
360+
// Convert paths to C strings
361+
let c_path1 = CString::new(path1.as_os_str().as_bytes())
362+
.map_err(|e| USimpleError::new(1, format!("Invalid path {}: {}", path1.display(), e)))?;
363+
let c_path2 = CString::new(path2.as_os_str().as_bytes())
364+
.map_err(|e| USimpleError::new(1, format!("Invalid path {}: {}", path2.display(), e)))?;
365+
366+
// RENAME_EXCHANGE flag for renameat2
367+
const RENAME_EXCHANGE: libc::c_int = 2;
368+
369+
// Use renameat2 to atomically exchange the files
370+
let result = unsafe {
371+
libc::syscall(
372+
libc::SYS_renameat2,
373+
libc::AT_FDCWD,
374+
c_path1.as_ptr(),
375+
libc::AT_FDCWD,
376+
c_path2.as_ptr(),
377+
RENAME_EXCHANGE,
378+
)
379+
};
380+
381+
if result == 0 {
382+
if opts.verbose {
383+
println!("{} <-> {}", path1.display(), path2.display());
384+
}
385+
Ok(())
386+
} else {
387+
let errno = unsafe { *libc::__errno_location() };
388+
match errno {
389+
libc::ENOTSUP | libc::EINVAL => Err(USimpleError::new(
390+
1,
391+
get_message("mv-error-exchange-not-supported"),
392+
)),
393+
libc::ENOENT => {
394+
let missing_path = if !path1.exists() { path1 } else { path2 };
395+
Err(MvError::NoSuchFile(missing_path.display().to_string()).into())
396+
}
397+
libc::EXDEV => Err(USimpleError::new(
398+
1,
399+
get_message("mv-error-exchange-cross-device"),
400+
)),
401+
_ => {
402+
let error_msg = std::io::Error::from_raw_os_error(errno);
403+
Err(USimpleError::new(
404+
1,
405+
format!("exchange failed: {}", error_msg),
406+
))
407+
}
408+
}
409+
}
410+
}
411+
412+
/// Fallback exchange implementation for non-Linux systems
413+
#[cfg(not(target_os = "linux"))]
414+
fn exchange_files(_path1: &Path, _path2: &Path, _opts: &Options) -> UResult<()> {
415+
Err(USimpleError::new(
416+
1,
417+
get_message("mv-error-exchange-not-supported"),
418+
))
419+
}
420+
314421
fn parse_paths(files: &[OsString], opts: &Options) -> Vec<PathBuf> {
315422
let paths = files.iter().map(Path::new);
316423

@@ -536,6 +643,17 @@ fn handle_multiple_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> {
536643
pub fn mv(files: &[OsString], opts: &Options) -> UResult<()> {
537644
let paths = parse_paths(files, opts);
538645

646+
// Handle exchange mode
647+
if opts.exchange {
648+
if paths.len() != 2 {
649+
return Err(USimpleError::new(
650+
1,
651+
get_message("mv-error-exchange-needs-two-files"),
652+
));
653+
}
654+
return exchange_files(&paths[0], &paths[1], opts);
655+
}
656+
539657
if let Some(ref name) = opts.target_dir {
540658
return move_files_into_dir(&paths, &PathBuf::from(name), opts);
541659
}

tests/by-util/test_mv.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2488,3 +2488,140 @@ fn test_mv_cross_device_permission_denied() {
24882488
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o755))
24892489
.expect("Unable to restore directory permissions");
24902490
}
2491+
2492+
// Tests for --exchange flag
2493+
#[test]
2494+
#[cfg(target_os = "linux")]
2495+
fn test_mv_exchange_basic() {
2496+
let (at, mut ucmd) = at_and_ucmd!();
2497+
2498+
at.write("file1", "content1");
2499+
at.write("file2", "content2");
2500+
2501+
ucmd.arg("--exchange").arg("file1").arg("file2").succeeds();
2502+
2503+
// After exchange, file1 should have content2 and file2 should have content1
2504+
assert_eq!(at.read("file1"), "content2");
2505+
assert_eq!(at.read("file2"), "content1");
2506+
}
2507+
2508+
#[test]
2509+
#[cfg(target_os = "linux")]
2510+
fn test_mv_exchange_verbose() {
2511+
let (at, mut ucmd) = at_and_ucmd!();
2512+
2513+
at.write("file1", "content1");
2514+
at.write("file2", "content2");
2515+
2516+
ucmd.arg("--exchange")
2517+
.arg("--verbose")
2518+
.arg("file1")
2519+
.arg("file2")
2520+
.succeeds()
2521+
.stdout_contains("file1 <-> file2");
2522+
}
2523+
2524+
#[test]
2525+
fn test_mv_exchange_wrong_number_of_args() {
2526+
let (at, mut ucmd) = at_and_ucmd!();
2527+
2528+
at.write("file1", "content1");
2529+
2530+
ucmd.arg("--exchange")
2531+
.arg("file1")
2532+
.fails()
2533+
.stderr_contains("requires at least 2 values");
2534+
}
2535+
2536+
#[test]
2537+
fn test_mv_exchange_three_files() {
2538+
let (at, mut ucmd) = at_and_ucmd!();
2539+
2540+
at.write("file1", "content1");
2541+
at.write("file2", "content2");
2542+
at.write("file3", "content3");
2543+
2544+
ucmd.arg("--exchange")
2545+
.arg("file1")
2546+
.arg("file2")
2547+
.arg("file3")
2548+
.fails()
2549+
.stderr_contains("--exchange requires exactly two files");
2550+
}
2551+
2552+
#[test]
2553+
fn test_mv_exchange_conflicts_with_target_directory() {
2554+
let (at, mut ucmd) = at_and_ucmd!();
2555+
2556+
at.write("file1", "content1");
2557+
at.write("file2", "content2");
2558+
at.mkdir("dir");
2559+
2560+
ucmd.arg("--exchange")
2561+
.arg("--target-directory")
2562+
.arg("dir")
2563+
.arg("file1")
2564+
.arg("file2")
2565+
.fails()
2566+
.stderr_contains("--exchange conflicts with --target-directory");
2567+
}
2568+
2569+
#[test]
2570+
fn test_mv_exchange_conflicts_with_backup() {
2571+
let (at, mut ucmd) = at_and_ucmd!();
2572+
2573+
at.write("file1", "content1");
2574+
at.write("file2", "content2");
2575+
2576+
ucmd.arg("--exchange")
2577+
.arg("--backup")
2578+
.arg("file1")
2579+
.arg("file2")
2580+
.fails()
2581+
.stderr_contains("--exchange conflicts with backup options");
2582+
}
2583+
2584+
#[test]
2585+
#[cfg(target_os = "linux")]
2586+
fn test_mv_exchange_missing_file() {
2587+
let (at, mut ucmd) = at_and_ucmd!();
2588+
2589+
at.write("file1", "content1");
2590+
// file2 doesn't exist
2591+
2592+
ucmd.arg("--exchange")
2593+
.arg("file1")
2594+
.arg("file2")
2595+
.fails()
2596+
.stderr_contains("cannot stat file2: No such file or directory");
2597+
}
2598+
2599+
#[test]
2600+
#[cfg(not(target_os = "linux"))]
2601+
fn test_mv_exchange_missing_file() {
2602+
let (at, mut ucmd) = at_and_ucmd!();
2603+
2604+
at.write("file1", "content1");
2605+
// file2 doesn't exist
2606+
2607+
ucmd.arg("--exchange")
2608+
.arg("file1")
2609+
.arg("file2")
2610+
.fails()
2611+
.stderr_contains("--exchange is not supported on this system");
2612+
}
2613+
2614+
#[test]
2615+
#[cfg(not(target_os = "linux"))]
2616+
fn test_mv_exchange_not_supported() {
2617+
let (at, mut ucmd) = at_and_ucmd!();
2618+
2619+
at.write("file1", "content1");
2620+
at.write("file2", "content2");
2621+
2622+
ucmd.arg("--exchange")
2623+
.arg("file1")
2624+
.arg("file2")
2625+
.fails()
2626+
.stderr_contains("--exchange is not supported on this system");
2627+
}

0 commit comments

Comments
 (0)