Skip to main content

gix_command/
prepare.rs

1use std::{
2    borrow::Cow,
3    ffi::{OsStr, OsString},
4    path::{Path, PathBuf},
5    process::{Command, Stdio},
6};
7
8use bstr::ByteSlice;
9use gix_error::Result;
10
11use crate::{Context, Prepare, extract_interpreter, is_bare_command, split_paths, win_path_lookup};
12
13/// Builder
14impl Prepare {
15    /// If called, the command will be checked for characters that are typical for shell
16    /// scripts, and if found will use `sh` to execute it or whatever is set as
17    /// [`with_shell_program()`](Self::with_shell_program()).
18    ///
19    /// Commands are inspected using their native encoded bytes, including commands that cannot
20    /// be represented as UTF-8.
21    ///
22    /// If a shell is used, then arguments given here with [arg()](Self::arg) or
23    /// [args()](Self::args) will be substituted via `"$@"` if it's not already present in the
24    /// command.
25    ///
26    ///
27    /// The [`command_may_be_shell_script_allow_manual_argument_splitting()`](Self::command_may_be_shell_script_allow_manual_argument_splitting())
28    /// and [`command_may_be_shell_script_disallow_manual_argument_splitting()`](Self::command_may_be_shell_script_disallow_manual_argument_splitting())
29    /// methods also call this method.
30    ///
31    /// If neither this method nor [`with_shell()`](Self::with_shell()) is called, commands are
32    /// always executed verbatim and directly, without the use of a shell.
33    pub fn command_may_be_shell_script(mut self) -> Self {
34        self.use_shell = self
35            .command
36            .as_encoded_bytes()
37            .find_byteset(b"|&;<>()$`\\\"' \t\n*?[#~=%")
38            .is_some();
39        self
40    }
41
42    /// If called, unconditionally use a shell to execute the command and its arguments.
43    ///
44    /// This uses `sh` to execute it, or whatever is set as
45    /// [`with_shell_program()`](Self::with_shell_program()).
46    ///
47    /// Arguments given here with [arg()](Self::arg) or [args()](Self::args) will be
48    /// substituted via `"$@"` if it's not already present in the command.
49    ///
50    /// If neither this method nor
51    /// [`command_may_be_shell_script()`](Self::command_may_be_shell_script()) is called,
52    /// commands are always executed verbatim and directly, without the use of a shell. (But
53    /// see [`command_may_be_shell_script()`](Self::command_may_be_shell_script()) on other
54    /// methods that call that method.)
55    ///
56    /// We also disallow manual argument splitting
57    /// (see [`command_may_be_shell_script_disallow_manual_argument_splitting`](Self::command_may_be_shell_script_disallow_manual_argument_splitting()))
58    /// to assure a shell is indeed used, no matter what.
59    pub fn with_shell(mut self) -> Self {
60        self.use_shell = true;
61        self.allow_manual_arg_splitting = false;
62        self
63    }
64
65    /// Quote the command if it is run in a shell, so its path is left intact.
66    ///
67    /// This is only meaningful if the command has been arranged to run in a shell, either
68    /// unconditionally with [`with_shell()`](Self::with_shell()), or conditionally with
69    /// [`command_may_be_shell_script()`](Self::command_may_be_shell_script()).
70    ///
71    /// Note that this should not be used if the command is a script - quoting is only the
72    /// right choice if it's known to be a program path.
73    ///
74    /// Note also that this does not affect arguments passed with [arg()](Self::arg) or
75    /// [args()](Self::args), which do not have to be quoted by the *caller* because they are
76    /// passed as `"$@"` positional parameters (`"$1"`, `"$2"`, and so on).
77    pub fn with_quoted_command(mut self) -> Self {
78        self.quote_command = true;
79        self
80    }
81
82    /// Set the name or path to the shell `program` to use if a shell is to be used, to avoid
83    /// using the default shell which is `sh`.
84    ///
85    /// Note that shells that are not Bourne-style cannot be expected to work correctly,
86    /// because POSIX shell syntax is assumed when searching for and conditionally adding
87    /// `"$@"` to receive arguments, where applicable (and in the behaviour of
88    /// [`with_quoted_command()`](Self::with_quoted_command()), if called).
89    pub fn with_shell_program(mut self, program: impl Into<OsString>) -> Self {
90        self.shell_program = Some(program.into());
91        self
92    }
93
94    /// Unconditionally turn off using the shell when spawning the command.
95    ///
96    /// Note that not using the shell is the default. So an effective use of this method
97    /// is some time after [`command_may_be_shell_script()`](Self::command_may_be_shell_script())
98    /// or [`with_shell()`](Self::with_shell()) was called.
99    pub fn without_shell(mut self) -> Self {
100        self.use_shell = false;
101        self
102    }
103
104    /// Set additional `ctx` to be used when spawning the process.
105    ///
106    /// Note that this is a must for most kind of commands that `git` usually spawns, as at
107    /// least they need to know the correct Git repository to function.
108    pub fn with_context(mut self, ctx: Context) -> Self {
109        self.context = Some(ctx);
110        self
111    }
112
113    /// Like [`command_may_be_shell_script()`](Self::command_may_be_shell_script()), but try to
114    /// split arguments by hand if this can be safely done without a shell.
115    ///
116    /// This is useful on platforms where spawning processes is slow, or where many processes
117    /// have to be spawned in a row which should be sped up. Manual argument splitting is
118    /// enabled by default on Windows only.
119    ///
120    /// Note that this does *not* check for the use of possible shell builtins. Commands may
121    /// fail or behave differently if they are available as shell builtins and no corresponding
122    /// external command exists, or the external command behaves differently.
123    /// Leading shell assignment words are applied to the environment when followed by a command.
124    pub fn command_may_be_shell_script_allow_manual_argument_splitting(mut self) -> Self {
125        self.allow_manual_arg_splitting = true;
126        self.command_may_be_shell_script()
127    }
128
129    /// Like [`command_may_be_shell_script()`](Self::command_may_be_shell_script()), but don't
130    /// allow to bypass the shell even if manual argument splitting can be performed safely.
131    pub fn command_may_be_shell_script_disallow_manual_argument_splitting(mut self) -> Self {
132        self.allow_manual_arg_splitting = false;
133        self.command_may_be_shell_script()
134    }
135
136    /// Configure the process to use `stdio` for _stdin_.
137    pub fn stdin(mut self, stdio: Stdio) -> Self {
138        self.stdin = stdio;
139        self
140    }
141    /// Configure the process to use `stdio` for _stdout_.
142    pub fn stdout(mut self, stdio: Stdio) -> Self {
143        self.stdout = stdio;
144        self
145    }
146    /// Configure the process to use `stdio` for _stderr_.
147    pub fn stderr(mut self, stdio: Stdio) -> Self {
148        self.stderr = stdio;
149        self
150    }
151
152    /// Add `arg` to the list of arguments to call the command with.
153    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
154        self.args.push(arg.into());
155        self
156    }
157
158    /// Add `args` to the list of arguments to call the command with.
159    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
160        self.args
161            .append(&mut args.into_iter().map(Into::into).collect::<Vec<_>>());
162        self
163    }
164
165    /// Add `key` with `value` to the environment of the spawned command.
166    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
167        self.env.push((key.into(), value.into()));
168        self
169    }
170}
171
172/// Finalization
173impl Prepare {
174    /// Spawn the command as configured.
175    ///
176    /// Encoding errors during preparation are returned with [`std::io::ErrorKind::InvalidInput`].
177    pub fn spawn(self) -> std::io::Result<std::process::Child> {
178        let mut cmd =
179            Command::try_from(self).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
180        gix_trace::debug!(cmd = ?cmd);
181        cmd.spawn()
182    }
183}
184
185impl TryFrom<Prepare> for Command {
186    type Error = gix_error::Error;
187
188    fn try_from(mut prep: Prepare) -> Result<Self> {
189        let mut inline_env = Vec::new();
190        let mut cmd = if prep.use_shell {
191            let split_args = prep
192                .allow_manual_arg_splitting
193                .then(|| {
194                    let command = gix_path::os_str_into_bstr(&prep.command).ok()?;
195                    if command.find_byteset(b"\\|&;<>()$`\n*?[#~%").is_none() {
196                        crate::parse::command_line(command).ok()
197                    } else {
198                        None
199                    }
200                })
201                .flatten();
202            match split_args {
203                Some(parsed) => {
204                    let mut cmd = if cfg!(windows) {
205                        windows_command(parsed.command, &prep.env, &parsed.env)
206                    } else {
207                        Command::new(parsed.command)
208                    };
209                    cmd.args(parsed.args);
210                    inline_env = parsed.env;
211                    cmd
212                }
213                None => {
214                    let mut cmd = match prep.shell_program {
215                        Some(shell) => Command::new(shell),
216                        None => gix_path::env::shell_command(),
217                    };
218                    // Passed as `command_name` after `-c <script>`; the shell uses it
219                    // as `$0`, which prefixes its own diagnostic messages. If the
220                    // shell path has no extractable basename — reachable only via
221                    // degenerate input like `""` or `/` — fall back to `_`, the
222                    // conventional placeholder for an unused `$0`, rather than
223                    // making a false claim about which shell is running.
224                    let arg0 = std::path::Path::new(cmd.get_program())
225                        .file_name()
226                        .unwrap_or(std::ffi::OsStr::new("_"))
227                        .to_os_string();
228                    cmd.arg("-c");
229                    if !prep.args.is_empty() {
230                        if !prep.command.as_encoded_bytes().contains_str("$@") {
231                            if prep.quote_command {
232                                let command = gix_path::os_str_into_bstr(&prep.command)?;
233                                prep.command = gix_path::from_bstring(gix_quote::single(command))?.into();
234                            }
235                            prep.command.push(r#" "$@""#);
236                        } else {
237                            gix_trace::debug!(
238                                r#"Will not add '"$@"' to '{:?}' as it seems to contain '$@' already"#,
239                                prep.command
240                            );
241                        }
242                    }
243                    cmd.arg(prep.command);
244                    cmd.arg(arg0);
245                    cmd
246                }
247            }
248        } else if cfg!(windows) {
249            windows_command(prep.command, &prep.env, &[])
250        } else {
251            Command::new(prep.command)
252        };
253        // We never want to have terminals pop-up on Windows if this runs from a GUI application.
254        #[cfg(windows)]
255        {
256            use std::os::windows::process::CommandExt;
257            const CREATE_NO_WINDOW: u32 = 0x08000000;
258            cmd.creation_flags(CREATE_NO_WINDOW);
259        }
260        cmd.stdin(prep.stdin)
261            .stdout(prep.stdout)
262            .stderr(prep.stderr)
263            .envs(prep.env)
264            .args(prep.args);
265        if let Some(ctx) = prep.context {
266            if let Some(git_dir) = ctx.git_dir {
267                cmd.env("GIT_DIR", &git_dir);
268            }
269            if let Some(worktree_dir) = ctx.worktree_dir {
270                cmd.env("GIT_WORK_TREE", worktree_dir);
271            }
272            if let Some(value) = ctx.no_replace_objects {
273                cmd.env("GIT_NO_REPLACE_OBJECTS", usize::from(value).to_string());
274            }
275            if let Some(namespace) = ctx.ref_namespace {
276                cmd.env("GIT_NAMESPACE", gix_path::from_bstring(namespace)?);
277            }
278            if let Some(value) = ctx.literal_pathspecs {
279                cmd.env("GIT_LITERAL_PATHSPECS", usize::from(value).to_string());
280            }
281            if let Some(value) = ctx.glob_pathspecs {
282                cmd.env(
283                    if value {
284                        "GIT_GLOB_PATHSPECS"
285                    } else {
286                        "GIT_NOGLOB_PATHSPECS"
287                    },
288                    "1",
289                );
290            }
291            if let Some(value) = ctx.icase_pathspecs {
292                cmd.env("GIT_ICASE_PATHSPECS", usize::from(value).to_string());
293            }
294            if let Some(stderr) = ctx.stderr {
295                cmd.stderr(if stderr { Stdio::inherit() } else { Stdio::null() });
296            }
297        }
298        cmd.envs(inline_env);
299        Ok(cmd)
300    }
301}
302
303/// Create a Windows command using Git-compatible `PATH` lookup and shebang dispatch.
304///
305/// The last `PATH` in `inline_env` overrides the last one in `env`, which overrides the inherited value (of this process).
306/// The selected `PATH` is searched in order. A resolved shebang script is launched through its interpreter, ignoring shebang
307/// arguments. If `PATH` does not resolve a bare command, the missing program remains anchored in its first entry
308/// so Rust's broader Windows lookup cannot find it elsewhere.
309fn windows_command(command: OsString, env: &[(OsString, OsString)], inline_env: &[(String, OsString)]) -> Command {
310    let explicit_joined_paths = inline_env
311        .iter()
312        .rev()
313        .find(|(name, _)| name.eq_ignore_ascii_case("PATH"))
314        .map(|(_, value)| value.as_os_str())
315        .or_else(|| {
316            env.iter()
317                .rev()
318                .find(|(name, _)| name.to_str().is_some_and(|name| name.eq_ignore_ascii_case("PATH")))
319                .map(|(_, value)| value.as_os_str())
320        });
321    let joined_paths = explicit_joined_paths
322        .map(Cow::Borrowed)
323        .or_else(|| std::env::var_os("PATH").map(Cow::Owned));
324    let looked_up = joined_paths
325        .as_deref()
326        .and_then(|joined_paths| win_path_lookup(command.as_ref(), joined_paths));
327    let program: Cow<'_, Path> = match looked_up {
328        // Use the manually resolved path.
329        Some(program) => Cow::Owned(program),
330        // A bare PATH miss must neither probe a worktree file nor use Rust's broader Windows search.
331        None if is_bare_command(Path::new(&command)) => {
332            return Command::new(prevent_further_path_lookup(
333                command.as_ref(),
334                joined_paths.as_deref().unwrap_or_else(|| OsStr::new("")),
335            ));
336        }
337        None => Cow::Borrowed(command.as_ref()),
338    };
339    if let Some(shebang) = extract_interpreter(program.as_ref()) {
340        let mut cmd = Command::new(shebang.interpreter);
341        // Git for Windows ignores shebang arguments and passes only the script path.
342        cmd.arg(program.as_ref());
343        cmd
344    } else {
345        Command::new(program.as_ref())
346    }
347}
348
349/// Represent the failed lookup of `command` in `PATH` without permitting another search.
350///
351/// `joined_paths` is the complete value of `PATH`, not one of its entries. The first non-empty entry is
352/// joined with `command`, producing a path that Rust's Windows resolver will not look up elsewhere. If there is no such
353/// entry, a trailing separator makes `command` invalid instead.
354fn prevent_further_path_lookup(command: &Path, joined_paths: &OsStr) -> PathBuf {
355    if let Some(mut root) = split_paths(joined_paths).next() {
356        root.push(command);
357        root
358    } else {
359        let mut missing = command.to_owned();
360        // A trailing separator makes the program invalid without allowing Rust to search ambient locations.
361        missing.push("");
362        missing
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn inherited_path_is_used_for_both_probing_and_execution() -> gix_testtools::TestResult {
372        if gix_testtools::run_in_isolated_process()? {
373            return Ok(());
374        }
375        let root = gix_testtools::tempfile::tempdir()?;
376        let bin = root.path().join("bin");
377        std::fs::create_dir(&bin)?;
378        let _cwd = gix_testtools::set_current_dir(root.path())?;
379        std::fs::write("ssh", b"#!/untrusted/interpreter\n")?;
380
381        for path in [Some(bin.to_str().expect("temporary path is UTF-8")), Some(""), None] {
382            let environment = gix_testtools::Env::new();
383            let _environment = match path {
384                Some(path) => environment.set("PATH", path),
385                None => environment.unset("PATH"),
386            };
387            let mut cmd = windows_command("ssh".into(), &[], &[]);
388            let expected = match path {
389                Some(path) if !path.is_empty() => bin.join("ssh"),
390                _ => Path::new("ssh").join(""),
391            };
392            assert_eq!(
393                cmd.get_program(),
394                expected,
395                "a PATH miss cannot probe a worktree file or leave a bare command for another lookup"
396            );
397            assert!(cmd.get_args().next().is_none(), "the planted shebang is never used");
398            assert!(cmd.spawn().is_err(), "a missing program fails to spawn");
399        }
400
401        let _environment = gix_testtools::Env::new().set("PATH", bin.to_str().expect("temporary path is UTF-8"));
402        let executable = bin.join("ssh.exe");
403        std::fs::write(&executable, b"executable placeholder")?;
404        assert_eq!(
405            windows_command("ssh".into(), &[], &[]).get_program(),
406            executable,
407            "the executable selected from PATH is also the one passed to the process launcher"
408        );
409        let explicit_script = windows_command("./ssh".into(), &[], &[]);
410        assert_eq!(
411            explicit_script.get_program(),
412            Path::new("/untrusted/interpreter"),
413            "an explicitly requested script retains shebang support"
414        );
415        assert_eq!(
416            explicit_script.get_args().collect::<Vec<_>>(),
417            [OsStr::new("./ssh")],
418            "the interpreter receives the explicitly requested script"
419        );
420        Ok(())
421    }
422
423    #[test]
424    fn explicit_path_lookup_failure_stays_within_that_path() -> gix_testtools::TestResult {
425        let joined_paths = std::env::join_paths(["", "not/a/real/path", "also/not/real"])?;
426        let cmd = windows_command("missing.exe".into(), &[], &[("PATH".into(), joined_paths)]);
427        assert_eq!(
428            cmd.get_program(),
429            std::path::Path::new("not/a/real/path/missing.exe"),
430            "an inline PATH miss must not leave a bare program for Rust to resolve elsewhere"
431        );
432        Ok(())
433    }
434}