Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/uu/mv/locales/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 76 additions & 19 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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};
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<u32>) {
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<u32>) {
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<u32>) -> 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<u32>) -> 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<u32>) -> 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 {
Expand Down
71 changes: 70 additions & 1 deletion tests/by-util/test_mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};

Expand Down Expand Up @@ -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"));
}
Loading