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
88 changes: 55 additions & 33 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,12 +465,15 @@
} else if source_is_dir && target.exists() {
match opts.overwrite {
OverwriteMode::NoClobber => return Ok(()),
OverwriteMode::Interactive => prompt_overwrite(target, None)?,
OverwriteMode::Interactive => {
let writable = is_writable(target);
prompt_overwrite(target, writable)?;
}
OverwriteMode::Force => {}
OverwriteMode::Default => {
let (writable, mode) = is_writable(target);
let writable = is_writable(target);
if !writable && io::stdin().is_terminal() {
prompt_overwrite(target, mode)?;
prompt_overwrite(target, writable)?;
}
}
}
Expand Down Expand Up @@ -842,13 +845,16 @@
}
return Ok(());
}
OverwriteMode::Interactive => prompt_overwrite(to, None)?,
OverwriteMode::Interactive => {
let writable = is_writable(to);
prompt_overwrite(to, writable)?;
}
OverwriteMode::Force => {}
OverwriteMode::Default => {
// GNU mv prompts when stdin is a TTY and target is not writable
let (writable, mode) = is_writable(to);
let writable = is_writable(to);
if !writable && io::stdin().is_terminal() {
prompt_overwrite(to, mode)?;
prompt_overwrite(to, writable)?;
}
}
}
Expand Down Expand Up @@ -1566,52 +1572,68 @@
fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none())
}

/// Check if file is writable, returning the mode for potential reuse.
/// Whether the destination can be overwritten without asking the user.
///
/// A symlink destination always counts as writable, because `mv` replaces the
/// link itself and never writes through it. Anything else is decided by asking
/// the kernel whether the caller's effective credentials allow writing, so root
/// and ACL/group grants are honoured.
#[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
fn is_writable(path: &Path) -> bool {
use rustix::fs::{Access, AtFlags, CWD, accessat};

Check warning on line 1583 in src/uu/mv/src/mv.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'accessat' (file:'src/uu/mv/src/mv.rs', line:1583)

if path
.symlink_metadata()
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return true;
}

// AT_EACCESS is what makes this about the caller's effective credentials.

Check warning on line 1592 in src/uu/mv/src/mv.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'EACCESS' (file:'src/uu/mv/src/mv.rs', line:1592)
// Android has no such flag and always uses the real ids.
#[cfg(target_os = "android")]
let flags = AtFlags::empty();
#[cfg(not(target_os = "android"))]
let flags = AtFlags::EACCESS;

Check warning on line 1597 in src/uu/mv/src/mv.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'EACCESS' (file:'src/uu/mv/src/mv.rs', line:1597)

accessat(CWD, path, Access::WRITE_OK, flags).is_ok()

Check warning on line 1599 in src/uu/mv/src/mv.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'accessat' (file:'src/uu/mv/src/mv.rs', line:1599)
}

/// 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
}
fn is_writable(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| !metadata.permissions().readonly())
}

