Bob Hyman 09b3dab35d
Allow filesystem commands to access files with glob metachars in name (#10694)
(squashed version of #10557, clean commit history and review thread)

Fixes #10571, also potentially: #10364, #10211, #9558, #9310,


# Description
Changes processing of arguments to filesystem commands that are source
paths or globs.
Applies to `cp, cp-old, mv, rm, du` but not `ls` (because it uses a
different globbing interface) or `glob` (because it uses a different
globbing library).

The core of the change is to lookup the argument first as a file and
only glob if it is not. That way,
a path containing glob metacharacters can be referenced without glob
quoting, though it will have to be single quoted to avoid nushell
parsing.

Before: A file path that looks like a glob is not matched by the glob
specified as a (source) argument and takes some thinking about to
access. You might say the glob pattern shadows a file with the same
spelling.
```
> ls a*
╭───┬────────┬──────┬──────┬────────────────╮
│ # │  name  │ type │ size │    modified    │
├───┼────────┼──────┼──────┼────────────────┤
│ 0 │ a[bc]d │ file │  0 B │ 34 seconds ago │
│ 1 │ abd    │ file │  0 B │ now            │
│ 2 │ acd    │ file │  0 B │ now            │
╰───┴────────┴──────┴──────┴────────────────╯

> cp --verbose 'a[bc]d' dest
copied /home/bobhy/src/rust/work/r4/abd to /home/bobhy/src/rust/work/r4/dest/abd
copied /home/bobhy/src/rust/work/r4/acd to /home/bobhy/src/rust/work/r4/dest/acd

> ## Note -- a[bc]d *not* copied, and seemingly hard to access.
> cp --verbose 'a\[bc\]d' dest
Error:   × No matches found
   ╭─[entry #33:1:1]
 1 │ cp --verbose 'a\[bc\]d' dest
   ·              ─────┬────
   ·                   ╰── no matches found
   ╰────

> #.. but is accessible with enough glob quoting.
> cp --verbose 'a[[]bc[]]d' dest
copied /home/bobhy/src/rust/work/r4/a[bc]d to /home/bobhy/src/rust/work/r4/dest/a[bc]d
```
Before_2: if file has glob metachars but isn't a valid pattern, user
gets a confusing error:

```
> touch 'a[b'
> cp 'a[b' dest
Error:   × Pattern syntax error near position 30: invalid range pattern
   ╭─[entry #13:1:1]
 1 │ cp 'a[b' dest
   ·    ──┬──
   ·      ╰── invalid pattern
   ╰────
```

After: Args to cp, mv, etc. are tried first as literal files, and only
as globs if not found to be files.

```
> cp --verbose 'a[bc]d' dest
copied /home/bobhy/src/rust/work/r4/a[bc]d to /home/bobhy/src/rust/work/r4/dest/a[bc]d
> cp --verbose '[a][bc]d' dest
copied /home/bobhy/src/rust/work/r4/abd to /home/bobhy/src/rust/work/r4/dest/abd
copied /home/bobhy/src/rust/work/r4/acd to /home/bobhy/src/rust/work/r4/dest/acd
```
After_2: file with glob metachars but invalid pattern just works.
(though Windows does not allow file name to contain `*`.).

```
> cp --verbose 'a[b' dest
copied /home/bobhy/src/rust/work/r4/a[b to /home/bobhy/src/rust/work/r4/dest/a[b
```

So, with this fix, a file shadows a glob pattern with the same spelling.
If you have such a file and really want to use the glob pattern, you
will have to glob quote some of the characters in the pattern. I think
that's less confusing to the user: if ls shows a file with a weird name,
s/he'll still be able to copy, rename or delete it.

# User-Facing Changes
Could break some existing scripts. If user happened to have a file with
a globbish name but was using a glob pattern with the same spelling, the
new version will process the file and not expand the glob.

# Tests + Formatting
<!--
Don't forget to add tests that cover your changes.

Make sure you've run and fixed any issues with these commands:

- `cargo fmt --all -- --check` to check standard code formatting (`cargo
fmt --all` applies these changes)
- `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to
check that you're using the standard code style
- `cargo test --workspace` to check that all tests pass (on Windows make
sure to [enable developer
mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging))
- `cargo run -- -c "use std testing; testing run-tests --path
crates/nu-std"` to run the tests for the standard library

> **Note**
> from `nushell` you can also use the `toolkit` as follows
> ```bash
> use toolkit.nu # or use an `env_change` hook to activate it
automatically
> toolkit check pr
> ```
-->

# After Submitting
<!-- If your PR had any user-facing changes, update [the
documentation](https://github.com/nushell/nushell.github.io) after the
PR is merged, if necessary. This will help us keep the docs up to date.
-->

---------

Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com>
2023-10-18 13:31:15 -05:00

244 lines
8.4 KiB
Rust

use nu_cmd_base::arg_glob;
use nu_engine::{current_dir, CallExt};
use nu_glob::GlobResult;
use nu_protocol::{
ast::Call,
engine::{Command, EngineState, Stack},
Category, Example, PipelineData, ShellError, Signature, Spanned, SyntaxShape, Type,
};
use std::path::PathBuf;
use uu_cp::{BackupMode, UpdateMode};
// TODO: related to uucore::error::set_exit_code(EXIT_ERR)
// const EXIT_ERR: i32 = 1;
#[cfg(not(target_os = "windows"))]
const PATH_SEPARATOR: &str = "/";
#[cfg(target_os = "windows")]
const PATH_SEPARATOR: &str = "\\";
#[derive(Clone)]
pub struct UCp;
impl Command for UCp {
fn name(&self) -> &str {
"cp"
}
fn usage(&self) -> &str {
"Copy files using uutils/coreutils cp."
}
fn search_terms(&self) -> Vec<&str> {
vec!["copy", "file", "files", "coreutils"]
}
fn signature(&self) -> Signature {
Signature::build("cp")
.input_output_types(vec![(Type::Nothing, Type::Nothing)])
.switch("recursive", "copy directories recursively", Some('r'))
.switch("verbose", "explicitly state what is being done", Some('v'))
.switch(
"force",
"if an existing destination file cannot be opened, remove it and try
again (this option is ignored when the -n option is also used).
currently not implemented for windows",
Some('f'),
)
.switch("interactive", "ask before overwriting files", Some('i'))
.switch("progress", "display a progress bar", Some('p'))
.switch("no-clobber", "do not overwrite an existing file", Some('n'))
.switch("debug", "explain how a file is copied. Implies -v", None)
.rest("paths", SyntaxShape::Filepath, "Copy SRC file/s to DEST")
.allow_variants_without_examples(true)
.category(Category::FileSystem)
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
description: "Copy myfile to dir_b",
example: "cp myfile dir_b",
result: None,
},
Example {
description: "Recursively copy dir_a to dir_b",
example: "cp -r dir_a dir_b",
result: None,
},
Example {
description: "Recursively copy dir_a to dir_b, and print the feedbacks",
example: "cp -r -v dir_a dir_b",
result: None,
},
Example {
description: "Move many files into a directory",
example: "cp *.txt dir_a",
result: None,
},
]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let interactive = call.has_flag("interactive");
let force = call.has_flag("force");
let no_clobber = call.has_flag("no-clobber");
let progress = call.has_flag("progress");
let recursive = call.has_flag("recursive");
let verbose = call.has_flag("verbose");
let debug = call.has_flag("debug");
let overwrite = if no_clobber {
uu_cp::OverwriteMode::NoClobber
} else if interactive {
if force {
uu_cp::OverwriteMode::Interactive(uu_cp::ClobberMode::Force)
} else {
uu_cp::OverwriteMode::Interactive(uu_cp::ClobberMode::Standard)
}
} else if force {
uu_cp::OverwriteMode::Clobber(uu_cp::ClobberMode::Force)
} else {
uu_cp::OverwriteMode::Clobber(uu_cp::ClobberMode::Standard)
};
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
let reflink_mode = uu_cp::ReflinkMode::Auto;
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
let reflink_mode = uu_cp::ReflinkMode::Never;
let paths: Vec<Spanned<String>> = call.rest(engine_state, stack, 0)?;
let mut paths: Vec<Spanned<String>> = paths
.into_iter()
.map(|p| Spanned {
item: nu_utils::strip_ansi_string_unlikely(p.item),
span: p.span,
})
.collect();
if paths.is_empty() {
return Err(ShellError::GenericError(
"Missing file operand".into(),
"Missing file operand".into(),
Some(call.head),
Some("Please provide source and destination paths".into()),
Vec::new(),
));
}
if paths.len() == 1 {
return Err(ShellError::GenericError(
"Missing destination path".into(),
format!("Missing destination path operand after {}", paths[0].item),
Some(paths[0].span),
None,
Vec::new(),
));
}
let target = paths.pop().expect("Should not be reached?");
let target_path = PathBuf::from(&target.item);
if target.item.ends_with(PATH_SEPARATOR) && !target_path.is_dir() {
return Err(ShellError::GenericError(
"is not a directory".into(),
"is not a directory".into(),
Some(target.span),
None,
Vec::new(),
));
};
// paths now contains the sources
let cwd = current_dir(engine_state, stack)?;
let mut sources: Vec<PathBuf> = Vec::new();
for p in paths {
let exp_files = arg_glob(&p, &cwd)?.collect::<Vec<GlobResult>>();
if exp_files.is_empty() {
return Err(ShellError::FileNotFound(p.span));
};
let mut app_vals: Vec<PathBuf> = Vec::new();
for v in exp_files {
match v {
Ok(path) => {
if !recursive && path.is_dir() {
return Err(ShellError::GenericError(
"could_not_copy_directory".into(),
"resolves to a directory (not copied)".into(),
Some(p.span),
Some("Directories must be copied using \"--recursive\"".into()),
Vec::new(),
));
};
app_vals.push(path)
}
Err(e) => {
return Err(ShellError::ErrorExpandingGlob(
format!("error {} in path {}", e.error(), e.path().display()),
p.span,
));
}
}
}
sources.append(&mut app_vals);
}
let options = uu_cp::Options {
overwrite,
reflink_mode,
recursive,
debug,
verbose: verbose || debug,
dereference: !recursive,
progress_bar: progress,
attributes_only: false,
backup: BackupMode::NoBackup,
copy_contents: false,
cli_dereference: false,
copy_mode: uu_cp::CopyMode::Copy,
no_target_dir: false,
one_file_system: false,
parents: false,
sparse_mode: uu_cp::SparseMode::Auto,
strip_trailing_slashes: false,
attributes: uu_cp::Attributes::NONE,
backup_suffix: String::from("~"),
target_dir: None,
update: UpdateMode::ReplaceAll,
};
if let Err(error) = uu_cp::copy(&sources, &target_path, &options) {
match error {
// code should still be EXIT_ERR as does GNU cp
uu_cp::Error::NotAllFilesCopied => {}
_ => {
return Err(ShellError::GenericError(
format!("{}", error),
format!("{}", error),
None,
None,
Vec::new(),
))
}
};
// TODO: What should we do in place of set_exit_code?
// uucore::error::set_exit_code(EXIT_ERR);
}
Ok(PipelineData::empty())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_examples() {
use crate::test_examples;
test_examples(UCp {})
}
}