Skip to content

Commit 80040e1

Browse files
committed
fix: make certain git-credential helper implementations work from cmd-prompts on Windows (#1103)
On prompts, typically only `git.exe` is available, but no shell (i.e. `sh`). Thus, we have to prefer going through `git.exe` to launch credential helpers and possibly split simple arguments ourselves.
1 parent 1ff26b9 commit 80040e1

File tree

4 files changed

+134
-38
lines changed

4 files changed

+134
-38
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-credentials/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ gix-trace = { version = "^0.1.3", path = "../gix-trace" }
2828
thiserror = "1.0.32"
2929
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
3030
bstr = { version = "1.3.0", default-features = false, features = ["std"]}
31+
shell-words = "1.0"
3132

3233

3334

gix-credentials/src/program/mod.rs

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ pub enum Kind {
1212
/// A custom credentials helper, as identified just by the name with optional arguments
1313
ExternalName {
1414
/// The name like `foo` along with optional args, like `foo --arg --bar="a b"`, with arguments using `sh` shell quoting rules.
15-
/// The program executed will be `git-credential-foo` if `name_and_args` starts with `foo`.
15+
/// The program executed will be `git-credential-foo [args]` if `name_and_args` starts with `foo [args]`.
16+
/// Note that a shell is only used if it's needed.
1617
name_and_args: BString,
1718
},
1819
/// A custom credentials helper, as identified just by the absolute path to the program and optional arguments. The program is executed through a shell.
@@ -53,7 +54,6 @@ impl Program {
5354
if gix_path::is_absolute(path) {
5455
Kind::ExternalPath { path_and_args: input }
5556
} else {
56-
input.insert_str(0, "git credential-");
5757
Kind::ExternalName { name_and_args: input }
5858
}
5959
};
@@ -65,33 +65,41 @@ impl Program {
6565
}
6666
from_custom_definition_inner(input.into())
6767
}
68-
}
69-
70-
/// Builder
71-
impl Program {
72-
/// By default `stderr` of programs is inherited and typically displayed in the terminal.
73-
pub fn suppress_stderr(mut self) -> Self {
74-
self.stderr = false;
75-
self
76-
}
77-
}
7868

