use nu_cmd_base::hook::eval_hook;
use nu_engine::{command_prelude::*, env_to_strings, get_eval_expression};
use nu_protocol::{
ast::{Expr, Expression},
did_you_mean,
process::ChildProcess,
ByteStream, NuGlob, OutDest,
};
use nu_system::ForegroundChild;
use nu_utils::IgnoreCaseExt;
use std::{
borrow::Cow,
io::Write,
path::{Path, PathBuf},
process::Stdio,
sync::{atomic::AtomicBool, Arc},
thread,
};
#[derive(Clone)]
pub struct External;
impl Command for External {
fn name(&self) -> &str {
"run-external"
}
fn usage(&self) -> &str {
"Runs external command."
}
fn signature(&self) -> nu_protocol::Signature {
Signature::build(self.name())
.input_output_types(vec![(Type::Any, Type::Any)])
.required("command", SyntaxShape::String, "External command to run.")
.rest("args", SyntaxShape::Any, "Arguments for external command.")
.category(Category::System)
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
let cwd = engine_state.cwd(Some(stack))?;
let name_expr = call
.positional_nth(0)
.ok_or_else(|| ShellError::MissingParameter {
param_name: "command".into(),
span: call.head,
})?;
let name = eval_argument(engine_state, stack, name_expr, false)?
.pop()
.expect("eval_argument returned zero-element vec")
.into_spanned(name_expr.span);
let executable = if cfg!(windows) && is_cmd_internal_command(&name.item) {
PathBuf::from("cmd.exe")
} else {
let expanded_name = if is_bare_string(name_expr) {
expand_tilde(&name.item)
} else {
name.item.clone()
};
let paths = nu_engine::env::path_str(engine_state, stack, call.head)?;
let Some(executable) = which(&expanded_name, &paths, &cwd) else {
return Err(command_not_found(
&name.item,
call.head,
engine_state,
stack,
));
};
executable
};
let mut command = std::process::Command::new(executable);
command.current_dir(cwd);
let envs = env_to_strings(engine_state, stack)?;
command.env_clear();
command.envs(envs);
let args = eval_arguments_from_call(engine_state, stack, call)?;
#[cfg(windows)]
if is_cmd_internal_command(&name.item) {
use std::os::windows::process::CommandExt;
command.args(["/D", "/C", &name.item]);
for arg in &args {
command.raw_arg(escape_cmd_argument(arg)?.as_ref());
}
} else {
command.args(args.into_iter().map(|s| s.item));
}
#[cfg(not(windows))]
command.args(args.into_iter().map(|s| s.item));
let stdout = stack.stdout();
let stderr = stack.stderr();
let merged_stream = if matches!(stdout, OutDest::Pipe) && matches!(stderr, OutDest::Pipe) {
let (reader, writer) = os_pipe::pipe()?;
command.stdout(writer.try_clone()?);
command.stderr(writer);
Some(reader)
} else {
command.stdout(Stdio::try_from(stdout)?);
command.stderr(Stdio::try_from(stderr)?);
None
};
let data_to_copy_into_stdin = match input {
PipelineData::ByteStream(stream, metadata) => match stream.into_stdio() {
Ok(stdin) => {
command.stdin(stdin);
None
}
Err(stream) => {
command.stdin(Stdio::piped());
Some(PipelineData::ByteStream(stream, metadata))
}
},
PipelineData::Empty => {
command.stdin(Stdio::inherit());
None
}
value => {
command.stdin(Stdio::piped());
Some(value)
}
};
log::trace!("run-external spawning: {command:?}");
#[cfg(windows)]
let mut child = ForegroundChild::spawn(command)?;
#[cfg(unix)]
let mut child = ForegroundChild::spawn(
command,
engine_state.is_interactive,
&engine_state.pipeline_externals_state,
)?;
if let Some(data) = data_to_copy_into_stdin {
let stdin = child.as_mut().stdin.take().expect("stdin is piped");
let engine_state = engine_state.clone();
let stack = stack.clone();
thread::Builder::new()
.name("external stdin worker".into())
.spawn(move || {
let _ = write_pipeline_data(engine_state, stack, data, stdin);
})
.err_span(call.head)?;
}
let child = ChildProcess::new(
child,
merged_stream,
matches!(stderr, OutDest::Pipe),
call.head,
)?;
Ok(PipelineData::ByteStream(
ByteStream::child(child, call.head),
None,
))
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
description: "Run an external command",
example: r#"run-external "echo" "-n" "hello""#,
result: None,
},
Example {
description: "Redirect stdout from an external command into the pipeline",
example: r#"run-external "echo" "-n" "hello" | split chars"#,
result: None,
},
Example {
description: "Redirect stderr from an external command into the pipeline",
example: r#"run-external "nu" "-c" "print -e hello" e>| split chars"#,
result: None,
},
]
}
}
fn remove_quotes(s: &str) -> &str {
let quoted_by_double_quotes = s.len() >= 2 && s.starts_with('"') && s.ends_with('"');
let quoted_by_single_quotes = s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'');
let quoted_by_backticks = s.len() >= 2 && s.starts_with('`') && s.ends_with('`');
if quoted_by_double_quotes || quoted_by_single_quotes || quoted_by_backticks {
&s[1..s.len() - 1]
} else {
s
}
}
pub fn eval_arguments_from_call(
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
) -> Result<Vec<Spanned<String>>, ShellError> {
let ctrlc = &engine_state.ctrlc;
let cwd = engine_state.cwd(Some(stack))?;
let mut args: Vec<Spanned<String>> = vec![];
for (expr, spread) in call.rest_iter(1) {
if is_bare_string(expr) {
for arg in eval_argument(engine_state, stack, expr, spread)? {
let tilde_expanded = expand_tilde(&arg);
for glob_expanded in expand_glob(&tilde_expanded, &cwd, expr.span, ctrlc)? {
let inner_quotes_removed = remove_inner_quotes(&glob_expanded);
args.push(inner_quotes_removed.into_owned().into_spanned(expr.span));
}
}
} else {
for arg in eval_argument(engine_state, stack, expr, spread)? {
args.push(arg.into_spanned(expr.span));
}
}
}
Ok(args)
}
fn eval_argument(
engine_state: &EngineState,
stack: &mut Stack,
expr: &Expression,
spread: bool,
) -> Result<Vec<String>, ShellError> {
let mut expr = expr.clone();
if let Expr::String(s) = &expr.expr {
expr.expr = Expr::String(remove_quotes(s).into());
}
let eval = get_eval_expression(engine_state);
match eval(engine_state, stack, &expr)? {
Value::List { vals, .. } => {
if spread {
vals.into_iter()
.map(|val| val.coerce_into_string())
.collect()
} else {
Err(ShellError::CannotPassListToExternal {
arg: String::from_utf8_lossy(engine_state.get_span_contents(expr.span)).into(),
span: expr.span,
})
}
}
value => {
if spread {
Err(ShellError::CannotSpreadAsList { span: expr.span })
} else {
Ok(vec![value.coerce_into_string()?])
}
}
}
}
fn is_bare_string(expr: &Expression) -> bool {
let Expr::String(s) = &expr.expr else {
return false;
};
let quoted_by_double_quotes = s.len() >= 2 && s.starts_with('"') && s.ends_with('"');
let quoted_by_single_quotes = s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'');
!quoted_by_double_quotes && !quoted_by_single_quotes
}
fn expand_tilde(arg: &str) -> String {
nu_path::expand_tilde(arg).to_string_lossy().to_string()
}
fn expand_glob(
arg: &str,
cwd: &Path,
span: Span,
interrupt: &Option<Arc<AtomicBool>>,
) -> Result<Vec<String>, ShellError> {
const GLOB_CHARS: &[char] = &['*', '?', '['];
if !arg.contains(GLOB_CHARS) {
return Ok(vec![arg.into()]);
}
let glob = NuGlob::Expand(arg.to_owned()).into_spanned(span);
let Ok((_prefix, paths)) = nu_engine::glob_from(&glob, cwd, span, None) else {
return Ok(vec![arg.into()]);
};
let relative_to_dot = Path::new(arg).starts_with(".");
let paths = paths
.flat_map(|path_result| match path_result {
Ok(path) => Some(path),
Err(err) => {
log::warn!("Error in run_external::expand_glob(): {}", err);
None
}
})
.map(|path| {
path.strip_prefix(cwd)
.map(|path| path.to_owned())
.unwrap_or(path)
})
.map(|path| {
if relative_to_dot && path.is_relative() {
Path::new(".").join(path)
} else {
path
}
})
.map(|path| path.to_string_lossy().into_owned())
.map(|path| {
if !nu_utils::ctrl_c::was_pressed(interrupt) {
Ok(path)
} else {
Err(ShellError::InterruptedByUser { span: Some(span) })
}
})
.collect::<Result<Vec<String>, ShellError>>()?;
if !paths.is_empty() {
Ok(paths)
} else {
Ok(vec![arg.into()])
}
}
fn remove_inner_quotes(arg: &str) -> Cow<'_, str> {
if !arg.starts_with("--") {
return Cow::Borrowed(arg);
}
let Some((option, value)) = arg.split_once('=') else {
return Cow::Borrowed(arg);
};
if option.contains('"') || option.contains('\'') || option.contains('`') {
return Cow::Borrowed(arg);
}
let value = remove_quotes(value);
Cow::Owned(format!("{option}={value}"))
}
fn write_pipeline_data(
mut engine_state: EngineState,
mut stack: Stack,
data: PipelineData,
mut writer: impl Write,
) -> Result<(), ShellError> {
if let PipelineData::ByteStream(stream, ..) = data {
stream.write_to(writer)?;
} else if let PipelineData::Value(Value::Binary { val, .. }, ..) = data {
writer.write_all(&val)?;
} else {
stack.start_capture();
Arc::make_mut(&mut engine_state.config).use_ansi_coloring = false;
let output =
crate::Table.run(&engine_state, &mut stack, &Call::new(Span::unknown()), data)?;
for value in output {
let bytes = value.coerce_into_binary()?;
writer.write_all(&bytes)?;
}
}
Ok(())
}
pub fn command_not_found(
name: &str,
span: Span,
engine_state: &EngineState,
stack: &mut Stack,
) -> ShellError {
if let Some(hook) = &engine_state.config.hooks.command_not_found {
let mut stack = stack.start_capture();
let canary = "ENTERED_COMMAND_NOT_FOUND";
if stack.has_env_var(engine_state, canary) {
return ShellError::ExternalCommand {
label: format!(
"Command {name} not found while running the `command_not_found` hook"
),
help: "Make sure the `command_not_found` hook itself does not use unknown commands"
.into(),
span,
};
}
stack.add_env_var(canary.into(), Value::bool(true, Span::unknown()));
let output = eval_hook(
&mut engine_state.clone(),
&mut stack,
None,
vec![("cmd_name".into(), Value::string(name, span))],
hook,
"command_not_found",
);
stack.remove_env_var(engine_state, canary);
match output {
Ok(PipelineData::Value(Value::String { val, .. }, ..)) => {
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: val,
span,
};
}
Err(err) => {
return err;
}
_ => {
}
}
}
if let Some(replacement) = crate::removed_commands().get(&name.to_lowercase()) {
return ShellError::RemovedCommand {
removed: name.to_lowercase(),
replacement: replacement.clone(),
span,
};
}
if let Some(module) = engine_state.which_module_has_decl(name.as_bytes(), &[]) {
let module = String::from_utf8_lossy(module);
let full_name = format!("{module} {name}");
if engine_state.find_decl(full_name.as_bytes(), &[]).is_some() {
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: format!("Did you mean `{full_name}`?"),
span,
};
} else {
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: format!("A command with that name exists in module `{module}`. Try importing it with `use`"),
span,
};
}
}
let signatures = engine_state.get_signatures(false);
if let Some(sig) = signatures.iter().find(|sig| {
sig.search_terms
.iter()
.any(|term| term.to_folded_case() == name.to_folded_case())
}) {
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: format!("Did you mean `{}`?", sig.name),
span,
};
}
if let Some(cmd) = did_you_mean(signatures.iter().map(|sig| &sig.name), name) {
if cmd == name {
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: "There is a built-in command with the same name".into(),
span,
};
}
return ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: format!("Did you mean `{cmd}`?"),
span,
};
}
ShellError::ExternalCommand {
label: format!("Command `{name}` not found"),
help: format!("`{name}` is neither a Nushell built-in or a known external command"),
span,
}
}
pub fn which(name: &str, paths: &str, cwd: &Path) -> Option<PathBuf> {
#[cfg(windows)]
let paths = format!("{};{}", cwd.display(), paths);
which::which_in(name, Some(paths), cwd).ok()
}
fn is_cmd_internal_command(name: &str) -> bool {
const COMMANDS: &[&str] = &[
"ASSOC", "CLS", "ECHO", "FTYPE", "MKLINK", "PAUSE", "START", "VER", "VOL",
];
COMMANDS.iter().any(|cmd| cmd.eq_ignore_ascii_case(name))
}
#[cfg(windows)]
fn has_cmd_special_character(s: &str) -> bool {
const SPECIAL_CHARS: &[char] = &['<', '>', '&', '|', '^'];
SPECIAL_CHARS.iter().any(|c| s.contains(*c))
}
#[cfg(windows)]
fn escape_cmd_argument(arg: &Spanned<String>) -> Result<Cow<'_, str>, ShellError> {
let Spanned { item: arg, span } = arg;
if arg.contains(['\r', '\n', '%']) {
Err(ShellError::ExternalCommand {
label:
"Arguments to CMD internal commands cannot contain new lines or percent signs '%'"
.into(),
help: "some characters currently cannot be securely escaped".into(),
span: *span,
})
} else if arg.contains('"') {
if arg.chars().filter(|c| *c == '"').count() == 2
&& arg.starts_with('"')
&& arg.ends_with('"')
{
Ok(Cow::Borrowed(arg))
} else {
Err(ShellError::ExternalCommand {
label: "Arguments to CMD internal commands cannot contain embedded double quotes"
.into(),
help: "this case currently cannot be securely handled".into(),
span: *span,
})
}
} else if arg.contains(' ') || has_cmd_special_character(arg) {
Ok(Cow::Owned(format!("\"{arg}\"")))
} else {
Ok(Cow::Borrowed(arg))
}
}
#[cfg(test)]
mod test {
use super::*;
use nu_protocol::ast::ListItem;
use nu_test_support::{fs::Stub, playground::Playground};
#[test]
fn test_remove_quotes() {
assert_eq!(remove_quotes(r#""#), r#""#);
assert_eq!(remove_quotes(r#"'"#), r#"'"#);
assert_eq!(remove_quotes(r#"''"#), r#""#);
assert_eq!(remove_quotes(r#""foo""#), r#"foo"#);
assert_eq!(remove_quotes(r#"`foo '"' bar`"#), r#"foo '"' bar"#);
assert_eq!(remove_quotes(r#"'foo' bar"#), r#"'foo' bar"#);
assert_eq!(remove_quotes(r#"r#'foo'#"#), r#"r#'foo'#"#);
}
#[test]
fn test_eval_argument() {
fn expression(expr: Expr) -> Expression {
Expression {
expr,
span: Span::unknown(),
ty: Type::Any,
custom_completion: None,
}
}
fn eval(expr: Expr, spread: bool) -> Result<Vec<String>, ShellError> {
let engine_state = EngineState::new();
let mut stack = Stack::new();
eval_argument(&engine_state, &mut stack, &expression(expr), spread)
}
let actual = eval(Expr::String("".into()), false).unwrap();
let expected = &[""];
assert_eq!(actual, expected);
let actual = eval(Expr::String("'foo'".into()), false).unwrap();
let expected = &["foo"];
assert_eq!(actual, expected);
let actual = eval(Expr::RawString("'foo'".into()), false).unwrap();
let expected = &["'foo'"];
assert_eq!(actual, expected);
let actual = eval(Expr::List(vec![]), true).unwrap();
let expected: &[&str] = &[];
assert_eq!(actual, expected);
let actual = eval(
Expr::List(vec![
ListItem::Item(expression(Expr::String("'foo'".into()))),
ListItem::Item(expression(Expr::String("bar".into()))),
]),
true,
)
.unwrap();
let expected = &["'foo'", "bar"];
assert_eq!(actual, expected);
eval(Expr::String("".into()), true).unwrap_err();
eval(Expr::List(vec![]), false).unwrap_err();
}
#[test]
fn test_expand_glob() {
Playground::setup("test_expand_glob", |dirs, play| {
play.with_files(&[Stub::EmptyFile("a.txt"), Stub::EmptyFile("b.txt")]);
let cwd = dirs.test();
let actual = expand_glob("*.txt", cwd, Span::unknown(), &None).unwrap();
let expected = &["a.txt", "b.txt"];
assert_eq!(actual, expected);
let actual = expand_glob("./*.txt", cwd, Span::unknown(), &None).unwrap();
let expected = vec![
Path::new(".").join("a.txt").to_string_lossy().into_owned(),
Path::new(".").join("b.txt").to_string_lossy().into_owned(),
];
assert_eq!(actual, expected);
let actual = expand_glob("'*.txt'", cwd, Span::unknown(), &None).unwrap();
let expected = &["'*.txt'"];
assert_eq!(actual, expected);
let actual = expand_glob(".", cwd, Span::unknown(), &None).unwrap();
let expected = &["."];
assert_eq!(actual, expected);
let actual = expand_glob("./a.txt", cwd, Span::unknown(), &None).unwrap();
let expected = &["./a.txt"];
assert_eq!(actual, expected);
let actual = expand_glob("[*.txt", cwd, Span::unknown(), &None).unwrap();
let expected = &["[*.txt"];
assert_eq!(actual, expected);
})
}
#[test]
fn test_remove_inner_quotes() {
let actual = remove_inner_quotes(r#"--option=value"#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option="value""#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option='value'"#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option "value""#);
let expected = r#"--option "value""#;
assert_eq!(actual, expected);
}
#[test]
fn test_write_pipeline_data() {
let engine_state = EngineState::new();
let stack = Stack::new();
let mut buf = vec![];
let input = PipelineData::Empty;
write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
assert_eq!(buf, b"");
let mut buf = vec![];
let input = PipelineData::Value(Value::string("foo", Span::unknown()), None);
write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
assert_eq!(buf, b"foo");
let mut buf = vec![];
let input = PipelineData::Value(Value::binary(b"foo", Span::unknown()), None);
write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
assert_eq!(buf, b"foo");
let mut buf = vec![];
let input = PipelineData::ByteStream(
ByteStream::read(
b"foo".as_slice(),
Span::unknown(),
None,
ByteStreamType::Unknown,
),
None,
);
write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
assert_eq!(buf, b"foo");
}
}