#[cfg(unix)]
fn get_interactive_prompt(to: &Path, cached_mode: Option<u32>) -> String {
// 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 {
fn get_interactive_prompt(to: &Path, writable: bool) -> String {
// The mode is only needed for the "overriding mode" wording, which is used
// exactly when the destination is not writable.
if let Some(mode) = (!writable)
.then(|| to.metadata().ok().map(|m| m.permissions().mode()))
.flatten()
{
let file_mode = mode & 0o777;
// Check if file is not writable by user
if (mode & 0o200) == 0 {
let perms = display_permissions_unix(mode, false);
let mode_info = format!("{file_mode:04o} ({perms})");
return translate!("mv-prompt-overwrite-mode", "target" => to.quote(), "mode_info" => mode_info);
}
let perms = display_permissions_unix(mode, 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 {
fn get_interactive_prompt(to: &Path, _writable: bool) -> 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)) {
fn prompt_overwrite(to: &Path, writable: bool) -> io::Result<()> {
if !prompt_yes!("{}", get_interactive_prompt(to, writable)) {
return Err(io::Error::other(""));
}
Ok(())
Expand Down
64 changes: 64 additions & 0 deletions tests/by-util/test_mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3132,6 +3132,70 @@
assert!(at.file_exists("source"));
}

/// A symlink destination must not trigger the "overriding mode" prompt just
/// because the file it points at is unwritable: `mv` replaces the link itself
/// and never writes through it.
#[cfg(unix)]
#[test]
fn test_mv_no_prompt_symlink_dest_pointing_to_unwriteable_file() {
let (at, mut ucmd) = at_and_ucmd!();

at.touch("source_link");
at.touch("real_unwriteable");
at.set_mode("real_unwriteable", 0o555);
at.symlink_file("real_unwriteable", "link");

ucmd.arg("source_link")
.arg("link")
.terminal_sim_stdio(TerminalSimulation {
stdin: true,
stdout: false,
stderr: false,
..Default::default()
})
.succeeds()
.no_stderr();

assert!(!at.file_exists("source_link"));
assert!(!at.is_symlink("link"));
}

/// Writability has to be judged with the caller's effective credentials: root

Check warning on line 3163 in tests/by-util/test_mv.rs

View workflow job for this annotation

GitHub Actions / Style/spelling (ubuntu-latest, feat_os_unix)

WARNING: `cspell`: Unknown word 'Writability' (file:'tests/by-util/test_mv.rs', line:3163)
/// can overwrite a 0555 file, so no prompt is warranted. Deriving it from the
/// owner write bit instead makes root prompt, which breaks privileged
/// activations. Requires root via non-interactive `sudo`; skipped otherwise.
#[cfg(unix)]
#[test]
fn test_mv_no_prompt_as_root_over_unwriteable_file() {
use uutests::util::run_ucmd_as_root_with_terminal_simulation;

let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.touch("source_root");
at.touch("target_root");
at.set_mode("target_root", 0o555);

let result = run_ucmd_as_root_with_terminal_simulation(
&ts,
&["source_root", "target_root"],
TerminalSimulation {
stdin: true,
stdout: false,
stderr: false,
..Default::default()
},
"n\n",
);
if let Ok(result) = result {
result.success().no_stderr();
assert!(!at.file_exists("source_root"));
assert!(at.file_exists("target_root"));
} else {
println!("Test skipped; requires root user");
}
}

#[cfg(unix)]
#[test]
fn test_mv_force_no_prompt_unwriteable_file() {
Expand Down
39 changes: 31 additions & 8 deletions tests/uutests/src/lib/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3354,6 +3354,36 @@ pub fn run_ucmd_as_root_with_stdin_stdout(
stdin: Option<&str>,
stdout: Option<&str>,
) -> std::result::Result<CmdResult, String> {
let mut cmd = sudo_ucmd(ts, args)?;
if let Some(stdin) = stdin {
cmd.set_stdin(File::open(stdin).unwrap());
}
if let Some(stdout) = stdout {
cmd.set_stdout(File::open(stdout).unwrap());
}
Ok(cmd.run())
}

/// Like [`run_ucmd_as_root`], but runs the utility attached to a pseudo terminal.
///
/// Utilities only prompt when stdin is a tty, so a plain root run cannot reach
/// the prompt path at all. `stdin` is written to the terminal after `term`.
#[cfg(unix)]
pub fn run_ucmd_as_root_with_terminal_simulation(
ts: &TestScenario,
args: &[&str],
term: TerminalSimulation,
stdin: &str,
) -> std::result::Result<CmdResult, String> {
let mut cmd = sudo_ucmd(ts, args)?;
cmd.terminal_sim_stdio(term).pipe_in(stdin);
Ok(cmd.run())
}

/// Build a `sudo`-wrapped invocation of the utility under test, or explain why
/// root is unavailable.
#[cfg(unix)]
fn sudo_ucmd(ts: &TestScenario, args: &[&str]) -> std::result::Result<UCommand, String> {
if is_ci() {
Err(format!("{UUTILS_INFO}: {}", "cannot run inside CI"))
} else {
Expand All @@ -3366,7 +3396,6 @@ pub fn run_ucmd_as_root_with_stdin_stdout(
{
Ok(output) if String::from_utf8_lossy(&output.stdout).eq("root\n") => {
// we can run sudo and we're root
// run ucmd as root:
let mut cmd = ts.cmd("sudo");
cmd.env("PATH", PATH)
.envs(DEFAULT_ENV)
Expand All @@ -3375,13 +3404,7 @@ pub fn run_ucmd_as_root_with_stdin_stdout(
.arg(&ts.bin_path)
.arg(&ts.util_name)
.args(args);
if let Some(stdin) = stdin {
cmd.set_stdin(File::open(stdin).unwrap());
}
if let Some(stdout) = stdout {
cmd.set_stdout(File::open(stdout).unwrap());
}
Ok(cmd.run())
Ok(cmd)
}
Ok(output)
if String::from_utf8_lossy(&output.stderr).eq("sudo: a password is required\n") =>
Expand Down
Loading