79-
impl Program {
80-
pub(crate) fn start(
81-
&mut self,
82-
action: &helper::Action,
83-
) -> std::io::Result<(std::process::ChildStdin, Option<std::process::ChildStdout>)> {
84-
assert!(self.child.is_none(), "BUG: must not call `start()` twice");
69+
/// Convert the program into the respective command, suitable to invoke `action`.
70+
pub fn to_command(&self, action: &helper::Action) -> std::process::Command {
71+
let git_program = cfg!(windows).then(|| "git.exe").unwrap_or("git");
8572
let mut cmd = match &self.kind {
8673
Kind::Builtin => {
87-
let mut cmd = Command::new(cfg!(windows).then(|| "git.exe").unwrap_or("git"));
74+
let mut cmd = Command::new(git_program);
8875
cmd.arg("credential").arg(action.as_arg(false));
8976
cmd
9077
}
91-
Kind::ExternalShellScript(for_shell)
92-
| Kind::ExternalName {
93-
name_and_args: for_shell,
78+
Kind::ExternalName { name_and_args } => {
79+
let mut args = name_and_args.clone();
80+
args.insert_str(0, "credential-");
81+
args.insert_str(0, " ");
82+
args.insert_str(0, git_program);
83+
let split_args = if args.find_byteset(b"\\|&;<>()$`\n*?[#~%").is_none() {
84+
args.to_str()
85+
.ok()
86+
.and_then(|args| shell_words::split(args).ok().map(Vec::into_iter))
87+
} else {
88+
None
89+
};
90+
match split_args {
91+
Some(mut args) => {
92+
let mut cmd = Command::new(args.next().expect("non-empty input"));
93+
cmd.args(args).arg(action.as_arg(true));
94+
cmd
95+
}
96+
None => gix_command::prepare(gix_path::from_bstr(args.as_ref()).into_owned())
97+
.arg(action.as_arg(true))
98+
.with_shell()
99+
.into(),
100+
}
94101
}
102+
Kind::ExternalShellScript(for_shell)
95103
| Kind::ExternalPath {
96104
path_and_args: for_shell,
97105
} => gix_command::prepare(gix_path::from_bstr(for_shell.as_bstr()).as_ref())
@@ -106,6 +114,26 @@ impl Program {
106114
Stdio::null()
107115
})
108116
.stderr(if self.stderr { Stdio::inherit() } else { Stdio::null() });
117+
cmd
118+
}
119+
}
120+
121+
/// Builder
122+
impl Program {
123+
/// By default `stderr` of programs is inherited and typically displayed in the terminal.
124+
pub fn suppress_stderr(mut self) -> Self {
125+
self.stderr = false;
126+
self
127+
}
128+
}
129+
130+
impl Program {
131+
pub(crate) fn start(
132+
&mut self,
133+
action: &helper::Action,
134+
) -> std::io::Result<(std::process::ChildStdin, Option<std::process::ChildStdout>)> {
135+
assert!(self.child.is_none(), "BUG: must not call `start()` twice");
136+
let mut cmd = self.to_command(action);
109137
gix_trace::debug!(cmd = ?cmd, "launching credential helper");
110138
let mut child = cmd.spawn()?;
111139
let stdin = child.stdin.take().expect("stdin to be configured");
Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,102 @@
1-
use gix_credentials::{program::Kind, Program};
1+
use gix_credentials::{helper, program::Kind, Program};
2+
3+
#[cfg(windows)]
4+
const GIT: &str = "git.exe";
5+
#[cfg(not(windows))]
6+
const GIT: &str = "git";
7+
8+
#[cfg(windows)]
9+
const SH: &str = "sh";
10+
#[cfg(not(windows))]
11+
const SH: &str = "/bin/sh";
212

313
#[test]
4-
fn script() {
5-
assert!(
6-
matches!(Program::from_custom_definition("!exe").kind, Kind::ExternalShellScript(script) if script == "exe")
14+
fn empty() {
15+
let prog = Program::from_custom_definition("");
16+
assert!(matches!(&prog.kind, Kind::ExternalName { name_and_args } if name_and_args == ""));
17+
assert_eq!(
18+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
19+
format!(r#""{GIT}" "credential-" "store""#),
20+
"not useful, but allowed, would have to be caught elsewhere"
21+
);
22+
}
23+
24+
#[test]
25+
fn simple_script_in_path() {
26+
let prog = Program::from_custom_definition("!exe");
27+
assert!(matches!(&prog.kind, Kind::ExternalShellScript(script) if script == "exe"));
28+
assert_eq!(
29+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
30+
r#""exe" "store""#,
31+
"it didn't detect anything shell-scripty, and thus doesn't use a shell"
732
);
833
}
934

1035
#[test]
1136
fn name_with_args() {
1237
let input = "name --arg --bar=\"a b\"";
13-
let expected = "git credential-name --arg --bar=\"a b\"";
14-
assert!(
15-
matches!(Program::from_custom_definition(input).kind, Kind::ExternalName{name_and_args} if name_and_args == expected)
38+
let prog = Program::from_custom_definition(input);
39+
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
40+
assert_eq!(
41+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
42+
format!(r#""{GIT}" "credential-name" "--arg" "--bar=a b" "store""#)
43+
);
44+
}
45+
46+
#[test]
47+
fn name_with_special_args() {
48+
let input = "name --arg --bar=~/folder/in/home";
49+
let prog = Program::from_custom_definition(input);
50+
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
51+
assert_eq!(
52+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
53+
format!(r#""{SH}" "-c" "{GIT} credential-name --arg --bar=~/folder/in/home \"$@\"" "--" "store""#)
1654
);
1755
}
1856

1957
#[test]
2058
fn name() {
2159
let input = "name";
22-
let expected = "git credential-name";
23-
assert!(
24-
matches!(Program::from_custom_definition(input).kind, Kind::ExternalName{name_and_args} if name_and_args == expected)
60+
let prog = Program::from_custom_definition(input);
61+
assert!(matches!(&prog.kind, Kind::ExternalName{name_and_args} if name_and_args == input));
62+
assert_eq!(
63+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
64+
format!(r#""{GIT}" "credential-name" "store""#),
65+
"we detect that this can run without shell, which is also more portable on windows"
2566
);
2667
}
2768

2869
#[test]
29-
fn path_with_args() {
70+
fn path_with_args_that_definitely_need_shell() {
3071
let input = "/abs/name --arg --bar=\"a b\"";
31-
assert!(
32-
matches!(Program::from_custom_definition(input).kind, Kind::ExternalPath{path_and_args} if path_and_args == input)
72+
let prog = Program::from_custom_definition(input);
73+
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
74+
assert_eq!(
75+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
76+
format!(r#""{SH}" "-c" "/abs/name --arg --bar=\"a b\" \"$@\"" "--" "store""#)
3377
);
3478
}
3579

3680
#[test]
37-
fn path() {
81+
fn path_without_args() {
3882
let input = "/abs/name";
39-
assert!(
40-
matches!(Program::from_custom_definition(input).kind, Kind::ExternalPath{path_and_args} if path_and_args == input)
83+
let prog = Program::from_custom_definition(input);
84+
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
85+
assert_eq!(
86+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
87+
r#""/abs/name" "store""#,
88+
"no shell is used"
89+
);
90+
}
91+
92+
#[test]
93+
fn path_with_simple_args() {
94+
let input = "/abs/name a b";
95+
let prog = Program::from_custom_definition(input);
96+
assert!(matches!(&prog.kind, Kind::ExternalPath{path_and_args} if path_and_args == input));
97+
assert_eq!(
98+
format!("{:?}", prog.to_command(&helper::Action::Store("egal".into()))),
99+
format!(r#""{SH}" "-c" "/abs/name a b \"$@\"" "--" "store""#),
100+
"a shell is used as well because there are arguments, and we don't do splitting ourselves. On windows, this can be a problem."
41101
);
42102
}

0 commit comments

Comments
 (0)