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}