diff --git a/src/uu/mv/locales/en-US.ftl b/src/uu/mv/locales/en-US.ftl index fda4ea2246e..ac72570b4f8 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_info}? # Progress messages mv-progress-moving = moving diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 723875f615f..8a489903b28 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)] @@ -20,11 +20,11 @@ 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; use std::path::{Path, PathBuf, absolute}; @@ -38,6 +38,8 @@ 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, @@ -128,12 +130,14 @@ 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 - #[default] 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 } } @@ -421,15 +427,14 @@ 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, None)?, + OverwriteMode::Force => {} + OverwriteMode::Default => { + let (writable, mode) = is_writable(target); + if !writable && std::io::stdin().is_terminal() { + prompt_overwrite(target, mode)?; } } - OverwriteMode::Force => {} } Err(MvError::NonDirectoryToDirectory( source.quote().to_string(), @@ -731,15 +736,15 @@ fn rename( } return Ok(()); } - OverwriteMode::Interactive => { - if !prompt_yes!( - "{}", - translate!("mv-prompt-overwrite", "target" => to.quote()) - ) { - return Err(io::Error::other("")); + OverwriteMode::Interactive => prompt_overwrite(to, None)?, + OverwriteMode::Force => {} + OverwriteMode::Default => { + // GNU mv prompts when stdin is a TTY and target is not writable + let (writable, mode) = is_writable(to); + if !writable && std::io::stdin().is_terminal() { + prompt_overwrite(to, mode)?; } } - OverwriteMode::Force => {} } backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix); @@ -1201,6 +1206,58 @@ 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, Option) { + if let Ok(metadata) = path.metadata() { + let mode = metadata.permissions().mode(); + // Check if user write bit is set + ((mode & 0o200) != 0, Some(mode)) + } else { + (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, Option) { + if let Ok(metadata) = path.metadata() { + (!metadata.permissions().readonly(), None) + } else { + (false, None) // If we can't get metadata, prompt user to be safe + } +} + +#[cfg(unix)] +fn get_interactive_prompt(to: &Path, cached_mode: Option) -> String { + use libc::mode_t; + // 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 { + let perms = display_permissions_unix(mode as mode_t, false); + 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()) +} + +#[cfg(not(unix))] +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, cached_mode: Option) -> io::Result<()> { + if !prompt_yes!("{}", get_interactive_prompt(to, cached_mode)) { + 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 { diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 37987e822ed..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 +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable 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}; @@ -2735,3 +2737,70 @@ 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_unwriteable_file_when_using_tty() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("source"); + at.touch("target"); + at.set_mode("target", 0o000); + + ucmd.arg("source") + .arg("target") + .terminal_sim_stdio(TerminalSimulation { + stdin: true, + stdout: false, + stderr: false, + ..Default::default() + }) + .pipe_in("n\n") + .fails() + .stderr_contains("replace 'target', overriding mode 0000"); + + assert!(at.file_exists("source")); +} + +#[cfg(unix)] +#[test] +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); + + 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_unwriteable_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")); +}