Skip to main content

nu_command/system/
run_external.rs

1use itertools::Itertools;
2use nu_cmd_base::hook::eval_hook;
3use nu_engine::{command_prelude::*, env_to_strings};
4use nu_path::{AbsolutePath, dots::expand_ndots_safe, expand_tilde};
5use nu_protocol::{
6    ByteStream, NuGlob, OutDest, Signals, UseAnsiColoring, did_you_mean,
7    process::{ChildProcess, PostWaitCallback},
8    shell_error::io::IoError,
9};
10use nu_system::{ForegroundChild, kill_by_pid, prepare_background_command};
11use nu_utils::IgnoreCaseExt;
12use pathdiff::diff_paths;
13#[cfg(windows)]
14use std::os::windows::process::CommandExt;
15use std::{
16    borrow::Cow,
17    ffi::{OsStr, OsString},
18    io::Write,
19    path::{Path, PathBuf},
20    process::Stdio,
21    sync::Arc,
22    thread,
23};
24
25#[derive(Clone)]
26pub struct External;
27
28impl Command for External {
29    fn name(&self) -> &str {
30        "run-external"
31    }
32
33    fn description(&self) -> &str {
34        "Runs external command."
35    }
36
37    fn extra_description(&self) -> &str {
38        "All externals are run with this command, whether you call it directly with `run-external external` or use `external` or `^external`.
39If you create a custom command with this name, that will be used instead."
40    }
41
42    fn signature(&self) -> nu_protocol::Signature {
43        Signature::build(self.name())
44            .input_output_types(vec![(Type::Any, Type::Any)])
45            .rest(
46                "command",
47                SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::Any]),
48                "External command to run, with arguments.",
49            )
50            .category(Category::System)
51    }
52
53    fn run(
54        &self,
55        engine_state: &EngineState,
56        stack: &mut Stack,
57        call: &Call,
58        input: PipelineData,
59    ) -> Result<PipelineData, ShellError> {
60        let cwd = engine_state.cwd(Some(stack))?;
61        let rest = call.rest::<Value>(engine_state, stack, 0)?;
62        let name_args = rest.split_first().map(|(x, y)| (x, y.to_vec()));
63
64        let Some((name, mut call_args)) = name_args else {
65            return Err(ShellError::MissingParameter {
66                param_name: "no command given".into(),
67                span: call.head,
68            });
69        };
70
71        let name_str: Cow<str> = match &name {
72            Value::Glob { val, .. } => Cow::Borrowed(val),
73            Value::String { val, .. } => Cow::Borrowed(val),
74            Value::List { vals, .. } => {
75                let Some((first, args)) = vals.split_first() else {
76                    return Err(ShellError::MissingParameter {
77                        param_name: "external command given as list empty".into(),
78                        span: call.head,
79                    });
80                };
81                // Prepend elements in command list to the list of arguments except the first
82                call_args.splice(..0, args.to_vec());
83                first.coerce_str()?
84            }
85            _ => Cow::Owned(name.clone().coerce_into_string()?),
86        };
87
88        let expanded_name = match &name {
89            // Expand tilde and ndots on the name if it's a bare string / glob (#13000)
90            Value::Glob { no_expand, .. } if !*no_expand => {
91                expand_ndots_safe(expand_tilde(&*name_str))
92            }
93            _ => Path::new(&*name_str).to_owned(),
94        };
95
96        let paths = nu_engine::env::path_str(engine_state, stack, call.head).unwrap_or_default();
97
98        // On Windows, the user could have run the cmd.exe built-in commands "assoc"
99        // and "ftype" to create a file association for an arbitrary file extension.
100        // They then could have added that extension to the PATHEXT environment variable.
101        // For example, a nushell script with extension ".nu" can be set up with
102        // "assoc .nu=nuscript" and "ftype nuscript=C:\path\to\nu.exe '%1' %*",
103        // and then by adding ".NU" to PATHEXT. In this case we use the which command,
104        // which will find the executable with or without the extension. If "which"
105        // returns true, that means that we've found the script and we believe the
106        // user wants to use the windows association to run the script. The only
107        // easy way to do this is to run cmd.exe with the script as an argument.
108        // File extensions of .COM, .EXE, .BAT, and .CMD are ignored because Windows
109        // can run those files directly. PS1 files are also ignored and that
110        // extension is handled in a separate block below.
111        let pathext_script_in_windows = if cfg!(windows) {
112            if let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) {
113                let ext = executable
114                    .extension()
115                    .unwrap_or_default()
116                    .to_string_lossy()
117                    .to_uppercase();
118
119                !["COM", "EXE", "BAT", "CMD", "PS1"]
120                    .iter()
121                    .any(|c| *c == ext)
122            } else {
123                false
124            }
125        } else {
126            false
127        };
128
129        // let's make sure it's a .ps1 script, but only on Windows
130        let (potential_powershell_script, path_to_ps1_executable) = if cfg!(windows) {
131            if let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) {
132                let ext = executable
133                    .extension()
134                    .unwrap_or_default()
135                    .to_string_lossy()
136                    .to_uppercase();
137                (ext == "PS1", Some(executable))
138            } else {
139                (false, None)
140            }
141        } else {
142            (false, None)
143        };
144
145        // Find the absolute path to the executable. On Windows, set the
146        // executable to "cmd.exe" if it's a CMD internal command. If the
147        // command is not found, display a helpful error message.
148        let executable = if cfg!(windows)
149            && (is_cmd_internal_command(&name_str) || pathext_script_in_windows)
150        {
151            PathBuf::from("cmd.exe")
152        } else if cfg!(windows) && potential_powershell_script && path_to_ps1_executable.is_some() {
153            // If we're on Windows and we're trying to run a PowerShell script, we'll use
154            // `powershell.exe` to run it. We shouldn't have to check for powershell.exe because
155            // it's automatically installed on all modern windows systems.
156            PathBuf::from("powershell.exe")
157        } else {
158            // Determine the PATH to be used and then use `which` to find it - though this has no
159            // effect if it's an absolute path already
160            let Some(executable) = which(&expanded_name, &paths, cwd.as_ref()) else {
161                return Err(command_not_found(
162                    &name_str,
163                    call.head,
164                    engine_state,
165                    stack,
166                    &cwd,
167                ));
168            };
169            executable
170        };
171
172        // Create the command.
173        let mut command = std::process::Command::new(&executable);
174
175        // Configure PWD.
176        command.current_dir(cwd);
177
178        // Configure environment variables.
179        let envs = env_to_strings(engine_state, stack)?;
180        command.env_clear();
181        command.envs(envs);
182
183        // Configure args.
184        let args = eval_external_arguments(engine_state, stack, call_args)?;
185        #[cfg(windows)]
186        if is_cmd_internal_command(&name_str) || pathext_script_in_windows {
187            // The /D flag disables execution of AutoRun commands from registry.
188            // The /C flag followed by a command name instructs CMD to execute
189            // that command and quit.
190            command.args(["/D", "/C", &expanded_name.to_string_lossy()]);
191            for arg in &args {
192                command.raw_arg(escape_cmd_argument(arg)?);
193            }
194        } else if potential_powershell_script {
195            command.args([
196                "-File",
197                &path_to_ps1_executable.unwrap_or_default().to_string_lossy(),
198            ]);
199            command.args(args.into_iter().map(|s| s.item));
200        } else {
201            command.args(args.into_iter().map(|s| s.item));
202        }
203        #[cfg(not(windows))]
204        command.args(args.into_iter().map(|s| s.item));
205
206        // Configure stdout and stderr. If both are set to `OutDest::Pipe`,
207        // we'll set up a pipe that merges two streams into one.
208        //
209        // Do **not** force-pipe bare external stdout for interactive `$ans` capture.
210        // Replacing `Print`/`Inherit` with a pipe steals the TTY from full-screen /
211        // interactive tools (`nvim`, `btm`, etc.): they hang or misbehave because
212        // `isatty(stdout)` is false and `store_byte_stream_prefix` blocks reading the
213        // pipe. Bare externals keep the terminal; `$ans.last` only receives external
214        // bytes when stdout is already redirected into the pipeline (e.g. `^cmd | collect`).
215        let stdout = stack.stdout();
216        let stderr = stack.stderr();
217        let merged_stream = if matches!(stdout, OutDest::Pipe) && matches!(stderr, OutDest::Pipe) {
218            let (reader, writer) =
219                os_pipe::pipe().map_err(|err| IoError::new(err, call.head, None))?;
220            command.stdout(
221                writer
222                    .try_clone()
223                    .map_err(|err| IoError::new(err, call.head, None))?,
224            );
225            command.stderr(writer);
226            Some(reader)
227        } else {
228            if engine_state.is_background_job()
229                && matches!(stdout, OutDest::Inherit | OutDest::Print)
230            {
231                command.stdout(Stdio::null());
232            } else {
233                command.stdout(
234                    Stdio::try_from(stdout).map_err(|err| IoError::new(err, call.head, None))?,
235                );
236            }
237
238            if engine_state.is_background_job()
239                && matches!(stderr, OutDest::Inherit | OutDest::Print)
240            {
241                command.stderr(Stdio::null());
242            } else {
243                command.stderr(
244                    Stdio::try_from(stderr).map_err(|err| IoError::new(err, call.head, None))?,
245                );
246            }
247
248            None
249        };
250
251        // Configure stdin. We'll try connecting input to the child process
252        // directly. If that's not possible, we'll set up a pipe and spawn a
253        // thread to copy data into the child process.
254        let data_to_copy_into_stdin = match input {
255            PipelineData::ByteStream(stream, metadata) => match stream.into_stdio() {
256                Ok(stdin) => {
257                    command.stdin(stdin);
258                    None
259                }
260                Err(stream) => {
261                    command.stdin(Stdio::piped());
262                    Some(PipelineData::byte_stream(stream, metadata))
263                }
264            },
265            PipelineData::Empty => {
266                // MCP and background completions must not inherit the live terminal.
267                if engine_state.is_mcp || stack.suppress_stdin {
268                    command.stdin(Stdio::null());
269                } else {
270                    command.stdin(Stdio::inherit());
271                }
272                None
273            }
274            value => {
275                command.stdin(Stdio::piped());
276                Some(value)
277            }
278        };
279
280        // Detach even when stdin is a pipe of candidates (`ls | fzf`). Otherwise
281        // the child keeps `/dev/tty` and races reedline from a completion thread.
282        if engine_state.is_mcp || stack.suppress_stdin {
283            prepare_background_command(&mut command);
284        }
285
286        // Log the command we're about to run in case it's useful for debugging purposes.
287        log::trace!("run-external spawning: {command:?}");
288
289        // Spawn the child process. On Unix, also put the child process to
290        // foreground if we're in an interactive session.
291        #[cfg(windows)]
292        let child = ForegroundChild::spawn(command);
293        #[cfg(unix)]
294        let child = ForegroundChild::spawn(
295            command,
296            // `suppress_stdin` children are already detached; do not also take
297            // the foreground pgrp from the completion thread.
298            engine_state.is_interactive && !stack.suppress_stdin,
299            engine_state.is_background_job(),
300            &engine_state.pipeline_externals_state,
301        );
302
303        let mut child = child.map_err(|err| {
304            let context = format!("Could not spawn foreground child: {err}");
305            IoError::new_internal(err, context)
306        })?;
307
308        if let Some(thread_job) = engine_state.current_thread_job()
309            && !thread_job.try_add_pid(child.pid())
310        {
311            kill_by_pid(child.pid().into()).map_err(|err| {
312                ShellError::Io(IoError::new_internal(
313                    err,
314                    "Could not spawn external stdin worker",
315                ))
316            })?;
317        }
318
319        // If we need to copy data into the child process, do it now.
320        if let Some(data) = data_to_copy_into_stdin {
321            let stdin = child.as_mut().stdin.take().expect("stdin is piped");
322            let engine_state = engine_state.clone();
323            let stack = stack.clone();
324            thread::Builder::new()
325                .name("external stdin worker".into())
326                .spawn(move || {
327                    let _ = write_pipeline_data(engine_state, stack, data, stdin);
328                })
329                .map_err(|err| {
330                    IoError::new_with_additional_context(
331                        err,
332                        call.head,
333                        None,
334                        "Could not spawn external stdin worker",
335                    )
336                })?;
337        }
338
339        let child_pid = child.pid();
340
341        // Wrap the output into a `PipelineData::byte_stream`.
342        let child = ChildProcess::new(
343            child,
344            merged_stream,
345            matches!(stderr, OutDest::Pipe),
346            call.head,
347            Some(PostWaitCallback::for_job_control(
348                engine_state,
349                Some(child_pid),
350                executable
351                    .as_path()
352                    .file_name()
353                    .and_then(|it| it.to_str())
354                    .map(|it| it.to_string()),
355            )),
356        )?;
357
358        Ok(PipelineData::byte_stream(
359            ByteStream::child(child, call.head),
360            None,
361        ))
362    }
363
364    fn examples(&self) -> Vec<Example<'_>> {
365        vec![
366            Example {
367                description: "Run an external command",
368                example: r#"run-external "echo" "-n" "hello""#,
369                result: None,
370            },
371            Example {
372                description: "Redirect stdout from an external command into the pipeline",
373                example: r#"run-external "echo" "-n" "hello" | split chars"#,
374                result: None,
375            },
376            Example {
377                description: "Redirect stderr from an external command into the pipeline",
378                example: r#"run-external "nu" "-c" "print -e hello" e>| split chars"#,
379                result: None,
380            },
381        ]
382    }
383}
384
385/// Evaluate all arguments, performing expansions when necessary.
386pub fn eval_external_arguments(
387    engine_state: &EngineState,
388    stack: &mut Stack,
389    call_args: Vec<Value>,
390) -> Result<Vec<Spanned<OsString>>, ShellError> {
391    let cwd = engine_state.cwd(Some(stack))?;
392    let mut args: Vec<Spanned<OsString>> = Vec::with_capacity(call_args.len());
393
394    for arg in call_args {
395        let span = arg.span();
396        match arg {
397            // Expand globs passed to run-external
398            Value::Glob { val, no_expand, .. } if !no_expand => args.extend(
399                expand_glob(
400                    &val,
401                    cwd.as_std_path(),
402                    span,
403                    engine_state.signals().clone(),
404                )?
405                .into_iter()
406                .map(|s| s.into_spanned(span)),
407            ),
408            other => args
409                .push(OsString::from(coerce_into_string(engine_state, other)?).into_spanned(span)),
410        }
411    }
412    Ok(args)
413}
414
415/// Custom `coerce_into_string()`, including globs, since those are often args to `run-external`
416/// as well
417fn coerce_into_string(engine_state: &EngineState, val: Value) -> Result<String, ShellError> {
418    match val {
419        Value::List { .. } => Err(ShellError::CannotPassListToExternal {
420            arg: String::from_utf8_lossy(engine_state.get_span_contents(val.span())).into_owned(),
421            span: val.span(),
422        }),
423        Value::Glob { val, .. } => Ok(val),
424        _ => val.coerce_into_string(),
425    }
426}
427
428/// Performs glob expansion on `arg`. If the expansion found no matches or the pattern
429/// is not a valid glob, then this returns the original string as the expansion result.
430///
431/// Note: This matches the default behavior of Bash, but is known to be
432/// error-prone. We might want to change this behavior in the future.
433fn expand_glob(
434    arg: &str,
435    cwd: &Path,
436    span: Span,
437    signals: Signals,
438) -> Result<Vec<OsString>, ShellError> {
439    // For an argument that isn't a glob, just do the `expand_tilde`
440    // and `expand_ndots` expansion
441    if !nu_glob::is_glob_with_backend(arg) {
442        let path = expand_ndots_safe(expand_tilde(arg));
443        return Ok(vec![path.into()]);
444    }
445
446    // We must use `nu_engine::glob_from` here, in order to ensure we get paths from the correct
447    // dir
448    let glob = NuGlob::Expand(arg.to_owned()).into_spanned(span);
449    if let Ok((prefix, matches)) = nu_engine::glob_from(&glob, cwd, span, None, signals.clone()) {
450        let mut result: Vec<OsString> = vec![];
451
452        for m in matches {
453            signals.check(&span)?;
454            if let Ok(arg) = m {
455                let arg = resolve_globbed_path_to_cwd_relative(arg, prefix.as_ref(), cwd);
456                result.push(arg.into());
457            } else {
458                result.push(arg.into());
459            }
460        }
461
462        // FIXME: do we want to special-case this further? We might accidentally expand when they don't
463        // intend to
464        if result.is_empty() {
465            result.push(arg.into());
466        }
467
468        Ok(result)
469    } else {
470        Ok(vec![arg.into()])
471    }
472}
473
474fn resolve_globbed_path_to_cwd_relative(
475    path: PathBuf,
476    prefix: Option<&PathBuf>,
477    cwd: &Path,
478) -> PathBuf {
479    if let Some(prefix) = prefix {
480        if let Ok(remainder) = path.strip_prefix(prefix) {
481            let new_prefix = if let Some(pfx) = diff_paths(prefix, cwd) {
482                pfx
483            } else {
484                prefix.to_path_buf()
485            };
486            new_prefix.join(remainder)
487        } else {
488            path
489        }
490    } else {
491        path
492    }
493}
494
495/// Write `PipelineData` into `writer`. If `PipelineData` is not binary, it is
496/// first rendered using the `table` command.
497///
498/// Note: Avoid using this function when piping data from an external command to
499/// another external command, because it copies data unnecessarily. Instead,
500/// extract the pipe from the `PipelineData::byte_stream` of the first command
501/// and hand it to the second command directly.
502fn write_pipeline_data(
503    mut engine_state: EngineState,
504    mut stack: Stack,
505    data: PipelineData,
506    mut writer: impl Write,
507) -> Result<(), ShellError> {
508    if let PipelineData::ByteStream(stream, ..) = data {
509        stream.write_to(writer)?;
510    } else if let PipelineData::Value(Value::Binary { val, .. }, ..) = data {
511        writer
512            .write_all(&val)
513            .map_err(|err| IoError::new_internal(err, "Could not write pipeline data"))?;
514    } else {
515        stack.start_collect_value();
516
517        // Turn off color as we pass data through
518        Arc::make_mut(&mut engine_state.config).use_ansi_coloring = UseAnsiColoring::False;
519
520        // Invoke the `table` command.
521        let output =
522            crate::Table.run(&engine_state, &mut stack, &Call::new(Span::unknown()), data)?;
523
524        // Write the output.
525        for value in output {
526            let bytes = value.coerce_into_binary()?;
527            writer
528                .write_all(&bytes)
529                .map_err(|err| IoError::new_internal(err, "Could not write pipeline data"))?;
530        }
531    }
532    Ok(())
533}
534
535/// Returns a helpful error message given an invalid command name.
536pub fn command_not_found(
537    name: &str,
538    span: Span,
539    engine_state: &EngineState,
540    stack: &mut Stack,
541    cwd: &AbsolutePath,
542) -> ShellError {
543    // Run the `command_not_found` hook if there is one.
544    if let Some(hook) = &stack.get_config(engine_state).hooks.command_not_found {
545        let mut stack = stack.start_collect_value();
546        // Set a special environment variable to avoid infinite loops when the
547        // `command_not_found` hook triggers itself.
548        let canary = "ENTERED_COMMAND_NOT_FOUND";
549        if stack.has_env_var(engine_state, canary) {
550            return ShellError::ExternalCommand {
551                label: format!(
552                    "Command {name} not found while running the `command_not_found` hook"
553                ),
554                help: "Make sure the `command_not_found` hook itself does not use unknown commands"
555                    .into(),
556                span,
557            };
558        }
559        stack.add_env_var(canary.into(), Value::bool(true, Span::unknown()));
560
561        let output = eval_hook(
562            &mut engine_state.clone(),
563            &mut stack,
564            None,
565            vec![("cmd_name".into(), Value::string(name, span))],
566            hook,
567            "command_not_found",
568        );
569
570        // Remove the special environment variable that we just set.
571        stack.remove_env_var(engine_state, canary);
572
573        match output {
574            Ok(PipelineData::Value(Value::String { val, .. }, ..)) => {
575                return ShellError::ExternalCommand {
576                    label: format!("Command `{name}` not found"),
577                    help: val,
578                    span,
579                };
580            }
581            Err(err) => {
582                return err;
583            }
584            _ => {
585                // The hook did not return a string, so ignore it.
586            }
587        }
588    }
589
590    // If the name is one of the removed commands, recommend a replacement.
591    if let Some(replacement) = crate::removed_commands().get(&name.to_lowercase()) {
592        return ShellError::RemovedCommand {
593            removed: name.to_lowercase(),
594            replacement: replacement.clone(),
595            span,
596        };
597    }
598
599    // Making this a closure allows using return inside instead of nesting if-else's
600    let help = (|| {
601        // The command might be from another module. Try to find it.
602        // Note that built-in command categories are not modules,
603        // hence this won't find `math sqrt` if the user types `sqrt`.
604        if let Some(module) = engine_state.which_module_has_decl(name.as_bytes(), &[]) {
605            let module = String::from_utf8_lossy(module);
606
607            // Is the command already imported?
608            let full_name = format!("{module} {name}");
609            if engine_state.find_decl(full_name.as_bytes(), &[]).is_some() {
610                return format!("Did you mean `{full_name}`?");
611            }
612
613            return format!(
614                "A command with that name exists in module `{module}`. Try importing it with `use`"
615            );
616        }
617
618        // Try to match the name with the search terms of existing commands.
619        let signatures = engine_state.get_signatures_and_declids(false);
620        if let Some((last, others)) = signatures
621            .iter()
622            .map(|(sig, _)| sig)
623            .filter(|sig| {
624                let name = name.to_folded_case(); // do not allocate new strings in any()
625                sig.name
626                    .to_folded_case()
627                    .split_ascii_whitespace() // basically split into words
628                    .contains(name.as_str()) // find this one `math sqrt` from the example above
629                    || sig
630                        .search_terms
631                        .iter()
632                        .any(|term| term.to_folded_case() == name)
633            })
634            .map(|sig| format!("`{}`", sig.name))
635            .collect::<Vec<_>>()
636            .split_last()
637        {
638            let commands = if others.is_empty() {
639                last
640            } else {
641                // other or last
642                // other, other or last
643                &format!("{} or {last}", others.join(", "))
644            };
645
646            return format!("Did you mean {commands}?");
647        }
648
649        // Try a fuzzy search on the names of all existing commands.
650        if let Some(cmd) = did_you_mean(signatures.iter().map(|(sig, _)| &sig.name), name) {
651            // The user is invoking an external command with the same name as a
652            // built-in command. Remind them of this.
653            if cmd == name {
654                return "There is a built-in command with the same name".to_string();
655            }
656
657            return format!("Did you mean `{cmd}`?");
658        }
659
660        // If we find a file, it's likely that the user forgot to set permissions
661        if cwd.join(name).is_file() {
662            return format!(
663                "`{name}` refers to a file that is not executable. Did you forget to set execute permissions?"
664            );
665        }
666
667        // We found nothing useful. Give up and return a generic error message.
668        format!("`{name}` is neither a Nushell built-in or a known external command")
669    })();
670
671    ShellError::ExternalCommand {
672        label: format!("Command `{name}` not found"),
673        help,
674        span,
675    }
676}
677
678/// Searches for the absolute path of an executable by name. `.bat` and `.cmd`
679/// files are recognized as executables on Windows.
680///
681/// This is a wrapper around `which::which_in()` except that, on Windows, it
682/// also searches the current directory before any PATH entries.
683///
684/// Note: the `which.rs` crate always uses PATHEXT from the environment. As
685/// such, changing PATHEXT within Nushell doesn't work without updating the
686/// actual environment of the Nushell process.
687pub fn which(name: impl AsRef<OsStr>, paths: &str, cwd: &Path) -> Option<PathBuf> {
688    #[cfg(windows)]
689    let paths = format!("{};{}", cwd.display(), paths);
690    which::which_in(name, Some(paths), cwd).ok()
691}
692
693/// Returns true if `name` is a (somewhat useful) CMD internal command. The full
694/// list can be found at <https://ss64.com/nt/syntax-internal.html>
695fn is_cmd_internal_command(name: &str) -> bool {
696    const COMMANDS: &[&str] = &[
697        "ASSOC", "CLS", "ECHO", "FTYPE", "MKLINK", "PAUSE", "START", "VER", "VOL",
698    ];
699    COMMANDS.iter().any(|cmd| cmd.eq_ignore_ascii_case(name))
700}
701
702/// Returns true if a string contains CMD special characters.
703fn has_cmd_special_character(s: impl AsRef<[u8]>) -> bool {
704    s.as_ref()
705        .iter()
706        .any(|b| matches!(b, b'<' | b'>' | b'&' | b'|' | b'^'))
707}
708
709/// Escape an argument for CMD internal commands. The result can be safely passed to `raw_arg()`.
710#[cfg_attr(not(windows), allow(dead_code))]
711fn escape_cmd_argument(arg: &Spanned<OsString>) -> Result<Cow<'_, OsStr>, ShellError> {
712    let Spanned { item: arg, span } = arg;
713    let bytes = arg.as_encoded_bytes();
714    if bytes.iter().any(|b| matches!(b, b'\r' | b'\n' | b'%')) {
715        // \r and \n truncate the rest of the arguments and % can expand environment variables
716        Err(ShellError::ExternalCommand {
717            label:
718                "Arguments to CMD internal commands cannot contain new lines or percent signs '%'"
719                    .into(),
720            help: "some characters currently cannot be securely escaped".into(),
721            span: *span,
722        })
723    } else if bytes.contains(&b'"') {
724        // If `arg` is already quoted by double quotes, confirm there's no
725        // embedded double quotes, then leave it as is.
726        if bytes.iter().filter(|b| **b == b'"').count() == 2
727            && bytes.starts_with(b"\"")
728            && bytes.ends_with(b"\"")
729        {
730            Ok(Cow::Borrowed(arg))
731        } else {
732            Err(ShellError::ExternalCommand {
733                label: "Arguments to CMD internal commands cannot contain embedded double quotes"
734                    .into(),
735                help: "this case currently cannot be securely handled".into(),
736                span: *span,
737            })
738        }
739    } else if bytes.contains(&b' ') || has_cmd_special_character(bytes) {
740        // If `arg` contains space or special characters, quote the entire argument by double quotes.
741        let mut new_str = OsString::new();
742        new_str.push("\"");
743        new_str.push(arg);
744        new_str.push("\"");
745        Ok(Cow::Owned(new_str))
746    } else {
747        // FIXME?: what if `arg.is_empty()`?
748        Ok(Cow::Borrowed(arg))
749    }
750}
751
752#[cfg(test)]
753mod test {
754    use super::*;
755    use nu_test_support::{fs::Stub, playground::Playground};
756
757    #[test]
758    fn test_expand_glob() {
759        Playground::setup("test_expand_glob", |dirs, play| {
760            play.with_files(&[Stub::EmptyFile("a.txt"), Stub::EmptyFile("b.txt")]);
761
762            let cwd = dirs.test().as_std_path();
763
764            let actual = expand_glob("*.txt", cwd, Span::test_data(), Signals::empty()).unwrap();
765            let expected = &["a.txt", "b.txt"];
766            assert_eq!(actual, expected);
767
768            let actual = expand_glob("./*.txt", cwd, Span::test_data(), Signals::empty()).unwrap();
769            assert_eq!(actual, expected);
770
771            let actual = expand_glob("'*.txt'", cwd, Span::test_data(), Signals::empty()).unwrap();
772            let expected = &["'*.txt'"];
773            assert_eq!(actual, expected);
774
775            let actual = expand_glob(".", cwd, Span::test_data(), Signals::empty()).unwrap();
776            let expected = &["."];
777            assert_eq!(actual, expected);
778
779            let actual = expand_glob("./a.txt", cwd, Span::test_data(), Signals::empty()).unwrap();
780            let expected = &["./a.txt"];
781            assert_eq!(actual, expected);
782
783            let actual = expand_glob("[*.txt", cwd, Span::test_data(), Signals::empty()).unwrap();
784            let expected = &["[*.txt"];
785            assert_eq!(actual, expected);
786
787            let actual =
788                expand_glob("~/foo.txt", cwd, Span::test_data(), Signals::empty()).unwrap();
789            let home = dirs::home_dir().expect("failed to get home dir");
790            let expected: Vec<OsString> = vec![home.join("foo.txt").into()];
791            assert_eq!(actual, expected);
792        })
793    }
794
795    #[test]
796    fn test_write_pipeline_data() {
797        let mut engine_state = EngineState::new();
798        let stack = Stack::new();
799        let cwd = std::env::current_dir()
800            .unwrap()
801            .into_os_string()
802            .into_string()
803            .unwrap();
804
805        // set the PWD environment variable as it's required now
806        engine_state.add_env_var("PWD".into(), Value::string(cwd, Span::test_data()));
807
808        let mut buf = vec![];
809        let input = PipelineData::empty();
810        write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
811        assert_eq!(buf, b"");
812
813        let mut buf = vec![];
814        let input = PipelineData::value(Value::string("foo", Span::test_data()), None);
815        write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
816        assert_eq!(buf, b"foo");
817
818        let mut buf = vec![];
819        let input = PipelineData::value(Value::binary(b"foo", Span::test_data()), None);
820        write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
821        assert_eq!(buf, b"foo");
822
823        let mut buf = vec![];
824        let input = PipelineData::byte_stream(
825            ByteStream::read(
826                b"foo".as_slice(),
827                Span::test_data(),
828                Signals::empty(),
829                ByteStreamType::Unknown,
830            ),
831            None,
832        );
833        write_pipeline_data(engine_state.clone(), stack.clone(), input, &mut buf).unwrap();
834        assert_eq!(buf, b"foo");
835    }
836}
837
838/// `prepare_background_command` must detach from the controlling terminal/console
839/// so completer subprocesses cannot rewrite reedline's raw-mode state.
840#[cfg(test)]
841mod background_isolation_tests {
842    use nu_system::prepare_background_command;
843    use std::process::{Command, Stdio};
844
845    #[cfg(unix)]
846    fn assert_child_has_no_tty(stdin: Stdio) {
847        let mut cmd = Command::new("sh");
848        // Subshell so a failed redirect does not exit before the `||` branch.
849        cmd.args([
850            "-c",
851            "(exec 3>/dev/tty) 2>/dev/null && echo has_tty || echo no_tty",
852        ])
853        .stdin(stdin)
854        .stdout(Stdio::piped())
855        .stderr(Stdio::null());
856        prepare_background_command(&mut cmd);
857
858        let output = cmd.output().expect("sh should run");
859        assert_eq!(
860            String::from_utf8_lossy(&output.stdout).trim(),
861            "no_tty",
862            "child must not retain a controlling terminal after setsid"
863        );
864    }
865
866    #[cfg(unix)]
867    #[test]
868    fn setsid_removes_controlling_terminal() {
869        assert_child_has_no_tty(Stdio::null());
870    }
871
872    #[cfg(unix)]
873    #[test]
874    fn setsid_removes_controlling_terminal_with_piped_stdin() {
875        assert_child_has_no_tty(Stdio::piped());
876    }
877
878    #[cfg(windows)]
879    #[test]
880    fn create_no_window_has_no_console_window() {
881        // prepare_background_command uses CREATE_NO_WINDOW (required for completions).
882        //
883        // Do **not** probe with `echo.>CON`: opening CON can allocate a console even when
884        // the process was started with CREATE_NO_WINDOW, which false-positives on GHA
885        // (the old DETACHED_PROCESS-oriented check).
886        //
887        // GetConsoleWindow() reports whether a console is associated without allocating one.
888        let mut cmd = Command::new("powershell.exe");
889        cmd.args([
890            "-NoProfile",
891            "-NonInteractive",
892            "-Command",
893            concat!(
894                "Add-Type -Namespace NuBg -Name Native -MemberDefinition '",
895                "[DllImport(\"kernel32.dll\")] public static extern System.IntPtr GetConsoleWindow();",
896                "'; ",
897                "if ([NuBg.Native]::GetConsoleWindow() -eq [System.IntPtr]::Zero) { ",
898                "[Console]::Out.Write('no_console') ",
899                "} else { ",
900                "[Console]::Out.Write('has_console') ",
901                "}",
902            ),
903        ])
904        .stdin(Stdio::null())
905        .stdout(Stdio::piped())
906        .stderr(Stdio::piped());
907        prepare_background_command(&mut cmd);
908
909        let output = cmd.output().expect("powershell should run");
910        let stdout = String::from_utf8_lossy(&output.stdout);
911        let stderr = String::from_utf8_lossy(&output.stderr);
912        let token = stdout
913            .split_whitespace()
914            .find(|t| *t == "no_console" || *t == "has_console")
915            .unwrap_or("");
916        assert_eq!(
917            token, "no_console",
918            "child must have no console window under CREATE_NO_WINDOW; stdout={stdout:?} stderr={stderr}"
919        );
920    }
921}