1use std::io::Write;
16use std::process::{Command, Stdio};
17
18use crate::helper::{Credentials, Helper, HelperError};
19use crate::query::Query;
20use crate::trace::trace_enabled;
21
22#[derive(Debug, Clone)]
34pub struct GitCredentialHelper {
35 git_program: String,
36 protect_protocol: bool,
37}
38
39impl Default for GitCredentialHelper {
40 fn default() -> Self {
41 Self {
42 git_program: "git".to_owned(),
43 protect_protocol: true,
44 }
45 }
46}
47
48impl GitCredentialHelper {
49 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn with_program(git_program: impl Into<String>) -> Self {
59 Self {
60 git_program: git_program.into(),
61 protect_protocol: true,
62 }
63 }
64
65 pub fn with_protect_protocol(mut self, protect: bool) -> Self {
71 self.protect_protocol = protect;
72 self
73 }
74
75 fn run(&self, subcommand: &str, query: &Query) -> Result<String, HelperError> {
76 if trace_enabled() {
81 let mut e = std::io::stderr().lock();
82 let _ = writeln!(
83 e,
84 "creds: git credential {subcommand} ({:?}, {:?}, {:?})",
85 query.protocol, query.host, query.path,
86 );
87 }
88 let mut child = Command::new(&self.git_program)
89 .args(["credential", subcommand])
90 .stdin(Stdio::piped())
91 .stdout(Stdio::piped())
92 .stderr(Stdio::piped())
93 .spawn()?;
94
95 {
96 let stdin = child
97 .stdin
98 .as_mut()
99 .ok_or_else(|| HelperError::Failed("git stdin unavailable".into()))?;
100 write_input(stdin, query, None, self.protect_protocol)?;
101 }
102
103 let out = child.wait_with_output()?;
104 if !out.status.success() {
105 if subcommand == "fill" && out.status.code() == Some(128) {
109 return Ok(String::new());
110 }
111 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
112 return Err(HelperError::Failed(format!(
113 "git credential {subcommand} exited {}: {stderr}",
114 out.status,
115 )));
116 }
117 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
118 }
119}
120
121impl Helper for GitCredentialHelper {
122 fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
123 let stdout = self.run("fill", query)?;
124 Ok(parse_response(&stdout))
125 }
126
127 fn approve(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
128 let mut child = spawn(&self.git_program, "approve")?;
129 if let Some(stdin) = child.stdin.as_mut() {
130 write_input(stdin, query, Some(creds), self.protect_protocol)?;
131 }
132 let out = child.wait_with_output()?;
133 if !out.status.success() {
134 return Err(HelperError::Failed(format!(
135 "git credential approve exited {}: {}",
136 out.status,
137 String::from_utf8_lossy(&out.stderr).trim(),
138 )));
139 }
140 Ok(())
141 }
142
143 fn reject(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
144 let mut child = spawn(&self.git_program, "reject")?;
145 if let Some(stdin) = child.stdin.as_mut() {
146 write_input(stdin, query, Some(creds), self.protect_protocol)?;
147 }
148 let out = child.wait_with_output()?;
149 if !out.status.success() {
150 return Err(HelperError::Failed(format!(
151 "git credential reject exited {}: {}",
152 out.status,
153 String::from_utf8_lossy(&out.stderr).trim(),
154 )));
155 }
156 Ok(())
157 }
158}
159
160fn spawn(program: &str, subcommand: &str) -> Result<std::process::Child, HelperError> {
161 Command::new(program)
162 .args(["credential", subcommand])
163 .stdin(Stdio::piped())
164 .stdout(Stdio::piped())
165 .stderr(Stdio::piped())
166 .spawn()
167 .map_err(HelperError::Io)
168}
169
170fn write_input(
171 sink: &mut impl Write,
172 query: &Query,
173 creds: Option<&Credentials>,
174 protect_protocol: bool,
175) -> Result<(), HelperError> {
176 write_field(sink, "protocol", &query.protocol, protect_protocol)?;
177 write_field(sink, "host", &query.host, protect_protocol)?;
178 write_field(sink, "path", &query.path, protect_protocol)?;
179 if let Some(c) = creds {
180 write_field(sink, "username", &c.username, protect_protocol)?;
181 validate_value("password", &c.password, protect_protocol)?;
184 writeln!(sink, "password={}", c.password)?;
185 }
186 writeln!(sink)?;
188 Ok(())
189}
190
191fn write_field(
196 sink: &mut impl Write,
197 key: &str,
198 value: &str,
199 protect_protocol: bool,
200) -> Result<(), HelperError> {
201 if value.is_empty() {
202 return Ok(());
203 }
204 validate_value(key, value, protect_protocol)?;
205 writeln!(sink, "{key}={value}")?;
206 Ok(())
207}
208
209fn validate_value(key: &str, value: &str, protect_protocol: bool) -> Result<(), HelperError> {
221 if value.contains('\n') {
222 return Err(HelperError::Failed(format!(
223 "credential value for {key} contains newline: {value:?}"
224 )));
225 }
226 if value.contains('\0') {
227 return Err(HelperError::Failed(format!(
228 "credential value for {key} contains null byte: {value:?}"
229 )));
230 }
231 if protect_protocol && value.contains('\r') {
232 return Err(HelperError::Failed(format!(
233 "credential value for {key} contains carriage return: {value:?}\n\
234 If this is intended, set `credential.protectProtocol=false`"
235 )));
236 }
237 Ok(())
238}
239
240fn parse_response(stdout: &str) -> Option<Credentials> {
246 let mut username = String::new();
247 let mut password: Option<String> = None;
248 for line in stdout.lines() {
249 let Some((k, v)) = line.split_once('=') else {
250 continue;
251 };
252 match k {
253 "username" => username = v.to_owned(),
254 "password" => password = Some(v.to_owned()),
255 _ => {}
256 }
257 }
258 password.map(|p| Credentials::new(username, p))
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn parse_response_extracts_credentials() {
267 let out = "protocol=https\nhost=git.example.com\nusername=alice\npassword=hunter2\n";
268 assert_eq!(
269 parse_response(out),
270 Some(Credentials::new("alice", "hunter2")),
271 );
272 }
273
274 #[test]
275 fn parse_response_returns_none_without_password() {
276 let out = "protocol=https\nhost=git.example.com\nusername=alice\n";
279 assert_eq!(parse_response(out), None);
280 }
281
282 #[test]
283 fn parse_response_allows_empty_username_with_token_password() {
284 let out = "password=ghp_token\n";
285 assert_eq!(parse_response(out), Some(Credentials::new("", "ghp_token")));
286 }
287
288 #[test]
289 fn write_input_rejects_newline_in_path() {
290 let q = Query {
291 protocol: "https".into(),
292 host: "h.example".into(),
293 path: "evil\nrepo".into(),
294 };
295 let mut buf = Vec::new();
296 let err = write_input(&mut buf, &q, None, true).unwrap_err();
297 assert!(
298 matches!(&err, HelperError::Failed(m) if m.contains("contains newline")),
299 "got {err:?}"
300 );
301 }
302
303 #[test]
304 fn write_input_rejects_null_byte_even_when_protection_off() {
305 let q = Query {
306 protocol: "https".into(),
307 host: "h.example".into(),
308 path: "evil\0repo".into(),
309 };
310 let mut buf = Vec::new();
311 let err = write_input(&mut buf, &q, None, false).unwrap_err();
312 assert!(
313 matches!(&err, HelperError::Failed(m) if m.contains("contains null byte")),
314 "got {err:?}"
315 );
316 }
317
318 #[test]
319 fn write_input_rejects_carriage_return_by_default() {
320 let q = Query {
321 protocol: "https".into(),
322 host: "h.example".into(),
323 path: "evil\rrepo".into(),
324 };
325 let mut buf = Vec::new();
326 let err = write_input(&mut buf, &q, None, true).unwrap_err();
327 assert!(
328 matches!(&err, HelperError::Failed(m) if m.contains("contains carriage return")),
329 "got {err:?}"
330 );
331 }
332
333 #[test]
334 fn write_input_allows_carriage_return_when_protection_off() {
335 let q = Query {
336 protocol: "https".into(),
337 host: "h.example".into(),
338 path: "evil\rrepo".into(),
339 };
340 let mut buf = Vec::new();
341 write_input(&mut buf, &q, None, false).unwrap();
342 let s = String::from_utf8(buf).unwrap();
343 assert!(s.contains("path=evil\rrepo\n"));
344 }
345
346 #[test]
347 fn write_input_skips_empty_fields() {
348 let q = Query {
349 protocol: "https".into(),
350 host: "h.example".into(),
351 path: String::new(),
352 };
353 let mut buf = Vec::new();
354 write_input(&mut buf, &q, None, true).unwrap();
355 let s = String::from_utf8(buf).unwrap();
356 assert!(!s.contains("path="));
357 assert!(s.contains("protocol=https\n"));
358 assert!(s.contains("host=h.example\n"));
359 }
360}