From 444d26c2ba045d1ec95d828e9b09970dc38059d3 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sun, 7 Dec 2025 21:22:06 +0000 Subject: [PATCH 1/7] Adding fixes for i-3 GNU tests related to tty output when file is not writeable --- src/uu/mv/locales/en-US.ftl | 1 + src/uu/mv/src/mv.rs | 83 +++++++++++++++++++++++++++++++++---- tests/by-util/test_mv.rs | 82 ++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/src/uu/mv/locales/en-US.ftl b/src/uu/mv/locales/en-US.ftl index fda4ea2246e..a78a433bed5 100644 --- a/src/uu/mv/locales/en-US.ftl +++ b/src/uu/mv/locales/en-US.ftl @@ -61,6 +61,7 @@ mv-debug-skipped = skipped {$target} # Prompt messages mv-prompt-overwrite = overwrite {$target}? +mv-prompt-overwrite-mode = replace {$target}, overriding mode{$mode_str} ({$perms})? # Progress messages mv-progress-moving = moving diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 723875f615f..539d922dbf7 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -20,13 +20,15 @@ use std::collections::HashSet; use std::env; use std::ffi::OsString; use std::fs; -use std::io; +use std::io::{self, IsTerminal}; #[cfg(unix)] use std::os::unix; #[cfg(unix)] -use std::os::unix::fs::FileTypeExt; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; #[cfg(windows)] use std::os::windows; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; use std::path::{Path, PathBuf, absolute}; #[cfg(unix)] @@ -41,7 +43,7 @@ use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code}; use uucore::fs::make_fifo; use uucore::fs::{ MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file, - are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator, + are_hardlinks_to_same_file, canonicalize, display_permissions_unix, path_ends_with_terminator, }; #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] use uucore::fsxattr; @@ -133,8 +135,10 @@ pub enum OverwriteMode { /// '-i' '--interactive' prompt before overwrite Interactive, ///'-f' '--force' overwrite without prompt - #[default] Force, + /// No flag specified - prompt for unwritable files when stdin is TTY + #[default] + Default, } static OPT_FORCE: &str = "force"; @@ -341,8 +345,10 @@ fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode { OverwriteMode::NoClobber } else if matches.get_flag(OPT_INTERACTIVE) { OverwriteMode::Interactive - } else { + } else if matches.get_flag(OPT_FORCE) { OverwriteMode::Force + } else { + OverwriteMode::Default } } @@ -430,6 +436,16 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } } OverwriteMode::Force => {} + OverwriteMode::Default => { + if std::io::stdin().is_terminal() && !is_writable(target) { + if !prompt_yes!( + "{}", + translate!("mv-prompt-overwrite", "target" => target.quote()) + ) { + return Err(io::Error::other("").into()); + } + } + } } Err(MvError::NonDirectoryToDirectory( source.quote().to_string(), @@ -732,14 +748,21 @@ fn rename( return Ok(()); } OverwriteMode::Interactive => { - if !prompt_yes!( - "{}", - translate!("mv-prompt-overwrite", "target" => to.quote()) - ) { + let prompt_msg = get_interactive_prompt(to); + if !prompt_yes!("{}", prompt_msg) { return Err(io::Error::other("")); } } OverwriteMode::Force => {} + OverwriteMode::Default => { + // GNU mv prompts when stdin is a TTY and target is not writable + if std::io::stdin().is_terminal() && !is_writable(to) { + let prompt_msg = get_interactive_prompt(to); + if !prompt_yes!("{}", prompt_msg) { + return Err(io::Error::other("")); + } + } + } } backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix); @@ -1201,6 +1224,48 @@ fn is_empty_dir(path: &Path) -> bool { fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none()) } +#[cfg(unix)] +fn is_writable(path: &Path) -> bool { + if let Ok(metadata) = path.metadata() { + let mode = metadata.permissions().mode(); + // Check if user write bit is set + (mode & 0o200) != 0 + } else { + true // If we can't get metadata, assume writable + } +} + +#[cfg(not(unix))] +fn is_writable(path: &Path) -> bool { + if let Ok(metadata) = path.metadata() { + !metadata.permissions().readonly() + } else { + true + } +} + +#[cfg(unix)] +fn get_interactive_prompt(to: &Path) -> String { + use libc::mode_t; + if let Ok(metadata) = to.metadata() { + let mode = metadata.permissions().mode(); + let file_mode = mode & 0o777; + // Check if file is not writable by user + if (mode & 0o200) == 0 { + let perms = display_permissions_unix(mode as mode_t, false); + // Prepend space to prevent translate macro from parsing as number + let mode_str = format!(" {:04o}", file_mode); + return translate!("mv-prompt-overwrite-mode", "target" => to.quote(), "mode_str" => mode_str, "perms" => perms); + } + } + translate!("mv-prompt-overwrite", "target" => to.quote()) +} + +#[cfg(not(unix))] +fn get_interactive_prompt(to: &Path) -> String { + translate!("mv-prompt-overwrite", "target" => to.quote()) +} + /// Checks if a file can be deleted by attempting to open it with delete permissions. #[cfg(windows)] fn can_delete_file(path: &Path) -> bool { diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 37987e822ed..82771bec881 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -2735,3 +2735,85 @@ fn test_mv_verbose_directory_recursive() { assert!(stdout.contains("'mv-dir/d/e/f' -> ")); assert!(stdout.contains("'mv-dir/d/e/f/file2' -> ")); } + +#[cfg(unix)] +#[test] +fn test_mv_prompt_unwritable_file_when_using_tty() { + use uutests::util::TerminalSimulation; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("source"); + at.touch("target"); + at.set_mode("target", 0o000); + + let result = scene + .ucmd() + .arg("source") + .arg("target") + .terminal_sim_stdio(TerminalSimulation { + stdin: true, + stdout: false, + stderr: false, + ..Default::default() + }) + .pipe_in("n\n") + .fails(); + + assert!( + result + .stderr_str() + .contains("replace 'target', overriding mode 0000") + ); + + assert!(at.file_exists("source")); +} + +#[cfg(unix)] +#[test] +fn test_mv_force_no_prompt_unwritable_file() { + use uutests::util::TerminalSimulation; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("source_f"); + at.touch("target_f"); + at.set_mode("target_f", 0o000); + + scene + .ucmd() + .arg("-f") + .arg("source_f") + .arg("target_f") + .terminal_sim_stdio(TerminalSimulation { + stdin: true, + stdout: false, + stderr: false, + ..Default::default() + }) + .succeeds() + .no_stderr(); + + assert!(!at.file_exists("source_f")); + assert!(at.file_exists("target_f")); +} + +#[cfg(unix)] +#[test] +fn test_mv_no_prompt_unwritable_file_with_no_tty() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("source_notty"); + at.touch("target_notty"); + at.set_mode("target_notty", 0o000); + + ucmd.arg("source_notty") + .arg("target_notty") + .succeeds() + .no_stderr(); + + assert!(!at.file_exists("source_notty")); + assert!(at.file_exists("target_notty")); +} From e9116a65193a48149e19a8bdd50471893f526d9b Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sun, 7 Dec 2025 21:50:53 +0000 Subject: [PATCH 2/7] Removed translation zero stripping hack and fixed clippy and spelling errors --- src/uu/mv/locales/en-US.ftl | 2 +- src/uu/mv/src/mv.rs | 22 ++++++++++++---------- tests/by-util/test_mv.rs | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/uu/mv/locales/en-US.ftl b/src/uu/mv/locales/en-US.ftl index a78a433bed5..ac72570b4f8 100644 --- a/src/uu/mv/locales/en-US.ftl +++ b/src/uu/mv/locales/en-US.ftl @@ -61,7 +61,7 @@ mv-debug-skipped = skipped {$target} # Prompt messages mv-prompt-overwrite = overwrite {$target}? -mv-prompt-overwrite-mode = replace {$target}, overriding mode{$mode_str} ({$perms})? +mv-prompt-overwrite-mode = replace {$target}, overriding mode {$mode_info}? # Progress messages mv-progress-moving = moving diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 539d922dbf7..500924b8795 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized +// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized unwritable mod error; #[cfg(unix)] @@ -40,10 +40,12 @@ use uucore::backup_control::{self, source_is_target_backup}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code}; #[cfg(unix)] +use uucore::fs::display_permissions_unix; +#[cfg(unix)] use uucore::fs::make_fifo; use uucore::fs::{ MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file, - are_hardlinks_to_same_file, canonicalize, display_permissions_unix, path_ends_with_terminator, + are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator, }; #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] use uucore::fsxattr; @@ -437,13 +439,14 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } OverwriteMode::Force => {} OverwriteMode::Default => { - if std::io::stdin().is_terminal() && !is_writable(target) { - if !prompt_yes!( + if std::io::stdin().is_terminal() + && !is_writable(target) + && !prompt_yes!( "{}", translate!("mv-prompt-overwrite", "target" => target.quote()) - ) { - return Err(io::Error::other("").into()); - } + ) + { + return Err(io::Error::other("").into()); } } } @@ -1253,9 +1256,8 @@ fn get_interactive_prompt(to: &Path) -> String { // Check if file is not writable by user if (mode & 0o200) == 0 { let perms = display_permissions_unix(mode as mode_t, false); - // Prepend space to prevent translate macro from parsing as number - let mode_str = format!(" {:04o}", file_mode); - return translate!("mv-prompt-overwrite-mode", "target" => to.quote(), "mode_str" => mode_str, "perms" => perms); + let mode_info = format!("{file_mode:04o} ({perms})"); + return translate!("mv-prompt-overwrite-mode", "target" => to.quote(), "mode_info" => mode_info); } } translate!("mv-prompt-overwrite", "target" => to.quote()) diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 82771bec881..305210a6459 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker:ignore mydir hardlinked tmpfs +// spell-checker:ignore mydir hardlinked tmpfs unwritable notty use filetime::FileTime; use rstest::rstest; From 8632db66b1fb1d920b8aafe0262de2f474e0b5ae Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sun, 7 Dec 2025 21:55:53 +0000 Subject: [PATCH 3/7] Removed unused windows import --- src/uu/mv/src/mv.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 500924b8795..433972202d7 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -27,8 +27,6 @@ use std::os::unix; use std::os::unix::fs::{FileTypeExt, PermissionsExt}; #[cfg(windows)] use std::os::windows; -#[cfg(windows)] -use std::os::windows::fs::MetadataExt; use std::path::{Path, PathBuf, absolute}; #[cfg(unix)] From 145f899545bf94e8ac93556790157eb140d6c62a Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 10 Dec 2025 17:35:45 +0000 Subject: [PATCH 4/7] Addressing comments and cleaning up tests --- src/uu/mv/src/mv.rs | 23 +++++++++++------------ tests/by-util/test_mv.rs | 37 ++++++++++++------------------------- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 433972202d7..ad3aa3edc25 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized unwritable +// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized mod error; #[cfg(unix)] @@ -136,7 +136,7 @@ pub enum OverwriteMode { Interactive, ///'-f' '--force' overwrite without prompt Force, - /// No flag specified - prompt for unwritable files when stdin is TTY + /// No flag specified - prompt for unwriteable files when stdin is TTY #[default] Default, } @@ -741,6 +741,13 @@ fn rename( return Err(io::Error::other(err_msg)); } + let prompt_and_check = || -> io::Result<()> { + if !prompt_yes!("{}", get_interactive_prompt(to)) { + return Err(io::Error::other("")); + } + Ok(()) + }; + match opts.overwrite { OverwriteMode::NoClobber => { if opts.debug { @@ -748,20 +755,12 @@ fn rename( } return Ok(()); } - OverwriteMode::Interactive => { - let prompt_msg = get_interactive_prompt(to); - if !prompt_yes!("{}", prompt_msg) { - return Err(io::Error::other("")); - } - } + OverwriteMode::Interactive => prompt_and_check()?, OverwriteMode::Force => {} OverwriteMode::Default => { // GNU mv prompts when stdin is a TTY and target is not writable if std::io::stdin().is_terminal() && !is_writable(to) { - let prompt_msg = get_interactive_prompt(to); - if !prompt_yes!("{}", prompt_msg) { - return Err(io::Error::other("")); - } + prompt_and_check()?; } } } diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 305210a6459..234c66ed06a 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker:ignore mydir hardlinked tmpfs unwritable notty +// spell-checker:ignore mydir hardlinked tmpfs notty use filetime::FileTime; use rstest::rstest; @@ -13,6 +13,8 @@ use std::path::Path; #[cfg(feature = "feat_selinux")] use uucore::selinux::get_getfattr_output; use uutests::new_ucmd; +#[cfg(unix)] +use uutests::util::TerminalSimulation; use uutests::util::TestScenario; use uutests::{at_and_ucmd, util_name}; @@ -2738,19 +2740,14 @@ fn test_mv_verbose_directory_recursive() { #[cfg(unix)] #[test] -fn test_mv_prompt_unwritable_file_when_using_tty() { - use uutests::util::TerminalSimulation; - - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; +fn test_mv_prompt_unwriteable_file_when_using_tty() { + let (at, mut ucmd) = at_and_ucmd!(); at.touch("source"); at.touch("target"); at.set_mode("target", 0o000); - let result = scene - .ucmd() - .arg("source") + ucmd.arg("source") .arg("target") .terminal_sim_stdio(TerminalSimulation { stdin: true, @@ -2759,32 +2756,22 @@ fn test_mv_prompt_unwritable_file_when_using_tty() { ..Default::default() }) .pipe_in("n\n") - .fails(); - - assert!( - result - .stderr_str() - .contains("replace 'target', overriding mode 0000") - ); + .fails() + .stderr_contains("replace 'target', overriding mode 0000"); assert!(at.file_exists("source")); } #[cfg(unix)] #[test] -fn test_mv_force_no_prompt_unwritable_file() { - use uutests::util::TerminalSimulation; - - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; +fn test_mv_force_no_prompt_unwriteable_file() { + let (at, mut ucmd) = at_and_ucmd!(); at.touch("source_f"); at.touch("target_f"); at.set_mode("target_f", 0o000); - scene - .ucmd() - .arg("-f") + ucmd.arg("-f") .arg("source_f") .arg("target_f") .terminal_sim_stdio(TerminalSimulation { @@ -2802,7 +2789,7 @@ fn test_mv_force_no_prompt_unwritable_file() { #[cfg(unix)] #[test] -fn test_mv_no_prompt_unwritable_file_with_no_tty() { +fn test_mv_no_prompt_unwriteable_file_with_no_tty() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("source_notty"); From 56feba069d3123b71a26b2cafcb63ae8577e17e6 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 10 Dec 2025 18:16:24 +0000 Subject: [PATCH 5/7] Spellcheck fixes --- src/uu/mv/src/mv.rs | 2 +- tests/by-util/test_mv.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index ad3aa3edc25..e6aa95f5130 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized +// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized unwriteable mod error; #[cfg(unix)] diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 234c66ed06a..7e22d930b49 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker:ignore mydir hardlinked tmpfs notty +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable use filetime::FileTime; use rstest::rstest; From cb34601dca2bfafa6b43d0e5b42b3fe41272d9fb Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 10 Dec 2025 18:38:32 +0000 Subject: [PATCH 6/7] Replacing helper function in other places too --- src/uu/mv/src/mv.rs | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index e6aa95f5130..b6f6fef2059 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -427,24 +427,11 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } else if target.exists() && source_is_dir { match opts.overwrite { OverwriteMode::NoClobber => return Ok(()), - OverwriteMode::Interactive => { - if !prompt_yes!( - "{}", - translate!("mv-prompt-overwrite", "target" => target.quote()) - ) { - return Err(io::Error::other("").into()); - } - } + OverwriteMode::Interactive => prompt_overwrite(target)?, OverwriteMode::Force => {} OverwriteMode::Default => { - if std::io::stdin().is_terminal() - && !is_writable(target) - && !prompt_yes!( - "{}", - translate!("mv-prompt-overwrite", "target" => target.quote()) - ) - { - return Err(io::Error::other("").into()); + if std::io::stdin().is_terminal() && !is_writable(target) { + prompt_overwrite(target)?; } } } @@ -741,13 +728,6 @@ fn rename( return Err(io::Error::other(err_msg)); } - let prompt_and_check = || -> io::Result<()> { - if !prompt_yes!("{}", get_interactive_prompt(to)) { - return Err(io::Error::other("")); - } - Ok(()) - }; - match opts.overwrite { OverwriteMode::NoClobber => { if opts.debug { @@ -755,12 +735,12 @@ fn rename( } return Ok(()); } - OverwriteMode::Interactive => prompt_and_check()?, + OverwriteMode::Interactive => prompt_overwrite(to)?, OverwriteMode::Force => {} OverwriteMode::Default => { // GNU mv prompts when stdin is a TTY and target is not writable if std::io::stdin().is_terminal() && !is_writable(to) { - prompt_and_check()?; + prompt_overwrite(to)?; } } } @@ -1265,6 +1245,14 @@ fn get_interactive_prompt(to: &Path) -> String { translate!("mv-prompt-overwrite", "target" => to.quote()) } +/// Prompts the user for confirmation and returns an error if declined. +fn prompt_overwrite(to: &Path) -> io::Result<()> { + if !prompt_yes!("{}", get_interactive_prompt(to)) { + return Err(io::Error::other("")); + } + Ok(()) +} + /// Checks if a file can be deleted by attempting to open it with delete permissions. #[cfg(windows)] fn can_delete_file(path: &Path) -> bool { From 7a158961a05fda733fbb5eb7b4ea08cb5e5c26f2 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 17 Dec 2025 16:17:52 +0000 Subject: [PATCH 7/7] mv: address review comments - reorder enum, cache mode, safer defaults --- src/uu/mv/src/mv.rs | 47 +++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index b6f6fef2059..8a489903b28 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -130,15 +130,15 @@ impl Default for Options { /// specifies behavior of the overwrite flag #[derive(Clone, Debug, Eq, PartialEq, Default)] pub enum OverwriteMode { + /// No flag specified - prompt for unwriteable files when stdin is TTY + #[default] + Default, /// '-n' '--no-clobber' do not overwrite NoClobber, /// '-i' '--interactive' prompt before overwrite Interactive, ///'-f' '--force' overwrite without prompt Force, - /// No flag specified - prompt for unwriteable files when stdin is TTY - #[default] - Default, } static OPT_FORCE: &str = "force"; @@ -427,11 +427,12 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } else if target.exists() && source_is_dir { match opts.overwrite { OverwriteMode::NoClobber => return Ok(()), - OverwriteMode::Interactive => prompt_overwrite(target)?, + OverwriteMode::Interactive => prompt_overwrite(target, None)?, OverwriteMode::Force => {} OverwriteMode::Default => { - if std::io::stdin().is_terminal() && !is_writable(target) { - prompt_overwrite(target)?; + let (writable, mode) = is_writable(target); + if !writable && std::io::stdin().is_terminal() { + prompt_overwrite(target, mode)?; } } } @@ -735,12 +736,13 @@ fn rename( } return Ok(()); } - OverwriteMode::Interactive => prompt_overwrite(to)?, + OverwriteMode::Interactive => prompt_overwrite(to, None)?, OverwriteMode::Force => {} OverwriteMode::Default => { // GNU mv prompts when stdin is a TTY and target is not writable - if std::io::stdin().is_terminal() && !is_writable(to) { - prompt_overwrite(to)?; + let (writable, mode) = is_writable(to); + if !writable && std::io::stdin().is_terminal() { + prompt_overwrite(to, mode)?; } } } @@ -1204,31 +1206,34 @@ fn is_empty_dir(path: &Path) -> bool { fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none()) } +/// Check if file is writable, returning the mode for potential reuse. #[cfg(unix)] -fn is_writable(path: &Path) -> bool { +fn is_writable(path: &Path) -> (bool, Option) { if let Ok(metadata) = path.metadata() { let mode = metadata.permissions().mode(); // Check if user write bit is set - (mode & 0o200) != 0 + ((mode & 0o200) != 0, Some(mode)) } else { - true // If we can't get metadata, assume writable + (false, None) // If we can't get metadata, prompt user to be safe } } +/// Check if file is writable. #[cfg(not(unix))] -fn is_writable(path: &Path) -> bool { +fn is_writable(path: &Path) -> (bool, Option) { if let Ok(metadata) = path.metadata() { - !metadata.permissions().readonly() + (!metadata.permissions().readonly(), None) } else { - true + (false, None) // If we can't get metadata, prompt user to be safe } } #[cfg(unix)] -fn get_interactive_prompt(to: &Path) -> String { +fn get_interactive_prompt(to: &Path, cached_mode: Option) -> String { use libc::mode_t; - if let Ok(metadata) = to.metadata() { - let mode = metadata.permissions().mode(); + // Use cached mode if available, otherwise fetch it + let mode = cached_mode.or_else(|| to.metadata().ok().map(|m| m.permissions().mode())); + if let Some(mode) = mode { let file_mode = mode & 0o777; // Check if file is not writable by user if (mode & 0o200) == 0 { @@ -1241,13 +1246,13 @@ fn get_interactive_prompt(to: &Path) -> String { } #[cfg(not(unix))] -fn get_interactive_prompt(to: &Path) -> String { +fn get_interactive_prompt(to: &Path, _cached_mode: Option) -> String { translate!("mv-prompt-overwrite", "target" => to.quote()) } /// Prompts the user for confirmation and returns an error if declined. -fn prompt_overwrite(to: &Path) -> io::Result<()> { - if !prompt_yes!("{}", get_interactive_prompt(to)) { +fn prompt_overwrite(to: &Path, cached_mode: Option) -> io::Result<()> { + if !prompt_yes!("{}", get_interactive_prompt(to, cached_mode)) { return Err(io::Error::other("")); } Ok(())