diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 68cc9c21887..311099effb7 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -465,12 +465,15 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } 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)?; } } } @@ -842,13 +845,16 @@ fn rename( } 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)?; } } } @@ -1566,52 +1572,68 @@ 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. +/// 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) { - 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}; + + 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. + // 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; + + accessat(CWD, path, Access::WRITE_OK, flags).is_ok() } /// 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 - } +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) -> 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) -> 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) -> 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(()) diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index b2bf30e8732..c0d28596c3f 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -3132,6 +3132,70 @@ fn test_mv_prompt_unwriteable_file_when_using_tty() { 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 +/// 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() { diff --git a/tests/uutests/src/lib/util.rs b/tests/uutests/src/lib/util.rs index ae86e348265..120313bebf5 100644 --- a/tests/uutests/src/lib/util.rs +++ b/tests/uutests/src/lib/util.rs @@ -3354,6 +3354,36 @@ pub fn run_ucmd_as_root_with_stdin_stdout( stdin: Option<&str>, stdout: Option<&str>, ) -> std::result::Result { + 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 { + 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 { if is_ci() { Err(format!("{UUTILS_INFO}: {}", "cannot run inside CI")) } else { @@ -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) @@ -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") =>