Files
nushell/crates/nu-command/src/filesystem/ucp.rs
Artemiy 387c5462e9 Add file attribute handling flag to cp (#11491)
# Description
This PR adds possibility to preserve/strip attributes from files when
using `cp` (via uu_cp::Attributes). To achieve this a single `--preserve
<list of attributes>` flag is added. This is different from how
coreutils and uutils cp function, but I believe this is better for
nushell.

Coreutils cp has three options `-p`, `--preserve` and `--no-presevce`.
The logic of these two options is not straightforward. As far as I
understand it is:
1. By default only mode attributes are preserved
2. `--preserve` option adds to default preserved attributes specified
ones (e.g. `--preserve=xattr,timestamps` will preserve mode, timestamps
and xattr)
3. `-p` is the same as `--preserve=mode,ownership,timestamps`
4. `--no-preserve` option rejects specified attributes (having priority
over `--preserve`)

However (in my opinion) the `--no-preserve` option is not needed,
because its only use seems to be rejecting attributes preserved by
default. But there is no need for this in nushell, because `--preserve`
can be specified with empty list as argument (whereas coreutils cp will
display a `cp: ambiguous argument ‘’ for ‘--preserve’` error if
`--preserve` is used with empty string as argument).

So to simplify this command is suggest (and implemented) only the
`--preserve` with the following logic:
1. By default mode attribute is preserved (as in coreutils cp)
2. `--preserve [ ... ]` will overwrite default with whatever is
specified in list (empty list meaning preserve nothing)

This way cp without `--preserve` behaves the same as coreutils `cp`, but
instead of using combinations of `--preserve` and `--no-preserve` one
needs to use `--preserve [ ... ]` with all attributes specified
explicitly. This seems more user-friendly to me as it does not require
remembering what the attributes preserved by default are and rejecting
them manually. However I see the possible problem with behavior
different from coreutils implementation, so some feedback is
apprecieated!

# User-Facing Changes
Users can now preserve or reject file attributes when using `cp`

# Tests + Formatting
Added tests manipulating mode and timestamps attributes.
2024-01-12 12:02:55 -06:00

373 lines
13 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, Value,
};
use std::path::PathBuf;
use uu_cp::{BackupMode, CopyMode, 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(
"update",
"copy only when the SOURCE file is newer than the destination file or when the destination file is missing",
Some('u')
)
.switch("progress", "display a progress bar", Some('p'))
.switch("no-clobber", "do not overwrite an existing file", Some('n'))
.named(
"preserve",
SyntaxShape::List(Box::new(SyntaxShape::String)),
"preserve only the specified attributes (empty list means no attributes preserved)
if not specified only mode is preserved
possible values: mode, ownership (unix only), timestamps, context, link, links, xattr",
None
)
.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,
},
Example {
description: "Copy only if source file is newer than target file",
example: "cp -u a b",
result: None,
},
Example {
description: "Copy file preserving mode and timestamps attributes",
example: "cp --preserve [ mode timestamps ] a b",
result: None,
},
Example {
description: "Copy file erasing all attributes",
example: "cp --preserve [] a b",
result: None,
},
]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let interactive = call.has_flag(engine_state, stack, "interactive")?;
let (update, copy_mode) = if call.has_flag(engine_state, stack, "update")? {
(UpdateMode::ReplaceIfOlder, CopyMode::Update)
} else {
(UpdateMode::ReplaceAll, CopyMode::Copy)
};
let force = call.has_flag(engine_state, stack, "force")?;
let no_clobber = call.has_flag(engine_state, stack, "no-clobber")?;
let progress = call.has_flag(engine_state, stack, "progress")?;
let recursive = call.has_flag(engine_state, stack, "recursive")?;
let verbose = call.has_flag(engine_state, stack, "verbose")?;
let preserve: Option<Value> = call.get_flag(engine_state, stack, "preserve")?;
let debug = call.has_flag(engine_state, stack, "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 {
error: "Missing file operand".into(),
msg: "Missing file operand".into(),
span: Some(call.head),
help: Some("Please provide source and destination paths".into()),
inner: vec![],
});
}
if paths.len() == 1 {
return Err(ShellError::GenericError {
error: "Missing destination path".into(),
msg: format!("Missing destination path operand after {}", paths[0].item),
span: Some(paths[0].span),
help: None,
inner: vec![],
});
}
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 {
error: "is not a directory".into(),
msg: "is not a directory".into(),
span: Some(target.span),
help: None,
inner: vec![],
});
};
// 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 { span: 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 {
error: "could_not_copy_directory".into(),
msg: "resolves to a directory (not copied)".into(),
span: Some(p.span),
help: Some(
"Directories must be copied using \"--recursive\"".into(),
),
inner: vec![],
});
};
app_vals.push(path)
}
Err(e) => {
return Err(ShellError::ErrorExpandingGlob {
msg: format!("error {} in path {}", e.error(), e.path().display()),
span: p.span,
});
}
}
}
sources.append(&mut app_vals);
}
// Make sure to send absolute paths to avoid uu_cp looking for cwd in std::env which is not
// supported in Nushell
for src in sources.iter_mut() {
if !src.is_absolute() {
*src = nu_path::expand_path_with(&src, &cwd);
}
}
let target_path = nu_path::expand_path_with(&target_path, &cwd);
let attributes = make_attributes(preserve)?;
let options = uu_cp::Options {
overwrite,
reflink_mode,
recursive,
debug,
attributes,
verbose: verbose || debug,
dereference: !recursive,
progress_bar: progress,
attributes_only: false,
backup: BackupMode::NoBackup,
copy_contents: false,
cli_dereference: false,
copy_mode,
no_target_dir: false,
one_file_system: false,
parents: false,
sparse_mode: uu_cp::SparseMode::Auto,
strip_trailing_slashes: false,
backup_suffix: String::from("~"),
target_dir: None,
update,
};
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 {
error: format!("{}", error),
msg: format!("{}", error),
span: None,
help: None,
inner: vec![],
})
}
};
// TODO: What should we do in place of set_exit_code?
// uucore::error::set_exit_code(EXIT_ERR);
}
Ok(PipelineData::empty())
}
}
const ATTR_UNSET: uu_cp::Preserve = uu_cp::Preserve::No { explicit: true };
const ATTR_SET: uu_cp::Preserve = uu_cp::Preserve::Yes { required: true };
fn make_attributes(preserve: Option<Value>) -> Result<uu_cp::Attributes, ShellError> {
if let Some(preserve) = preserve {
let mut attributes = uu_cp::Attributes {
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
ownership: ATTR_UNSET,
mode: ATTR_UNSET,
timestamps: ATTR_UNSET,
context: ATTR_UNSET,
links: ATTR_UNSET,
xattr: ATTR_UNSET,
};
parse_and_set_attributes_list(&preserve, &mut attributes)?;
Ok(attributes)
} else {
// By default preseerve only mode
Ok(uu_cp::Attributes {
mode: ATTR_SET,
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
ownership: ATTR_UNSET,
timestamps: ATTR_UNSET,
context: ATTR_UNSET,
links: ATTR_UNSET,
xattr: ATTR_UNSET,
})
}
}
fn parse_and_set_attributes_list(
list: &Value,
attribute: &mut uu_cp::Attributes,
) -> Result<(), ShellError> {
match list {
Value::List { vals, .. } => {
for val in vals {
parse_and_set_attribute(val, attribute)?;
}
Ok(())
}
_ => Err(ShellError::IncompatibleParametersSingle {
msg: "--preserve flag expects a list of strings".into(),
span: list.span(),
}),
}
}
fn parse_and_set_attribute(
value: &Value,
attribute: &mut uu_cp::Attributes,
) -> Result<(), ShellError> {
match value {
Value::String { val, .. } => {
let attribute = match val.as_str() {
"mode" => &mut attribute.mode,
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
"ownership" => &mut attribute.ownership,
"timestamps" => &mut attribute.timestamps,
"context" => &mut attribute.context,
"link" | "links" => &mut attribute.links,
"xattr" => &mut attribute.xattr,
_ => {
return Err(ShellError::IncompatibleParametersSingle {
msg: format!("--preserve flag got an unexpected attribute \"{}\"", val),
span: value.span(),
});
}
};
*attribute = ATTR_SET;
Ok(())
}
_ => Err(ShellError::IncompatibleParametersSingle {
msg: "--preserve flag expects a list of strings".into(),
span: value.span(),
}),
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_examples() {
use crate::test_examples;
test_examples(UCp {})
}
}