diff --git a/Cargo.lock b/Cargo.lock index 57ea0e95..022b6651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,6 +1561,7 @@ dependencies = [ "uu_mesg", "uu_mountpoint", "uu_nologin", + "uu_rename", "uu_renice", "uu_rev", "uu_setpgid", @@ -1744,6 +1745,15 @@ dependencies = [ "uucore 0.2.2", ] +[[package]] +name = "uu_rename" +version = "0.0.1" +dependencies = [ + "clap", + "libc", + "uucore 0.2.2", +] + [[package]] name = "uu_renice" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4b253cc5..bce324d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ feat_common_core = [ "mesg", "mountpoint", "nologin", + "rename", "renice", "rev", "setpgid", @@ -116,6 +117,7 @@ mcookie = { optional = true, version = "0.0.1", package = "uu_mcookie", path = " mesg = { optional = true, version = "0.0.1", package = "uu_mesg", path = "src/uu/mesg" } mountpoint = { optional = true, version = "0.0.1", package = "uu_mountpoint", path = "src/uu/mountpoint" } nologin = { optional = true, version = "0.0.1", package = "uu_nologin", path = "src/uu/nologin" } +rename = { optional = true, version = "0.0.1", package = "uu_rename", path = "src/uu/rename" } renice = { optional = true, version = "0.0.1", package = "uu_renice", path = "src/uu/renice" } rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" } setpgid = { optional = true, version = "0.0.1", package = "uu_setpgid", path = "src/uu/setpgid" } diff --git a/src/uu/rename/Cargo.toml b/src/uu/rename/Cargo.toml new file mode 100644 index 00000000..938a99e9 --- /dev/null +++ b/src/uu/rename/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "uu_rename" +version = "0.0.1" +edition = "2021" +description = "rename ~ (uutils) Rename files by substring replacement" + +[lib] +path = "src/rename.rs" + +[[bin]] +name = "rename" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +uucore = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } diff --git a/src/uu/rename/rename.md b/src/uu/rename/rename.md new file mode 100644 index 00000000..0dac6d2c --- /dev/null +++ b/src/uu/rename/rename.md @@ -0,0 +1,7 @@ +# rename + +``` +rename [options] ... +``` + +Rename files. diff --git a/src/uu/rename/src/argv.rs b/src/uu/rename/src/argv.rs new file mode 100644 index 00000000..df83033d --- /dev/null +++ b/src/uu/rename/src/argv.rs @@ -0,0 +1,60 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Where getopt would have stopped reading options. +//! +//! rename(1) permutes options with operands, so a flag is recognized anywhere +//! before a `--` - after all three operands, or between the substring and the +//! replacement. `POSIXLY_CORRECT` turns that off, and its mere presence does +//! it whatever the value: scanning stops at the first argument that is not an +//! option, and everything from there on is a filename however much it looks +//! like a flag. +//! +//! clap has no such concept, so rather than teach it one the argv is +//! terminated where getopt would have stopped and clap is handed the result. + +use std::env; +use std::ffi::{OsStr, OsString}; + +const TERMINATOR: &str = "--"; + +pub(crate) fn collect_getopt_argv(args: impl uucore::Args) -> Vec { + terminate_at_first_operand(args.collect(), env::var_os("POSIXLY_CORRECT").is_some()) +} + +/// A bare `-` is an operand and not an option, so it stops the scan like any +/// other name. `--` is longer than one unit and so answers true here, which is +/// why the caller tests for it first. +fn is_option(arg: &OsStr) -> bool { + // ASCII survives this on every platform: the encoded form keeps ASCII bytes + // as themselves, which is all a leading `-` needs. + matches!(arg.as_encoded_bytes(), [b'-', _, ..]) +} + +fn terminate_at_first_operand(mut argv: Vec, posixly_correct: bool) -> Vec { + if !posixly_correct { + return argv; + } + + let mut operand_at = None; + // argv[0] is the utility's own name and is never scanned. + for (index, arg) in argv.iter().enumerate().skip(1) { + if arg == TERMINATOR { + // getopt consumes this one itself, so there is nothing to insert + // and nothing after it to protect. + return argv; + } + if !is_option(arg) { + operand_at = Some(index); + break; + } + } + + if let Some(index) = operand_at { + argv.insert(index, OsString::from(TERMINATOR)); + } + + argv +} diff --git a/src/uu/rename/src/encoding.rs b/src/uu/rename/src/encoding.rs new file mode 100644 index 00000000..2b6830aa --- /dev/null +++ b/src/uu/rename/src/encoding.rs @@ -0,0 +1,95 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! The bridge between OsStr and the code units the engine works on. +//! +//! Both directions are lossless: bytes on unix, UTF-16 units on Windows. The +//! path separator is 0x2F in either encoding, so the scope rule needs no +//! special casing. `\` is deliberately not a separator here: rename(1) +//! documents `/`, and Rust accepts `/` on Windows too. +//! +//! Only unix and windows are covered; a third target does not build. +//! +//! Writing a name is done here rather than through `uucore::display::OsWrite` +//! because that trait refuses a Windows name that is not valid Unicode, with +//! `io::ErrorKind::InvalidData`, where a report has to print whatever the name +//! actually is - and because it has no impl for stderr. + +#[cfg(unix)] +mod imp { + use std::ffi::{OsStr, OsString}; + use std::io::{self, Write}; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::path::Path; + + pub(crate) type Unit = u8; + pub(crate) const SEP: Unit = b'/'; + + pub(crate) fn units(s: &OsStr) -> Vec { + s.as_bytes().to_vec() + } + + pub(crate) fn os_string(units: Vec) -> OsString { + OsString::from_vec(units) + } + + /// Filenames are written raw. A name holding an invalid byte is emitted as + /// that byte, which is what C util-linux does and what makes its -v output + /// round-trip through a shell. + pub(crate) fn write_os(w: &mut impl Write, s: &OsStr) -> io::Result<()> { + w.write_all(s.as_bytes()) + } + + pub(crate) fn symlink(target: &OsStr, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) + } +} + +#[cfg(windows)] +mod imp { + use std::ffi::{OsStr, OsString}; + use std::io::{self, Write}; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::Path; + + pub(crate) type Unit = u16; + pub(crate) const SEP: Unit = b'/' as u16; + + pub(crate) fn units(s: &OsStr) -> Vec { + s.encode_wide().collect() + } + + pub(crate) fn os_string(units: Vec) -> OsString { + OsString::from_wide(&units) + } + + /// There is no raw byte form of a Windows filename, so a lone surrogate is + /// replaced here. Unlike the unix arm this is lossy, and only for output. + pub(crate) fn write_os(w: &mut impl Write, s: &OsStr) -> io::Result<()> { + w.write_all(s.to_string_lossy().as_bytes()) + } + + /// Windows splits the call in two and needs the privilege or Developer + /// Mode to make either. Which one is right depends on what the target + /// names, and a relative target is stored relative to the LINK, so it is + /// resolved from the link's own directory - not from the process working + /// directory, which would answer about a different object and leave a link + /// Windows reports as broken. The -o guard resolves relative to the + /// process instead, because that side has C util-linux to conform to and + /// this side has none. + /// + /// A target that does not resolve is treated as a file, and whatever the + /// OS then says is passed back to the caller rather than pre-empted here. + pub(crate) fn symlink(target: &OsStr, link: &Path) -> io::Result<()> { + let base = link.parent().unwrap_or_else(|| Path::new("")); + if base.join(target).is_dir() { + std::os::windows::fs::symlink_dir(target, link) + } else { + std::os::windows::fs::symlink_file(target, link) + } + } +} + +pub(crate) use imp::{os_string, symlink, units, write_os, Unit, SEP}; diff --git a/src/uu/rename/src/errors.rs b/src/uu/rename/src/errors.rs new file mode 100644 index 00000000..7cce09da --- /dev/null +++ b/src/uu/rename/src/errors.rs @@ -0,0 +1,109 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Per-operand diagnostics. +//! +//! Each variant keeps the path it complains about as an `OsString` and writes +//! it out as the bytes it is made of. A name that is not valid UTF-8 is exactly +//! the kind of name people reach for rename to fix, and C util-linux prints it +//! raw on stderr just as it does on stdout; rendering it through a `String` +//! would replace it with U+FFFD. +//! +//! That is also why these do not implement `Display`. `Formatter` writes +//! `&str`, so the diagnostic and a lossless name are mutually exclusive. +//! +//! The trailing " (os error NN)" that Rust appends to an io::Error is stripped +//! back off: C util-linux prints the bare strerror text and nothing else. + +use std::ffi::OsString; +use std::io::{self, Write}; + +use uucore::error::strip_errno; + +use crate::output::Output; + +#[derive(Debug)] +pub(crate) enum RenameError { + NotAccessible { + path: OsString, + source: io::Error, + }, + + NotASymlink { + path: OsString, + }, + + RenameFailed { + old: OsString, + new: OsString, + source: io::Error, + }, + + /// The two halves of a symlink rewrite report separately, as they do for + /// C util-linux: a link in a directory that denies writes fails at the + /// unlink, and says so. + UnlinkFailed { + path: OsString, + source: io::Error, + }, + + /// This one names the target it tried to create, so an empty target prints + /// two spaces between "to" and "failed". + SymlinkFailed { + path: OsString, + new: OsString, + source: io::Error, + }, + + WriteFailed { + source: io::Error, + }, +} + +impl RenameError { + /// The `rename: ` prefix is written here rather than by `show_error!`, + /// which renders through `format!` and cannot carry a name losslessly. + pub(crate) fn report(&self, out: &mut Output) { + out.write_bytes(uucore::util_name().as_bytes()); + out.write_bytes(b": "); + + match self { + Self::NotAccessible { path, source } => { + out.write_os(path); + out.write_bytes(b": not accessible: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::NotASymlink { path } => { + out.write_os(path); + out.write_bytes(b": not a symbolic link"); + } + Self::RenameFailed { old, new, source } => { + out.write_os(old); + out.write_bytes(b": rename to "); + out.write_os(new); + out.write_bytes(b" failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::UnlinkFailed { path, source } => { + out.write_os(path); + out.write_bytes(b": unlink failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::SymlinkFailed { path, new, source } => { + out.write_os(path); + out.write_bytes(b": symlinking to "); + out.write_os(new); + out.write_bytes(b" failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::WriteFailed { source } => { + out.write_bytes(b"write error: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + } + + out.write_bytes(b"\n"); + } +} diff --git a/src/uu/rename/src/main.rs b/src/uu/rename/src/main.rs new file mode 100644 index 00000000..661fe143 --- /dev/null +++ b/src/uu/rename/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_rename); diff --git a/src/uu/rename/src/output.rs b/src/uu/rename/src/output.rs new file mode 100644 index 00000000..bb126bf9 --- /dev/null +++ b/src/uu/rename/src/output.rs @@ -0,0 +1,69 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! A stream that remembers its first write failure instead of propagating it. +//! +//! Both of rename's streams need this, and for the same reason: C util-linux +//! finishes the whole run and then reports a failed write once, exiting 1 +//! whatever the tally says. The renames still happen; only the report is lost. + +use std::ffi::OsStr; +use std::io::{self, Write}; + +use crate::encoding; + +pub(crate) struct Output { + inner: W, + error: Option, +} + +impl Output { + pub(crate) fn new(inner: W) -> Self { + Self { inner, error: None } + } + + fn record(&mut self, result: io::Result<()>) { + if let Err(error) = result { + self.error.get_or_insert(error); + } + } + + pub(crate) fn write_bytes(&mut self, bytes: &[u8]) { + let result = self.inner.write_all(bytes); + self.record(result); + } + + /// Names go out raw, so one holding a newline really does split the line in + /// two - as it does for C util-linux, on either stream. + pub(crate) fn write_os(&mut self, value: &OsStr) { + let result = encoding::write_os(&mut self.inner, value); + self.record(result); + } + + pub(crate) fn write_quoted(&mut self, value: &OsStr) { + self.write_bytes(b"`"); + self.write_os(value); + self.write_bytes(b"'"); + } + + pub(crate) fn flush(&mut self) { + let result = self.inner.flush(); + self.record(result); + } + + pub(crate) fn into_error(self) -> Option { + self.error + } + + /// On the REPORT stream C util-linux is selective about which failed write + /// it complains of at exit: a full disk yes, a reader that has gone away + /// no, so a run piped into something short-lived still reports what it did. + /// It is not selective on the diagnostic stream, which is why this is a + /// method here and not a rule in `into_error`. + pub(crate) fn into_reported_error(self) -> Option { + self.into_error() + .filter(|error| error.kind() != io::ErrorKind::BrokenPipe) + } +} diff --git a/src/uu/rename/src/prompt.rs b/src/uu/rename/src/prompt.rs new file mode 100644 index 00000000..f1dfbc28 --- /dev/null +++ b/src/uu/rename/src/prompt.rs @@ -0,0 +1,136 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! The -i prompt, and how much of stdin it takes. +//! +//! C util-linux decides this once, from the terminal settings on fd 0, before +//! the first operand is looked at, and the answer has two shapes. Off a +//! terminal, and on one whose line discipline assembles lines, a whole line is +//! consumed per prompt and a read holding no newline is read again. On a +//! terminal in non-canonical mode nothing will ever supply that newline, so one +//! read is the answer whatever it holds, the rest of it is discarded, and the +//! newline the terminal never sent is written to stdout instead. +//! +//! Reading a line rather than the whole answer is not an optimization. It is +//! what keeps a run bounded: a stdin that never ends would otherwise be +//! accumulated until the allocator gives up. + +use std::ffi::OsStr; +use std::io::{self, BufRead, Write}; + +use crate::output::Output; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Answering { + Line, + Keystroke, +} + +impl Answering { + pub(crate) fn from_stdin() -> Self { + if imp::stdin_assembles_lines() { + Self::Line + } else { + Self::Keystroke + } + } + + /// Ask, then take one answer. Accepted when it begins with `y` or `Y`, + /// which is what rpmatch accepts under LC_ALL=C; a locale whose YESEXPR + /// differs is a known divergence. + pub(crate) fn accepts(self, out: &mut Output, new: &OsStr) -> bool { + out.write_bytes(uucore::util_name().as_bytes()); + out.write_bytes(b": overwrite "); + out.write_quoted(new); + out.write_bytes(b"? "); + out.flush(); + + let answer = self.read(&mut io::stdin().lock(), out); + match answer { + Some(answer) => matches!(answer, b'y' | b'Y'), + None => { + // A prompt that reaches end of input declines, and says so: + // C util-linux echoes a literal `n` and terminates the line + // the prompt left open. The echo is not the locale's nostr, + // and a typed decline never produces it. + out.write_bytes(b"n\n"); + false + } + } + } + + /// The first byte of the answer, or `None` at end of input - which includes + /// a stdin that cannot be read at all, because C util-linux cannot tell + /// those two apart either and assumes the same answer for both. + /// + /// Only the first byte is ever kept, so the buffer never grows with the + /// input. + fn read(self, input: &mut R, out: &mut Output) -> Option { + let mut first = None; + + loop { + let (leading, available, newline) = match input.fill_buf() { + Ok([]) | Err(_) => break, + Ok(chunk) => ( + chunk.first().copied(), + chunk.len(), + chunk.iter().position(|byte| *byte == b'\n'), + ), + }; + + first = first.or(leading); + + match self { + Self::Keystroke => { + input.consume(available); + if first != Some(b'\n') { + out.write_bytes(b"\n"); + } + return first; + } + Self::Line => match newline { + Some(end) => { + input.consume(end + 1); + return first; + } + None => input.consume(available), + }, + } + } + + // An answer the input ended without terminating is still an answer. + first + } +} + +#[cfg(unix)] +mod imp { + /// Anything that is not a terminal is read through a buffer that stops at a + /// newline, and a terminal assembles lines exactly when ICANON is set. + pub(super) fn stdin_assembles_lines() -> bool { + let mut termios = std::mem::MaybeUninit::::uninit(); + + // SAFETY: tcgetattr writes the struct only on success, and this is the + // only reference to it. + let described = unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) } == 0; + if !described { + return true; + } + + // SAFETY: tcgetattr returned success, so it initialized c_lflag. + let c_lflag = unsafe { (*termios.as_ptr()).c_lflag }; + c_lflag & libc::ICANON != 0 + } +} + +#[cfg(windows)] +mod imp { + /// Windows has no termios. A console reads a line at a time unless a + /// program clears ENABLE_LINE_INPUT, which nothing in this utility does. + /// C util-linux does not run here, so there is nothing to conform to. + pub(super) fn stdin_assembles_lines() -> bool { + true + } +} diff --git a/src/uu/rename/src/rename.rs b/src/uu/rename/src/rename.rs new file mode 100644 index 00000000..a171c8e3 --- /dev/null +++ b/src/uu/rename/src/rename.rs @@ -0,0 +1,458 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::{self, BufWriter, IsTerminal, Write}; +use std::path::Path; + +use clap::builder::ValueParser; +use clap::{crate_version, Arg, ArgAction, ArgMatches, Command}; +use uucore::error::{set_exit_code, UResult}; +use uucore::{format_usage, help_about, help_usage}; + +mod argv; +mod encoding; +mod errors; +mod output; +mod prompt; +mod subst; + +use argv::collect_getopt_argv; +use encoding::{os_string, units, Unit, SEP}; +use errors::RenameError; +use output::Output; +use prompt::Answering; +use subst::{rewrite, Mode}; + +const ABOUT: &str = help_about!("rename.md"); +const USAGE: &str = help_usage!("rename.md"); + +mod options { + pub const VERBOSE: &str = "verbose"; + pub const SYMLINK: &str = "symlink"; + pub const NO_ACT: &str = "no-act"; + pub const ALL: &str = "all"; + pub const LAST: &str = "last"; + pub const NO_OVERWRITE: &str = "no-overwrite"; + pub const INTERACTIVE: &str = "interactive"; + pub const SUBSTRING: &str = "substring"; + pub const REPLACEMENT: &str = "replacement"; + pub const FILES: &str = "files"; +} + +/// What the run did, counted per operand. +/// +/// The status is selected from two counters rather than or'd together, so a +/// no-match, a name that did not change, an -o skip and an -i decline - which +/// touch neither counter - can neither degrade a success nor promote a +/// failure. The documented 64, for an unanticipated error, is never produced. +#[derive(Debug, Default)] +struct Tally { + /// Operands whose computed name differed from the current one and whose + /// operation succeeded. Note that this is not the same as "the tree + /// changed": two links to one inode rename successfully and change nothing. + renamed: usize, + failed: usize, +} + +impl Tally { + fn code(&self) -> i32 { + match (self.renamed > 0, self.failed > 0) { + (true, true) => 2, + (true, false) => 0, + (false, true) => 1, + (false, false) => 4, + } + } +} + +/// What one operand did. A skip, a no-match and an unchanged name are all +/// `Neither`; C util-linux cannot tell them apart either. +enum Outcome { + Renamed, + Neither, +} + +struct Options { + verbose: bool, + symlink: bool, + no_act: bool, + no_overwrite: bool, + interactive: bool, + mode: Mode, + needle: Vec, + replacement: Vec, +} + +impl Options { + fn from_matches(matches: &ArgMatches) -> Self { + let mode = if matches.get_flag(options::ALL) { + Mode::All + } else if matches.get_flag(options::LAST) { + Mode::Last + } else { + Mode::First + }; + + Self { + verbose: matches.get_flag(options::VERBOSE), + symlink: matches.get_flag(options::SYMLINK), + no_act: matches.get_flag(options::NO_ACT), + no_overwrite: matches.get_flag(options::NO_OVERWRITE), + interactive: matches.get_flag(options::INTERACTIVE), + mode, + needle: argument(matches, options::SUBSTRING), + replacement: argument(matches, options::REPLACEMENT), + } + } +} + +/// Both are `required(true)`, so clap has already refused an invocation that +/// omits them; the default stands in for an `unwrap` the review guidelines +/// forbid. +fn argument(matches: &ArgMatches, id: &str) -> Vec { + matches + .get_one::(id) + .map(|value| units(value.as_os_str())) + .unwrap_or_default() +} + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = match uu_app().try_get_matches_from(collect_getopt_argv(args)) { + Ok(matches) => matches, + Err(error) => { + report_parse_failure(&error); + return Ok(()); + } + }; + let options = Options::from_matches(&matches); + + // An identical substring and replacement short-circuit the whole run, not + // one operand: C util-linux issues no filesystem syscall at all, even for + // operands that do not exist. + if options.needle == options.replacement { + set_exit_code(4); + return Ok(()); + } + + let mut tally = Tally::default(); + let stdout = io::stdout(); + // C util-linux's stdout is stdio's: line buffered on a terminal and fully + // buffered anywhere else. Rust's is line buffered everywhere, and the + // difference is not only a matter of syscall counts - under a write limit + // it stops our loop part way through a run C util-linux finishes. + let sink: Box = if stdout.is_terminal() { + Box::new(stdout.lock()) + } else { + Box::new(BufWriter::new(stdout.lock())) + }; + let mut out = Output::new(sink); + let mut err = Output::new(io::stderr().lock()); + + // Asked once for the whole run, before any operand is looked at, which is + // where C util-linux asks it too. + let answering = Answering::from_stdin(); + + for operand in matches + .get_many::(options::FILES) + .unwrap_or_default() + { + match rename_one(&options, answering, operand, &mut out) { + Ok(Outcome::Renamed) => tally.renamed += 1, + Ok(Outcome::Neither) => {} + Err(error) => { + error.report(&mut err); + tally.failed += 1; + } + } + } + + out.flush(); + // A failed write discards the tally on either stream, and C util-linux + // finishes the run first either way. Only the report stream's failure has + // anywhere left to report itself, and only it forgives a reader that left. + let report_failed = if let Some(source) = out.into_reported_error() { + RenameError::WriteFailed { source }.report(&mut err); + true + } else { + false + }; + err.flush(); + let diagnostics_failed = err.into_error().is_some(); + + set_exit_code(if report_failed || diagnostics_failed { + 1 + } else { + tally.code() + }); + + Ok(()) +} + +/// clap has already decided what to say and which stream to say it on; all +/// that is left is to say it without unwrapping the write. +/// +/// The `?` this replaces hands the error to uucore, which prints it through a +/// `Display` impl that cannot report a failed write and panics on one. Calling +/// `print` here returns that result instead. Note the status comes from +/// `use_stderr` and not from clap's own `exit_code`, which is 2 for a usage +/// error where both C util-linux and this tree use 1. +fn report_parse_failure(error: &clap::Error) { + let written = error.print(); + let mut err = Output::new(io::stderr().lock()); + + match written { + // A reader that has gone away is not reported, here or at exit. + Err(source) if source.kind() != io::ErrorKind::BrokenPipe => { + RenameError::WriteFailed { source }.report(&mut err); + err.flush(); + set_exit_code(1); + } + _ => set_exit_code(i32::from(error.use_stderr())), + } +} + +/// One operand, start to finish. The only place that touches the filesystem. +fn rename_one( + options: &Options, + answering: Answering, + operand: &OsStr, + out: &mut Output, +) -> Result { + // The existence check keeps the operand exactly as it was typed, trailing + // separators and all, which is why `d1/s1/` reports ENOTDIR instead of + // renaming `d1/s1`. Everything after this point uses the stripped form. + let metadata = fs::symlink_metadata(operand).map_err(|source| RenameError::NotAccessible { + path: operand.to_os_string(), + source, + })?; + + // This precedes the match test, so a non-symlink fails even when the + // substring appears nowhere in its name. + if options.symlink && !metadata.file_type().is_symlink() { + return Err(RenameError::NotASymlink { + path: operand.to_os_string(), + }); + } + + // Symlink mode rewrites the link's target text and never the link's own + // name; the chain is not resolved, so a link to a link sees only the next + // hop's text. + let source = if options.symlink { + fs::read_link(operand) + .map_err(|source| RenameError::NotAccessible { + path: operand.to_os_string(), + source, + })? + .into_os_string() + } else { + operand.to_os_string() + }; + + let source_units = units(&source); + let change = rewrite( + &source_units, + &options.needle, + &options.replacement, + options.mode, + SEP, + ); + if change.is_unchanged() { + return Ok(Outcome::Neither); + } + + let old = os_string(change.old.to_vec()); + let new = os_string(change.new); + + // Only the two safeguards ask, and so C util-linux only asks for them: + // without -o or -i it never looks at the destination at all, and neither + // does this. + let taken = (options.no_overwrite || options.interactive) + && if options.symlink { + link_target_entry_exists(&new) + } else { + destination_exists_following_links(&new) + }; + + let skipped = if !taken { + false + } else if options.interactive && !options.no_act { + !answering.accepts(out, &new) + } else { + // -o, or -i under -n: -n never prompts and never reads stdin. `taken` + // is only ever set under one of the two safeguards, so reaching here + // means the other one is in force. + true + }; + if skipped { + report_skip(out, options, operand, &old, &new); + } + + if options.no_act { + // The verbose line prints even for an operand the guard skipped, so a + // skipped operand under -n prints two lines and counts as neither. + report(out, options, operand, &old, &new); + return Ok(if skipped { + Outcome::Neither + } else { + Outcome::Renamed + }); + } + + if skipped { + return Ok(Outcome::Neither); + } + + if options.symlink { + // Not atomic, and deliberately so: C util-linux unlinks then creates + // too, and a failure to create the replacement leaves the original + // link gone. + fs::remove_file(operand).map_err(|source| RenameError::UnlinkFailed { + path: operand.to_os_string(), + source, + })?; + encoding::symlink(&new, Path::new(operand)).map_err(|source| { + RenameError::SymlinkFailed { + path: operand.to_os_string(), + new: new.clone(), + source, + } + })?; + } else { + fs::rename(&old, &new).map_err(|source| RenameError::RenameFailed { + old: old.clone(), + new: new.clone(), + source, + })?; + } + + report(out, options, operand, &old, &new); + Ok(Outcome::Renamed) +} + +/// The default path asks whether anything is reachable at the destination, so +/// it follows symlinks: a dangling symlink sitting there does not count as +/// existing, and -o lets the rename clobber it. A probe that cannot be +/// performed at all - a parent directory denying search - reads as absent, +/// which is measured behavior and not a defensive default. +/// +/// Both guards ask whether the NAME is taken, never whether it names the same +/// file as the source, as C util-linux does. That shows on a filesystem which +/// folds case, where a rename differing only in case finds its own source at +/// the destination: -o skips it and -i prompts for it. +fn destination_exists_following_links(path: &OsStr) -> bool { + Path::new(path).try_exists().unwrap_or(false) +} + +/// Symlink mode asks a different question: is there an ENTRY at the new target +/// path? This is an lstat where the one above is a stat, so a dangling entry +/// counts here and blocks the rewrite. Do not merge the two. +fn link_target_entry_exists(path: &OsStr) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn report( + out: &mut Output, + options: &Options, + operand: &OsStr, + old: &OsStr, + new: &OsStr, +) { + if !options.verbose { + return; + } + + if options.symlink { + out.write_os(operand); + out.write_bytes(b": "); + } + out.write_quoted(old); + out.write_bytes(b" -> "); + out.write_quoted(new); + out.write_bytes(b"\n"); +} + +/// The skip report, which unlike the prompt is printed only under -v. +fn report_skip( + out: &mut Output, + options: &Options, + operand: &OsStr, + old: &OsStr, + new: &OsStr, +) { + if !options.verbose { + return; + } + + if options.symlink { + // Symlink mode names the link and the target it still has. + out.write_bytes(b"Skipping existing link: "); + out.write_quoted(operand); + out.write_bytes(b" -> "); + out.write_quoted(old); + } else { + out.write_bytes(b"Skipping existing file: "); + out.write_quoted(new); + } + out.write_bytes(b"\n"); +} + +fn flag(id: &'static str, short: char, help: &'static str) -> Arg { + Arg::new(id) + .short(short) + .long(id) + .help(help) + .action(ArgAction::SetTrue) +} + +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + // getopt lets a flag repeat, and so does C util-linux: `-v -v` renames. + // A SetTrue argument rejects its second occurrence without this, and + // it does not weaken `conflicts_with`, which clap validates separately. + .args_override_self(true) + .arg(flag(options::VERBOSE, 'v', "explain what is being done")) + .arg(flag(options::SYMLINK, 's', "act on the target of symlinks")) + .arg(flag(options::NO_ACT, 'n', "do not make any changes")) + .arg(flag(options::ALL, 'a', "replace all occurrences").conflicts_with(options::LAST)) + .arg(flag(options::LAST, 'l', "replace only the last occurrence")) + .arg( + flag(options::NO_OVERWRITE, 'o', "don't overwrite existing files") + .conflicts_with(options::INTERACTIVE), + ) + .arg(flag(options::INTERACTIVE, 'i', "prompt before overwrite")) + // The usage line already names all three, and C util-linux's help has + // no operand section at all, so listing them again would print three + // names with nothing beside them. + .arg( + Arg::new(options::SUBSTRING) + .value_name("substring") + .required(true) + .hide(true) + .value_parser(ValueParser::os_string()), + ) + .arg( + Arg::new(options::REPLACEMENT) + .value_name("replacement") + .required(true) + .hide(true) + .value_parser(ValueParser::os_string()), + ) + .arg( + Arg::new(options::FILES) + .value_name("file") + .required(true) + .num_args(1..) + .hide(true) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()), + ) +} diff --git a/src/uu/rename/src/subst.rs b/src/uu/rename/src/subst.rs new file mode 100644 index 00000000..00f85b20 --- /dev/null +++ b/src/uu/rename/src/subst.rs @@ -0,0 +1,410 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (words) axbxcx abxcx aaaaaaaa abab abcZ axbxc axbxcZ +// spell-checker:ignore (words) aZbxcx aZbZcZ Zabc ZaZbZcZ ZxZ ZYaZYbZYcZY + +//! The substitution engine. Pure: no filesystem, no syscalls, no encoding +//! assumptions. Generic over the filename code unit so the same rules apply to +//! bytes on unix and to UTF-16 units on Windows. + +/// Which occurrence of the substring a run replaces. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + First, + All, + Last, +} + +/// Replace occurrences of `needle` in `name` with `replacement`. +/// +/// Returns the new name, which equals `name` when nothing matched. +pub(crate) fn substitute( + name: &[T], + needle: &[T], + replacement: &[T], + mode: Mode, +) -> Vec { + if needle.is_empty() { + return interleave(name, replacement, mode); + } + + match mode { + Mode::First => match find(name, needle) { + Some(at) => splice(name, at, needle.len(), replacement), + None => name.to_vec(), + }, + Mode::Last => match rfind(name, needle) { + Some(at) => splice(name, at, needle.len(), replacement), + None => name.to_vec(), + }, + Mode::All => { + let mut out = Vec::with_capacity(name.len()); + let mut rest = name; + while let Some(at) = find(rest, needle) { + out.extend_from_slice(&rest[..at]); + out.extend_from_slice(replacement); + rest = &rest[at + needle.len()..]; + } + out.extend_from_slice(rest); + out + } + } +} + +/// One name before and after the substitution. +/// +/// `old` is not always the string that was passed in: outside whole-path mode +/// the trailing separators are stripped off, and both the messages and the +/// rename itself use the stripped form. +pub(crate) struct Rewrite<'a, T> { + pub(crate) old: &'a [T], + pub(crate) new: Vec, +} + +impl Rewrite<'_, T> { + /// Nothing to do. C util-linux cannot tell this apart from a name that + /// never matched, and counts both as neither renamed nor failed. + pub(crate) fn is_unchanged(&self) -> bool { + self.old == self.new + } +} + +/// Apply the substitution at the right scope. +/// +/// Normally only the final path component changes. If either argv string +/// contains a separator the whole path is in scope, which is what lets a rename +/// move a file between directories. The rule is decided on the two strings +/// themselves, so it holds even when the needle cannot match anything. +/// +/// Symlink mode feeds the link's target text through here unchanged: the target +/// is scoped the same way, so `-s sub SUB` leaves a target of `sub/t2` alone. +pub(crate) fn rewrite<'a, T: Copy + PartialEq>( + name: &'a [T], + needle: &[T], + replacement: &[T], + mode: Mode, + sep: T, +) -> Rewrite<'a, T> { + if needle.contains(&sep) || replacement.contains(&sep) { + return Rewrite { + old: name, + new: substitute(name, needle, replacement, mode), + }; + } + + let (prefix, component) = split_component(name, sep); + + let mut new = Vec::with_capacity(name.len()); + new.extend_from_slice(prefix); + new.extend_from_slice(&substitute(component, needle, replacement, mode)); + + Rewrite { + old: &name[..prefix.len() + component.len()], + new, + } +} + +/// Split a name into everything before its final component and the component +/// itself. Trailing separators belong to neither and are dropped, which is what +/// the -v line shows: `d1/` is reported as `d1`. +/// +/// A name that is nothing but separators is the exception, and it is why the +/// second search stops one unit short: there is nothing to strip without +/// emptying the name, so the component becomes the final separator alone and +/// everything before it is the prefix. For every other name the unit at +/// `end - 1` is a non-separator by construction, so the shorter search finds +/// the same separator the full one would. +fn split_component(name: &[T], sep: T) -> (&[T], &[T]) { + let end = name + .iter() + .rposition(|unit| *unit != sep) + .map_or(name.len(), |last| last + 1); + let start = name[..end.saturating_sub(1)] + .iter() + .rposition(|unit| *unit == sep) + .map_or(0, |at| at + 1); + (&name[..start], &name[start..end]) +} + +/// An empty needle matches between every two code units. `First` prepends, +/// `Last` appends, and `All` inserts at every boundary including both ends - +/// n + 1 times for a name of n code units. +fn interleave(name: &[T], replacement: &[T], mode: Mode) -> Vec { + match mode { + Mode::First => [replacement, name].concat(), + Mode::Last => [name, replacement].concat(), + Mode::All => { + let mut out = Vec::with_capacity(replacement.len() * (name.len() + 1) + name.len()); + for unit in name { + out.extend_from_slice(replacement); + out.push(*unit); + } + out.extend_from_slice(replacement); + out + } + } +} + +/// The index of the first occurrence of `needle`, which must not be empty. +fn find(name: &[T], needle: &[T]) -> Option { + name.windows(needle.len()).position(|w| w == needle) +} + +/// The index of the last occurrence of `needle`, which must not be empty. +fn rfind(name: &[T], needle: &[T]) -> Option { + name.windows(needle.len()).rposition(|w| w == needle) +} + +fn splice(name: &[T], at: usize, len: usize, replacement: &[T]) -> Vec { + let mut out = Vec::with_capacity(name.len() + replacement.len()); + out.extend_from_slice(&name[..at]); + out.extend_from_slice(replacement); + out.extend_from_slice(&name[at + len..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rewritten name, for the many cases where the old side is not the + /// point of the test. + fn rewritten(name: &[u8], needle: &[u8], replacement: &[u8], mode: Mode) -> Vec { + rewrite(name, needle, replacement, mode, b'/').new + } + + #[test] + fn test_first_replaces_only_the_leading_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::First), b"aZbxcx"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::First), b"Zx"); + } + + #[test] + fn test_all_replaces_every_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::All), b"aZbZcZ"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::All), b"ZZ"); + } + + #[test] + fn test_last_replaces_only_the_trailing_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::Last), b"axbxcZ"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::Last), b"xZ"); + } + + /// Matches do not overlap: the scan resumes after the match it just took. + #[test] + fn test_matches_do_not_overlap() { + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::First), b"Zaa"); + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::All), b"ZZ"); + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::Last), b"aaZ"); + } + + /// Last scans backward, which an odd-length overlap distinguishes from + /// "the final match a forward scan happens to reach". + #[test] + fn test_last_scans_backward() { + assert_eq!(substitute(b"aaaa", b"aaa", b"Z", Mode::Last), b"aZ"); + assert_eq!(substitute(b"aaaa", b"aaa", b"Z", Mode::All), b"Za"); + } + + /// The replacement is never rescanned, so a replacement containing the + /// needle terminates instead of looping. + #[test] + fn test_the_replacement_is_not_rescanned() { + assert_eq!(substitute(b"aaaa", b"a", b"aa", Mode::All), b"aaaaaaaa"); + assert_eq!(substitute(b"x", b"x", b"xx", Mode::All), b"xx"); + assert_eq!(substitute(b"ab", b"ab", b"abab", Mode::All), b"abab"); + } + + #[test] + fn test_no_match_returns_the_name_unchanged() { + assert_eq!(substitute(b"n1", b"q", b"z", Mode::First), b"n1"); + assert_eq!(substitute(b"a", b"aaa", b"Z", Mode::All), b"a"); + } + + #[test] + fn test_an_empty_replacement_deletes() { + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::First), b"abxcx"); + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::All), b"abc"); + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::Last), b"axbxc"); + } + + #[test] + fn test_an_empty_needle_prepends_by_default() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::First), b"Zabc"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::First), b"Zx"); + } + + #[test] + fn test_an_empty_needle_appends_under_last() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::Last), b"abcZ"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::Last), b"xZ"); + } + + /// n + 1 insertions for a name of n code units, both ends included. + #[test] + fn test_an_empty_needle_interleaves_under_all() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::All), b"ZaZbZcZ"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::All), b"ZxZ"); + assert_eq!(substitute(b"abc", b"", b"ZY", Mode::All), b"ZYaZYbZYcZY"); + } + + /// The count is in bytes, not characters. A name holding one two-byte + /// character is five bytes and takes six insertions; a char-based engine + /// would give five and would be wrong on exactly the mojibake filenames + /// people reach for rename to fix. + #[test] + fn test_the_interleave_counts_code_units_not_characters() { + assert_eq!( + substitute(b"caf\xc3\xa9", b"", b"_", Mode::All), + b"_c_a_f_\xc3_\xa9_" + ); + } + + /// A needle may split a multibyte sequence; C util-linux does it without + /// complaint and the result is not valid UTF-8. + #[test] + fn test_matching_is_over_bytes_with_no_character_awareness() { + assert_eq!( + substitute(b"caf\xc3\xa9", b"\xc3", b"X", Mode::First), + b"cafX\xa9" + ); + assert_eq!(substitute(b"lat\xe9n", b"n", b"N", Mode::Last), b"lat\xe9N"); + } + + /// Unreachable through the CLI - an empty operand never survives the + /// existence check. Pinned so the code cannot panic on it. + #[test] + fn test_an_empty_needle_and_an_empty_name_still_insert_once() { + assert_eq!(substitute(b"", b"", b"Z", Mode::All), b"Z"); + assert_eq!(substitute(b"", b"", b"Z", Mode::First), b"Z"); + assert_eq!(substitute(b"", b"", b"Z", Mode::Last), b"Z"); + } + + /// Either argv string containing a separator switches the whole path into + /// scope; the check is on the strings, not on the result. + #[test] + fn test_a_separator_in_either_argument_widens_the_scope() { + assert_eq!(rewritten(b"d1/s1", b"s", b"z", Mode::First), b"d1/z1"); + assert_eq!(rewritten(b"d1/s1", b"d1", b"d3", Mode::First), b"d1/s1"); + assert_eq!(rewritten(b"d1/s1", b"d1/", b"d3/", Mode::First), b"d3/s1"); + // A separator in the replacement alone widens the scope just as well, + // and the substitution stays literal: the separator already in the + // name is not absorbed, giving the doubled one below. + assert_eq!(rewritten(b"d1/s1", b"d1", b"d3/", Mode::First), b"d3//s1"); + assert_eq!(rewritten(b"d1/s1", b"d1/s", b"X", Mode::First), b"X1"); + } + + /// Whole-path mode is entered even when the needle cannot match, which + /// settles "before or after substitution". + #[test] + fn test_the_scope_is_decided_before_matching() { + assert_eq!(rewritten(b"d1/s1", b"z/q", b"Q", Mode::First), b"d1/s1"); + } + + #[test] + fn test_only_the_final_component_is_rewritten_by_default() { + assert_eq!(rewritten(b"d1//s1", b"s", b"z", Mode::First), b"d1//z1"); + assert_eq!(rewritten(b"d1/s1", b"", b"X", Mode::First), b"d1/Xs1"); + assert_eq!(rewritten(b"d1/s1", b"", b"X", Mode::All), b"d1/XsX1X"); + assert_eq!(rewritten(b"./top1", b"top", b"TOP", Mode::First), b"./TOP1"); + assert_eq!(rewritten(b"./top1", b".", b"X", Mode::First), b"./top1"); + } + + /// Trailing separators are not part of the component, and they are gone + /// from the old side too: `d1/` is reported as `d1`. + #[test] + fn test_trailing_separators_are_stripped_from_both_sides() { + let one = rewrite(b"d1/", b"1", b"2", Mode::First, b'/'); + assert_eq!(one.old, b"d1"); + assert_eq!(one.new, b"d2"); + + let many = rewrite(b"d1///", b"1", b"2", Mode::First, b'/'); + assert_eq!(many.old, b"d1"); + assert_eq!(many.new, b"d2"); + + let nested = rewrite(b"d1/sub/", b"sub", b"SUB", Mode::First, b'/'); + assert_eq!(nested.old, b"d1/sub"); + assert_eq!(nested.new, b"d1/SUB"); + + assert_eq!(rewritten(b"d1/", b"", b"Y", Mode::First), b"Yd1"); + } + + /// Stripping happens before the comparison, so an operand whose component + /// does not change is a no-op even though the raw operand and the new name + /// differ by a separator. C util-linux exits 4 in silence here. + #[test] + fn test_a_component_that_does_not_change_is_unchanged_despite_the_stripping() { + let quiet = rewrite(b"d1/", b"QQ", b"ZZ", Mode::First, b'/'); + assert_eq!(quiet.old, b"d1"); + assert_eq!(quiet.new, b"d1"); + assert!(quiet.is_unchanged()); + + let moved = rewrite(b"d1/", b"1", b"2", Mode::First, b'/'); + assert!(!moved.is_unchanged()); + } + + /// Whole-path mode does no stripping at all - there is no component to + /// split out - so a trailing separator survives on both sides. + #[test] + fn test_whole_path_mode_does_not_strip() { + let kept = rewrite(b"d1/", b"d1/", b"d9/", Mode::First, b'/'); + assert_eq!(kept.old, b"d1/"); + assert_eq!(kept.new, b"d9/"); + } + + /// A name that is nothing but separators keeps them all: it is not + /// stripped, and its component is the final separator by itself. + #[test] + fn test_an_all_separator_name_keeps_its_separators() { + let root = rewrite(b"/", b"", b"Y", Mode::First, b'/'); + assert_eq!(root.old, b"/"); + assert_eq!(root.new, b"Y/"); + + assert_eq!(rewritten(b"/", b"", b"Y", Mode::All), b"Y/Y"); + assert_eq!(rewritten(b"//", b"", b"Y", Mode::First), b"/Y/"); + assert_eq!(rewritten(b"//", b"", b"Y", Mode::All), b"/Y/Y"); + assert_eq!(rewritten(b"///", b"", b"Y", Mode::First), b"//Y/"); + assert_eq!(rewritten(b"/", b"x", b"y", Mode::First), b"/"); + assert_eq!(rewritten(b"", b"x", b"y", Mode::First), b""); + } + + /// In whole-path mode the modes apply to the separators themselves. + #[test] + fn test_whole_path_mode_treats_separators_as_ordinary_units() { + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::First), b"d1_/s1"); + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::Last), b"d1/_s1"); + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::All), b"d1__s1"); + assert_eq!(rewritten(b"d1/s1", b"/", b"//", Mode::First), b"d1//s1"); + } + + #[test] + fn test_the_scope_rule_is_code_unit_generic() { + let sep = u16::from(b'/'); + let name: Vec = "d1/s1".encode_utf16().collect(); + let needle: Vec = "s".encode_utf16().collect(); + let replacement: Vec = "z".encode_utf16().collect(); + let expected: Vec = "d1/z1".encode_utf16().collect(); + assert_eq!( + rewrite(&name, &needle, &replacement, Mode::First, sep).new, + expected + ); + } + + /// The engine is instantiated over u16 on Windows, which a unix build + /// never compiles. Same table, same expectations. + #[test] + fn test_the_engine_is_code_unit_generic() { + let name: Vec = "axbxcx".encode_utf16().collect(); + let needle: Vec = "x".encode_utf16().collect(); + let replacement: Vec = "Z".encode_utf16().collect(); + let expected: Vec = "aZbZcZ".encode_utf16().collect(); + assert_eq!( + substitute(&name, &needle, &replacement, Mode::All), + expected + ); + } +} diff --git a/tests/by-util/test_rename.rs b/tests/by-util/test_rename.rs new file mode 100644 index 00000000..21d93d36 --- /dev/null +++ b/tests/by-util/test_rename.rs @@ -0,0 +1,1289 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore (words) axbxcx aZbxcx aZbZcZ axbxcZ aXbXcX +// spell-checker:ignore (words) rpmatch lnk dang + +use uutests::{at_and_ucmd, new_ucmd}; + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +// -- the exit-status tally ----------------------------------------------- + +#[test] +fn test_a_plain_rename_reports_nothing_and_exits_zero() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["s", "z", "s1"]).succeeds().no_output(); + assert!(at.file_exists("z1")); + assert!(!at.file_exists("s1")); +} + +/// A no-match is not a failure and not a success: it is invisible to the tally. +#[test] +fn test_a_no_match_exits_four() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("n1"); + ucmd.args(&["q", "z", "n1"]).fails().code_is(4).no_output(); + assert!(at.file_exists("n1")); +} + +// -- option permutation and POSIXLY_CORRECT ------------------------------- + +/// POSIXLY_CORRECT turns permutation off: scanning stops at the first operand +/// and everything after it is a filename, however much it looks like a flag. +#[test] +fn test_posixly_correct_stops_the_scan_at_the_first_operand() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", "") + .args(&["s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1")); +} + +#[test] +fn test_posixly_correct_is_read_for_presence_and_not_for_value() { + for value in ["", "0"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", value) + .args(&["s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1"), "{value:?}"); + } +} + +/// Scanning stops at the first NON-OPTION, not at the first operand slot, so +/// flags written before it are still flags - and this invocation is short of +/// operands either way. +#[test] +fn test_posixly_correct_still_reads_the_flags_that_come_first() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", "") + .args(&["-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); +} + +/// The existence check runs before the substitution result matters. +#[test] +fn test_a_missing_operand_fails_even_when_the_needle_cannot_match() { + new_ucmd!() + .args(&["q", "z", "s9"]) + .fails() + .code_is(1) + .no_stdout(); +} + +/// The truth table. No boundary between the statuses is documented, so this +/// walks every mix of the three per-operand outcomes: s1 s2 rename, s8 s9 are +/// absent, n1 n2 exist and hold no `s`. Two rows carry the rules a tally of +/// flags rather than counters would get wrong - S+N is 0 rather than 2 or 4, +/// and F+N is 1 rather than 2. The last three reverse a mix already listed, +/// because two counters cannot encode an order. +#[test] +fn test_the_tally_selects_one_status_for_every_mix_of_outcomes() { + for (operands, code) in [ + (&["s1"][..], 0), + (&["s9"], 1), + (&["n1"], 4), + (&["s1", "s2"], 0), + (&["s1", "s9"], 2), + (&["s1", "n1"], 0), + (&["s8", "s9"], 1), + (&["s9", "n1"], 1), + (&["n1", "n2"], 4), + (&["s1", "s9", "n1"], 2), + (&["s9", "n1", "n2"], 1), + (&["s9", "s1"], 2), + (&["n1", "s1"], 0), + (&["n1", "s9"], 1), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + for name in ["s1", "s2", "n1", "n2"] { + at.touch(name); + } + let actual = ucmd.args(&["s", "z"]).args(operands).run().code(); + assert_eq!(actual, code, "operands {operands:?}"); + } +} + +/// The other half of this claim, that each failure is reported exactly once +/// and in argv order, is pinned by +/// unix::test_every_failing_operand_is_reported_once_and_in_order, because the +/// text it has to assert is the platform's strerror. +#[test] +fn test_a_failure_in_the_middle_does_not_stop_the_operands_after_it() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s8", "s2", "s9"]) + .fails() + .code_is(2) + .stdout_is("`s2' -> `z2'\n"); + assert!(at.file_exists("z2")); +} + +// -- the substring-equals-replacement short circuit ---------------------- + +/// The short circuit is for the whole run, before any operand is touched, so a +/// missing operand is not even noticed. +#[test] +fn test_an_identical_substring_and_replacement_touch_nothing() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["s", "s", "s1", "s9"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.file_exists("s1")); +} + +// -- reporting ----------------------------------------------------------- + +#[test] +fn test_verbose_names_both_paths() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); +} + +/// The report delimits both names with a backtick and a quote and escapes +/// nothing, so a name containing either one goes out as it is. +#[test] +fn test_the_report_does_not_escape_its_own_delimiters() { + for (name, needle, replacement, expected) in [ + ("q'te", "q", "Q", "`Q'te'"), + ("sp ace", "sp", "SP", "`SP ace'"), + ("t`ck", "t", "T", "`T`ck'"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&["-v", needle, replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> {expected}\n")); + } +} + +/// -n counts as though the rename had happened and prints the same line. +#[test] +fn test_no_act_changes_nothing_but_still_reports() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-nv", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("s1")); + assert!(!at.file_exists("z1")); +} + +// -- substitution modes -------------------------------------------------- + +/// Which occurrence each mode takes, and how each scan behaves where the +/// matches overlap. The overlap rows are the ones that separate the three +/// modes: -l scans backward rather than taking the last match a forward scan +/// would find, which is why `aaa` in `aaaa` gives `Za` one way and `aZ` the +/// other. The replacement is never rescanned, so replacing `a` by `aa` +/// terminates. +#[test] +fn test_the_modes_choose_which_occurrence_is_replaced() { + for (flags, needle, replacement, name, new) in [ + ("-v", "x", "Z", "axbxcx", "aZbxcx"), + ("-va", "x", "Z", "axbxcx", "aZbZcZ"), + ("-vl", "x", "Z", "axbxcx", "axbxcZ"), + ("-v", "aaa", "Z", "aaaa", "Za"), + ("-va", "aaa", "Z", "aaaa", "Za"), + ("-vl", "aaa", "Z", "aaaa", "aZ"), + ("-va", "a", "aa", "aaaa", "aaaaaaaa"), + ("-v", "x", "", "axbxcx", "abxcx"), + ("-va", "x", "", "axbxcx", "abc"), + ("-vl", "x", "", "axbxcx", "axbxc"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&[flags, needle, replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> `{new}'\n")); + assert!(at.file_exists(new), "{flags} {needle} {replacement} {name}"); + } +} + +/// The count is one more than the number of code units in the name, which is +/// what makes the -a row a statement about the engine rather than about this +/// fixture. +#[test] +fn test_an_empty_needle_inserts_at_every_boundary() { + for (flags, replacement, name, new) in [ + ("-v", "Z", "abc", "Zabc"), + ("-vl", "Z", "abc", "abcZ"), + ("-va", "Z", "abc", "ZaZbZcZ"), + ("-va", "ZY", "abc", "ZYaZYbZYcZY"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&[flags, "", replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> `{new}'\n")); + assert!(at.file_exists(new), "{flags} '' {replacement} {name}"); + } +} + +// -- path scope ---------------------------------------------------------- + +#[test] +fn test_only_the_final_component_is_rewritten() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1", "d9", "d1/s1"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.file_exists("d1/s1")); +} + +/// A separator in either argument widens the scope to the whole path, which is +/// how a rename moves a file between directories. +#[test] +fn test_a_separator_in_an_argument_moves_the_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.mkdir("d9"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1/", "d9/", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `d9/s1'\n"); + assert!(at.file_exists("d9/s1")); +} + +/// The scope is decided on the two argument strings, before any matching, so a +/// separator widens it even when the needle cannot possibly match. +#[test] +fn test_a_separator_in_either_argument_widens_the_scope() { + // Only the substring holds a separator. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1/s", "X", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `X1'\n"); + assert!(at.file_exists("X1")); + + // Only the replacement does. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.mkdir("d3"); + at.touch("d1/s1"); + ucmd.args(&["d1", "d3/", "d1/s1"]).succeeds(); + assert!(at.file_exists("d3/s1")); +} + +/// The report shows the stripped form on both sides, however many separators +/// there were. +#[test] +fn test_trailing_separators_are_stripped_from_the_operand() { + for (flags, needle, replacement, operand, expected) in [ + ("-v", "1", "9", "d1/", "`d1' -> `d9'\n"), + ("-v", "1", "9", "d1///", "`d1' -> `d9'\n"), + ("-v", "", "Y", "d1/", "`d1' -> `Yd1'\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&[flags, needle, replacement, operand]) + .succeeds() + .stdout_is(expected); + } +} + +/// Whole-path mode never splits a component out, so it has nothing to strip +/// and the trailing separator survives into the new name. +#[test] +fn test_whole_path_mode_keeps_a_trailing_separator() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&["-v", "d1/", "d9/", "d1/"]) + .succeeds() + .stdout_is("`d1/' -> `d9/'\n"); + assert!(at.dir_exists("d9")); +} + +/// The comparison that decides whether anything changed is between the +/// stripped name and the new one. Comparing against the raw operand instead +/// would find a difference here and report a rename of `d1` onto itself. +#[test] +fn test_the_change_comparison_happens_after_stripping() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&["-v", "QQ", "ZZ", "d1/"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.dir_exists("d1")); +} + +/// A leading `./` is prefix, not component, so it is neither matched against +/// nor lost. +#[test] +fn test_a_dot_relative_operand_keeps_its_prefix() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("top1"); + ucmd.args(&["-v", "top", "TOP", "./top1"]) + .succeeds() + .stdout_is("`./top1' -> `./TOP1'\n"); + assert!(at.file_exists("TOP1")); + + // The dot lives in the prefix, so a needle of "." matches nothing. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("top1"); + ucmd.args(&[".", "X", "./top1"]).fails().code_is(4); + assert!(at.file_exists("top1")); +} + +/// The stripping happens after the existence check, which still sees the +/// operand exactly as it was typed - so a trailing separator on a regular file +/// is an error rather than a rename. What the platform calls ENOTDIR is its +/// business, so only our half of the line is asserted. +#[test] +fn test_the_existence_check_sees_the_untouched_operand() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["s", "z", "d1/s1/"]) + .fails() + .code_is(1) + .stderr_contains("rename: d1/s1/: not accessible: "); + assert!(at.file_exists("d1/s1")); +} + +// -- the overwrite safeguards -------------------------------------------- + +/// A quiet -o run says nothing at all where a quiet -i run still asks. With +/// -v the prompt and whatever follows share a line, because the prompt carries +/// no newline of its own. +#[test] +fn test_the_skip_report_is_verbose_gated_and_the_prompt_is_not() { + for (flags, answer, code, expected) in [ + ("-o", "", 4, ""), + ("-vo", "", 4, "Skipping existing file: `n1'\n"), + ("-i", "n\n", 4, "rename: overwrite `n1'? "), + ( + "-vi", + "n\n", + 4, + "rename: overwrite `n1'? Skipping existing file: `n1'\n", + ), + ("-i", "y\n", 0, "rename: overwrite `n1'? "), + ("-vi", "y\n", 0, "rename: overwrite `n1'? `s1' -> `n1'\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("n1"); + let result = ucmd.args(&[flags, "s", "n", "s1"]).pipe_in(answer).run(); + assert_eq!(result.code(), code, "{flags} answered {answer:?}"); + result.no_stderr().stdout_is(expected); + } +} + +/// rpmatch under LC_ALL=C resolves to a test on the first character, and +/// uutests pins LC_ALL=C for every run. An implementation comparing the whole +/// answer against "y" would reject "yes", which is accepted; one that skipped +/// leading whitespace would accept " y", which is not. +#[test] +fn test_the_answer_is_matched_on_its_first_character() { + for answer in ["y\n", "yes\n", "Y extra\n"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in(answer) + .succeeds() + .stdout_is("rename: overwrite `z1'? "); + assert!( + !at.file_exists("s1"), + "{answer:?} should have been accepted" + ); + } + + for answer in ["n\n", "maybe\n", " y\n", "\n", "1\n"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in(answer) + .fails() + .code_is(4) + .stdout_is("rename: overwrite `z1'? "); + assert!(at.file_exists("s1"), "{answer:?} should have been declined"); + } +} + +/// Neither guard fires unless something is actually in the way, and -i does +/// not read stdin when it has nothing to ask about. +#[test] +fn test_an_absent_destination_is_never_guarded() { + for flags in ["-vo", "-vi"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + // The answer is never read, so the write end may break first. That it + // breaks at all is the evidence, not an error. + ucmd.args(&[flags, "s", "z", "s1"]) + .pipe_in("n\n") + .ignore_stdin_write_error() + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); + } +} + +#[test] +fn test_no_overwrite_refuses_a_destination_of_any_kind() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.mkdir("z1"); + ucmd.args(&["-vo", "s", "z", "s1"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing file: `z1'\n"); + assert!(at.file_exists("s1")); +} + +/// An accepted prompt is a decision to try, not a promise that it works: the +/// rename can still fail, and then it is a failure like any other. +#[test] +fn test_an_accepted_prompt_that_cannot_be_carried_out_fails() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.mkdir("z1"); + at.touch("z1/keep"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in("y\n") + .fails() + .code_is(1) + .stdout_is("rename: overwrite `z1'? "); + assert!(at.file_exists("s1")); +} + +/// Under -n both guards still run and still report, and the verbose line +/// prints on top of the skip - two lines for an operand that counts as +/// neither, so the run reports 4 rather than 0. -i degrades to exactly what -o +/// does and never reads stdin, so no answer changes any of it. +#[test] +fn test_no_act_degrades_both_safeguards_to_the_same_skip() { + for (flags, answer) in [("-nvo", ""), ("-nvi", "y\n"), ("-nvi", "n\n")] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("n1"); + // -n never reads stdin, so the write end may break before the answer + // lands. That is the behavior under test, not a failure of it. + ucmd.args(&[flags, "s", "n", "s1"]) + .pipe_in(answer) + .ignore_stdin_write_error() + .fails() + .code_is(4) + .stdout_is("Skipping existing file: `n1'\n`s1' -> `n1'\n"); + assert!(at.file_exists("s1"), "{flags} answered {answer:?}"); + } +} + +/// A prompt that reaches end of input answers itself with a literal `n` and +/// terminates the line, so unlike every other decline this one does leave the +/// prompt's line closed. The echo is not verbose-gated, because the prompt it +/// closes is not either. +#[test] +fn test_interactive_at_end_of_input_declines() { + for (flags, expected) in [ + ( + "-vi", + "rename: overwrite `z1'? n\nSkipping existing file: `z1'\n", + ), + ("-i", "rename: overwrite `z1'? n\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&[flags, "s", "z", "s1"]) + .fails() + .code_is(4) + .stdout_is(expected); + assert!(at.file_exists("s1"), "{flags}"); + } +} + +/// A run that outlives its input reaches end of input on the second prompt +/// rather than re-reading the first answer, and a multi-character yes does not +/// spill into the answer the next prompt reads. +#[test] +fn test_each_prompt_consumes_a_whole_line() { + for (answers, expected, second_renamed) in [ + ( + "y\n", + "rename: overwrite `n1'? rename: overwrite `n2'? n\n", + false, + ), + ( + "yn\n", + "rename: overwrite `n1'? rename: overwrite `n2'? n\n", + false, + ), + ( + "yes\ny\n", + "rename: overwrite `n1'? rename: overwrite `n2'? ", + true, + ), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + for name in ["s1", "s2", "n1", "n2"] { + at.touch(name); + } + ucmd.args(&["-i", "s", "n", "s1", "s2"]) + .pipe_in(answers) + .succeeds() + .stdout_is(expected); + assert!(!at.file_exists("s1"), "{answers:?}"); + assert_eq!(!at.file_exists("s2"), second_renamed, "{answers:?}"); + } +} + +// -- the option surface -------------------------------------------------- + +/// Three operands are required and a usage error is exit 1, which is the same +/// status as "everything failed". The wording is clap's and is not asserted. +#[test] +fn test_too_few_operands_exit_one() { + let cases: [&[&str]; 3] = [&[], &["s", "z"], &["-v", "s", "z"]]; + for args in cases { + new_ucmd!().args(args).fails().code_is(1); + } +} + +/// The only two mutually exclusive pairs, in every spelling that reaches them. +/// Overriding an argument with itself does not weaken this: `-l -a -l` still +/// conflicts. +#[test] +fn test_the_exclusive_pairs_are_refused_however_they_are_spelled() { + let cases: [&[&str]; 3] = [&["-a", "-l"], &["-o", "-i"], &["-l", "-a", "-l"]]; + for args in cases { + new_ucmd!() + .args(args) + .args(&["x", "Z", "axbxcx"]) + .fails() + .code_is(1); + } +} + +/// Every option has a long form and clap is configured to accept any +/// unambiguous prefix of one. A prefix shared by two options is an error +/// rather than a choice: `--n` reaches both of the no- options. +#[test] +fn test_a_long_option_may_be_abbreviated_to_an_unambiguous_prefix() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--verb", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--n", "s", "z", "s1"]).fails().code_is(1); + assert!(at.file_exists("s1")); +} + +/// Options are permuted: a flag is recognized anywhere, including between the +/// two arguments and between two file operands. +#[test] +fn test_a_flag_is_recognized_anywhere_among_the_operands() { + for args in [ + &["-v", "s", "z", "s1"][..], + &["s", "-v", "z", "s1"], + &["s", "z", "-v", "s1"], + &["s", "z", "s1", "-v"], + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(args).succeeds().stdout_is("`s1' -> `z1'\n"); + } + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + ucmd.args(&["s", "z", "s1", "-v", "s2"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n`s2' -> `z2'\n"); +} + +#[test] +fn test_a_terminator_turns_everything_after_it_into_an_operand() { + for args in [&["--", "s", "z", "s1"][..], &["s", "z", "--", "s1"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(args).succeeds(); + assert!(at.file_exists("z1"), "{args:?}"); + } + + // The flag is an operand now, and a missing one at that. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--", "s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1")); +} + +/// getopt lets a flag repeat and C util-linux renames regardless, so this is +/// behavior rather than wording: a clap SetTrue argument rejects its second +/// occurrence unless the command overrides an argument with itself. +#[test] +fn test_a_repeated_option_is_accepted() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-v", "-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("axbxcx"); + ucmd.args(&["-a", "-a", "x", "Z", "axbxcx"]).succeeds(); + assert!(at.file_exists("aZbZcZ")); +} + +/// Symlink mode, byte-oriented names, mode bits and POSIX rename(2) semantics +/// all need helpers that do not exist or do not behave the same on Windows, so +/// they are gathered here rather than gated one attribute at a time. Everything +/// above this line runs on all three platforms. +#[cfg(unix)] +mod unix { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use uutests::util::AtPath; + use uutests::{at_and_ucmd, new_ucmd}; + + /// Tests that arrange for a mode bit to deny something expect that denial + /// to happen. Root is not denied by any mode bit, so they are skipped + /// there rather than made to pass by accident. CI never runs as root; this + /// is for the developer who does. + fn skipped_as_root() -> bool { + if uucore::process::geteuid() == 0 { + println!("test skipped: root is not denied by any mode bit"); + return true; + } + false + } + + // -- error classes ---------------------------------------------------- + + /// The diagnostic carries the platform's strerror text, which is why this + /// half lives here and its other half, + /// test_a_failure_in_the_middle_does_not_stop_the_operands_after_it, + /// stays ungated. + #[test] + fn test_every_failing_operand_is_reported_once_and_in_order() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s8", "s2", "s9"]) + .fails() + .code_is(2) + .stdout_is("`s2' -> `z2'\n") + .stderr_is( + "rename: s8: not accessible: No such file or directory\n\ + rename: s9: not accessible: No such file or directory\n", + ); + assert!(at.file_exists("z2")); + } + + /// A fixture builder, the argv that fails against it, and the diagnostic + /// the failure produces. + type ErrorCase<'a> = (fn(&AtPath), Vec<&'a str>, String); + + /// The tally counts failures, not kinds of failure. Each of these is a + /// different errno reached through a different call, and every one of them + /// is exactly one failure and nothing else. Only the part of the + /// diagnostic we write is asserted - the errno text belongs to libc. + #[test] + fn test_every_error_class_counts_as_one_failure() { + let cases: [ErrorCase; 2] = [ + ( + |at| { + at.touch("top1"); + at.mkdir("d3"); + at.touch("d3/keep"); + }, + vec!["top1", "d3", "top1"], + "rename: top1: rename to d3 failed: ".into(), + ), + ( + |at| { + at.mkdir("d1"); + at.mkdir("d3"); + at.touch("d3/keep"); + }, + vec!["d1", "d3", "d1"], + "rename: d1: rename to d3 failed: ".into(), + ), + ]; + + for (setup, args, expected) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + setup(&at); + ucmd.args(&args) + .fails() + .code_is(1) + .no_stdout() + .stderr_contains(&expected); + } + } + + /// A directory that denies writes fails the rename; one that denies search + /// fails the existence check instead, and the two say different things. + /// Which they are is the claim; the errno text after them is the + /// platform's and is left to it. + #[test] + fn test_a_denied_directory_fails_at_whichever_step_needs_it() { + if skipped_as_root() { + return; + } + + for (mode, expected) in [ + (0o500, "rename: p/s1: rename to p/z1 failed: "), + (0o000, "rename: p/s1: not accessible: "), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("p"); + at.touch("p/s1"); + at.set_mode("p", mode); + ucmd.args(&["s", "z", "p/s1"]) + .fails() + .code_is(1) + .stderr_contains(expected); + at.set_mode("p", 0o700); + assert!(at.file_exists("p/s1")); + } + } + + /// A replacement that deletes the whole name is not caught before the + /// syscall: the empty string is handed to rename(2) and the kernel refuses + /// it. + #[test] + fn test_a_replacement_that_empties_the_name_fails_in_the_kernel() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("abc"); + ucmd.args(&["-v", "abc", "", "abc"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_is("rename: abc: rename to failed: No such file or directory\n"); + assert!(at.file_exists("abc")); + } + + // -- byte-oriented names ---------------------------------------------- + + /// Nothing quotes or escapes a name, in the report or in a diagnostic. + #[test] + fn test_a_name_holding_a_newline_splits_the_line_it_is_printed_on() { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"nl\nx"); + at.touch(name); + ucmd.args(&[OsStr::new("-v"), OsStr::new("nl"), OsStr::new("NL"), name]) + .succeeds() + .stdout_is("`nl\nx' -> `NL\nx'\n"); + assert!(at.file_exists(OsStr::from_bytes(b"NL\nx"))); + + new_ucmd!() + .args(&[ + OsStr::new("-v"), + OsStr::new("no"), + OsStr::new("yes"), + OsStr::from_bytes(b"no\nsuch"), + ]) + .fails() + .code_is(1) + .stderr_is("rename: no\nsuch: not accessible: No such file or directory\n"); + } + + // -- POSIX rename(2) semantics ---------------------------------------- + + /// A substitution that only rewrites a separator names the very same file, + /// and that still counts as a rename. + #[test] + fn test_a_rename_that_only_changes_the_string_still_counts() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "/", "//", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `d1//s1'\n"); + assert!(at.file_exists("d1/s1")); + } + + // -- the overwrite safeguards, where they need a link ----------------- + + /// -o asks whether anything is reachable at the destination, which follows + /// the link: a dangling symlink is not something, so it gets clobbered. + #[test] + fn test_no_overwrite_does_not_protect_a_dangling_destination() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.relative_symlink_file("t_missing", "n1"); + ucmd.args(&["-vo", "s", "n", "s1"]) + .succeeds() + .stdout_is("`s1' -> `n1'\n"); + assert!(!at.symlink_exists("n1")); + assert!(at.file_exists("n1")); + } + + /// A probe that cannot be performed at all reads as "nothing there", so -o + /// lets the rename go ahead and it fails on its own terms. Both runs below + /// report the same thing, which is the whole point: -o changes nothing + /// when it cannot see the destination. + #[test] + fn test_no_overwrite_treats_a_denied_probe_as_absent() { + if skipped_as_root() { + return; + } + + for flags in [&["-v", "-o"][..], &["-v"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + at.mkdir("e"); + at.touch("e/s1"); + at.set_mode("e", 0o000); + ucmd.args(flags) + .args(&["d1/", "e/", "d1/s1"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_contains("rename: d1/s1: rename to e/s1 failed: "); + at.set_mode("e", 0o700); + assert!(at.file_exists("d1/s1")); + } + } + + // -- symlink mode ----------------------------------------------------- + + #[test] + fn test_symlink_mode_rewrites_the_target_not_the_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + at.relative_symlink_file("t1", "l_ok"); + ucmd.args(&["-vs", "t", "z", "l_ok"]) + .succeeds() + .stdout_is("l_ok: `t1' -> `z1'\n"); + assert_eq!(at.resolve_link("l_ok"), "z1"); + } + + /// The symlink type check precedes the match test, so a non-symlink fails + /// even when the substring appears nowhere. + #[test] + fn test_symlink_mode_on_a_regular_file_fails_without_matching() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + ucmd.args(&["-s", "q", "Q", "t1"]) + .fails() + .code_is(1) + .stderr_is("rename: t1: not a symbolic link\n"); + } + + /// -s never resolves a chain: the target text of l_c1 is "l_c2", not "t1". + #[test] + fn test_symlink_mode_does_not_follow_a_chain() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + at.relative_symlink_file("t1", "l_c2"); + at.relative_symlink_file("l_c2", "l_c1"); + ucmd.args(&["-s", "t", "z", "l_c1"]).fails().code_is(4); + assert_eq!(at.resolve_link("l_c1"), "l_c2"); + } + + /// One tree of links covering every target shape -s has to handle. + fn create_links(at: &AtPath) { + at.touch("t1"); + at.mkdir("sub"); + at.touch("sub/t2"); + at.relative_symlink_file("t1", "l_ok"); + at.relative_symlink_file("t_missing", "l_dangle"); + at.relative_symlink_file("sub", "l_dir"); + at.relative_symlink_file("sub/t2", "l_deep"); + at.relative_symlink_file("/nonexistent/t1", "l_abs"); + at.relative_symlink_file("axbxcx", "l_rep"); + at.relative_symlink_file("t1", "l_c2"); + at.relative_symlink_file("l_c2", "l_c1"); + at.relative_symlink_file("l_self", "l_self"); + at.relative_symlink_file("t2", "sub/l_x"); + } + + /// -s rewrites the target text and nothing else, whatever that text is: a + /// target that leads nowhere, an absolute one, an empty needle inserting + /// into one, and a mode flag choosing among several matches in one. + #[test] + fn test_symlink_mode_rewrites_a_target_of_any_shape() { + let cases: [(&[&str], &str, &str); 4] = [ + (&["-s", "t", "z", "l_dangle"], "l_dangle", "z_missing"), + (&["-s", "t", "z", "l_abs"], "l_abs", "/nonexistent/z1"), + (&["-s", "", "P", "l_deep"], "l_deep", "sub/Pt2"), + (&["-s", "-a", "x", "X", "l_rep"], "l_rep", "aXbXcX"), + ]; + + for (args, link, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(args).succeeds(); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + } + + /// The target is scoped exactly the way a filename is: the final component + /// only, unless an argument holds a separator. A needle that matches only + /// the directory part matches nothing, and so does one that matches the + /// link's own name rather than its target. + #[test] + fn test_symlink_mode_scopes_the_target_like_a_filename() { + let cases: [(&[&str], &str, &str); 2] = [ + (&["-s", "sub", "SUB", "l_deep"], "l_deep", "sub/t2"), + (&["-s", "ok", "OK", "l_ok"], "l_ok", "t1"), + ]; + + for (args, link, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(args).fails().code_is(4).no_output(); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + } + + /// The type error is an ordinary per-operand failure, so it mixes with a + /// success the way any other failure does. + #[test] + fn test_symlink_mode_mixes_a_type_error_into_the_tally() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "-v", "t", "z", "t1", "l_ok"]) + .fails() + .code_is(2) + .stdout_is("l_ok: `t1' -> `z1'\n") + .stderr_is("rename: t1: not a symbolic link\n"); + assert_eq!(at.resolve_link("l_ok"), "z1"); + } + + /// Without -s a symlink is an ordinary directory entry: its name is + /// rewritten and its target is left alone. + /// + /// This rename differs only in case, so neither a lookup of the old name + /// nor one of the new name can tell whether anything happened on a + /// filesystem that folds case - both would answer about the same entry. + /// The stored spelling has to come from the directory itself, which is + /// also the stronger assertion here: it rejects a rename to any name other + /// than exactly `l_OK`. + #[test] + fn test_a_link_without_symlink_mode_is_renamed_by_name() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["_ok", "_OK", "l_ok"]).succeeds(); + assert_eq!(at.resolve_link("l_OK"), "t1"); + + let names: Vec<_> = std::fs::read_dir(at.plus(".")) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .collect(); + // The positive assertion guards the negative one: a listing that could + // not be read is empty, and would otherwise report the old name gone. + assert!(names.iter().any(|name| name == "l_OK"), "{names:?}"); + assert!(!names.iter().any(|name| name == "l_ok"), "{names:?}"); + } + + /// Under -s the guard asks whether the new target NAME is a directory + /// entry, not whether the link would resolve. `sub` is taken and `zzz` is + /// not. + #[test] + fn test_symlink_mode_no_overwrite_probes_the_new_target_name() { + let cases: [(&[&str], &str, &str, i32); 2] = [ + (&["-s", "-o", "t1", "sub", "l_ok"], "l_ok", "t1", 4), + (&["-s", "-o", "t1", "zzz", "l_ok"], "l_ok", "zzz", 0), + ]; + + for (args, link, target, code) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + let actual = ucmd.args(args).run().code(); + assert_eq!(actual, code, "{args:?}"); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + + // The skip line names the link and the target it still has, not the + // one it was refused. + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "-o", "-v", "t1", "sub", "l_ok"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing link: `l_ok' -> `t1'\n"); + } + + /// Under -s the prompt names the new target rather than the link, which is + /// the opposite way round from the skip line just above. + #[test] + fn test_symlink_mode_prompts_about_the_new_target() { + let cases: [(&[&str], &str, i32, &str, &str); 2] = [ + ( + &["-s", "-i", "t1", "sub", "l_ok"], + "y\n", + 0, + "rename: overwrite `sub'? ", + "sub", + ), + ( + &["-s", "-v", "-i", "t1", "sub", "l_ok"], + "n\n", + 4, + "rename: overwrite `sub'? Skipping existing link: `l_ok' -> `t1'\n", + "t1", + ), + ]; + + for (args, answer, code, expected, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + let result = ucmd.args(args).pipe_in(answer).run(); + assert_eq!(result.code(), code, "{args:?}"); + result.stdout_is(expected); + assert_eq!(at.resolve_link("l_ok"), target, "{args:?}"); + } + } + + /// The rewrite is not atomic and deliberately so: the unlink has already + /// happened when the symlink fails, and the original link is gone. The + /// message names the target it tried to create, so an empty target prints + /// two spaces. + /// + /// Only Linux can be made to fail this way: its symlink(2) returns ENOENT + /// when the target is an empty string, where the BSD call that macOS + /// inherits reserves that error for an empty link name and creates the + /// empty target happily. + #[cfg(target_os = "linux")] + #[test] + fn test_a_symlink_that_cannot_be_created_leaves_no_link_behind() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "t1", "", "l_ok"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_is("rename: l_ok: symlinking to failed: No such file or directory\n"); + assert!(!at.symlink_exists("l_ok")); + } + + /// Under -s the -o guard is an lstat on the new target, so a dangling entry + /// there blocks the rewrite, where the default path stats instead, finds + /// nothing, and lets the rename clobber it. The two modes need different + /// predicates and must not share one helper. + #[test] + fn test_symlink_mode_no_overwrite_is_blocked_by_a_dangling_new_target() { + let (at, mut ucmd) = at_and_ucmd!(); + at.relative_symlink_file("gone", "old_t"); + at.relative_symlink_file("also_gone", "new_t"); + at.relative_symlink_file("old_t", "lnk"); + ucmd.args(&["-vos", "old_t", "new_t", "lnk"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing link: `lnk' -> `old_t'\n"); + assert_eq!(at.resolve_link("lnk"), "old_t"); + } + + /// The rewrite is an unlink followed by a symlink, and the two halves + /// report separately: a link whose parent directory denies writes fails at + /// the unlink, keeps the link, and says which step it was. + #[test] + fn test_symlink_mode_reports_a_failed_unlink() { + if skipped_as_root() { + return; + } + + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("p"); + at.touch("t1"); + at.relative_symlink_file("t1", "p/l_ro"); + at.set_mode("p", 0o500); + ucmd.args(&["-s", "t", "z", "p/l_ro"]) + .fails() + .code_is(1) + .stderr_contains("rename: p/l_ro: unlink failed: "); + at.set_mode("p", 0o700); + assert_eq!(at.resolve_link("p/l_ro"), "t1"); + } + + /// The -s guard resolves a relative new target against the process working + /// directory rather than the link's own directory: `sub/b1` exists, but the + /// probe is for `b1`, which does not, so -o does not block the rewrite. + #[test] + fn test_symlink_mode_no_overwrite_probes_relative_to_the_working_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("sub"); + at.touch("sub/a1"); + at.touch("sub/b1"); + at.relative_symlink_file("a1", "sub/lnk"); + ucmd.args(&["-vos", "a", "b", "sub/lnk"]) + .succeeds() + .stdout_is("sub/lnk: `a1' -> `b1'\n"); + assert_eq!(at.resolve_link("sub/lnk"), "b1"); + } +} + +/// Two things need more than a unix: a stdout that refuses everything written +/// to it needs /dev/full, which is a Linux device, and a filename that is not +/// valid UTF-8 needs a filesystem that stores names as bytes. APFS, the +/// default on macOS, accepts only valid UTF-8 for creation, so making one +/// there fails with EILSEQ before the utility is ever invoked. +#[cfg(target_os = "linux")] +mod linux { + use std::ffi::OsStr; + use std::fs::{File, OpenOptions}; + use std::os::unix::ffi::OsStrExt; + use uutests::{at_and_ucmd, new_ucmd}; + + fn dev_full() -> Option { + match OpenOptions::new().write(true).open("/dev/full") { + Ok(file) => Some(file), + Err(_) => { + println!("test skipped: /dev/full is not available"); + None + } + } + } + + // -- byte-oriented names ---------------------------------------------- + + /// A filename is a byte string, and one that is not valid UTF-8 goes + /// through unchanged - matched as bytes, written to stdout as bytes, and + /// created as bytes. + #[test] + fn test_a_name_that_is_not_valid_utf8_survives_the_round_trip() { + let (at, mut ucmd) = at_and_ucmd!(); + let old = OsStr::from_bytes(b"lat\xe9n"); + at.touch(old); + ucmd.args(&[OsStr::new("-v"), OsStr::new("lat"), OsStr::new("LAT"), old]) + .succeeds() + .stdout_is_bytes(b"`lat\xe9n' -> `LAT\xe9n'\n"); + assert!(at.file_exists(OsStr::from_bytes(b"LAT\xe9n"))); + } + + /// The result is not valid UTF-8 and that is not an error. An engine + /// working over characters could not express any of these. + #[test] + fn test_a_needle_may_split_a_multibyte_character() { + for (needle, replacement, new) in [ + (&b"\xc3"[..], &b"X"[..], &b"cafX\xa9"[..]), + (&b"f\xc3"[..], &b"F"[..], &b"caF\xa9"[..]), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"caf\xc3\xa9"); + at.touch(name); + ucmd.args(&[ + OsStr::from_bytes(needle), + OsStr::from_bytes(replacement), + name, + ]) + .succeeds(); + assert!(at.file_exists(OsStr::from_bytes(new)), "{needle:?}"); + } + } + + /// The unit is a byte: `caf` is five bytes and takes six + /// insertions, where an engine seeing four characters would insert five + /// times - which is the whole reason the engine is generic over the code + /// unit. + #[test] + fn test_an_empty_needle_counts_code_units_not_characters() { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"caf\xc3\xa9"); + at.touch(name); + ucmd.args(&[OsStr::new("-a"), OsStr::new(""), OsStr::new("_"), name]) + .succeeds(); + assert!(at.file_exists(OsStr::from_bytes(b"_c_a_f_\xc3_\xa9_"))); + } + + // -- an unwritable stdout --------------------------------------------- + + /// Reporting is not best-effort. A run whose output cannot be written says + /// so once, at the end, and reports 1 - discarding a tally that had + /// already decided otherwise, even though every rename really happened. + /// A run that writes nothing never notices. + #[test] + fn test_a_stdout_that_cannot_be_written_overrides_the_tally() { + let cases: [(&[&str], i32, &str); 2] = [ + ( + &["-v", "s", "z", "s1", "s2"], + 1, + "rename: write error: No space left on device\n", + ), + (&["s", "z", "s1", "s2"], 0, ""), + ]; + + for (args, code, expected) in cases { + let Some(sink) = dev_full() else { return }; + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + let result = ucmd.args(args).set_stdout(sink).run(); + assert_eq!(result.code(), code, "{args:?}"); + result.stderr_is(expected); + } + + // The renames the failing run reported are still renames. + let Some(sink) = dev_full() else { return }; + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s1", "s2"]) + .set_stdout(sink) + .fails() + .code_is(1); + assert!(at.file_exists("z1")); + assert!(at.file_exists("z2")); + } + + /// The override is not a property of stdout. A diagnostic that cannot be + /// written discards the tally the same way - and, more importantly, does + /// not stop the run: the operand after the failing one is still renamed + /// and its report still reaches a stdout that works. + #[test] + fn test_a_stderr_that_cannot_be_written_overrides_the_tally_and_does_not_stop_the_run() { + let Some(sink) = dev_full() else { return }; + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + let result = ucmd + .args(&["-v", "s", "z", "s9", "s1"]) + .set_stderr(sink) + .run(); + assert_eq!(result.code(), 1); + result.stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); + } + + /// Help is written by clap rather than by us, but a failed write is still a + /// failed write: it is reported and it is worth 1, not a panic. + #[test] + fn test_a_clap_stream_that_cannot_be_written_is_reported() { + for args in [&["--help"], &["--version"]] { + let Some(sink) = dev_full() else { return }; + let mut ucmd = new_ucmd!(); + let result = ucmd.args(args).set_stdout(sink).run(); + assert_eq!(result.code(), 1, "{args:?}"); + result.stderr_is("rename: write error: No space left on device\n"); + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 95682dd3..8ee1a2ba 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -98,3 +98,7 @@ mod test_uuidgen; #[cfg(feature = "chcpu")] #[path = "by-util/test_chcpu.rs"] mod test_chcpu; + +#[cfg(feature = "rename")] +#[path = "by-util/test_rename.rs"] +mod test_rename;