1use std::io::Write;
30use std::path::PathBuf;
31use std::process::{Command, Stdio};
32
33use crate::config::{parse_bool, ConfigSet};
34use crate::error::{Error, Result};
35
36pub const NON_INTERACTIVE_MESSAGE: &str = "credentials required but unavailable (non-interactive)";
40
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct Credential {
51 pub protocol: Option<String>,
53 pub host: Option<String>,
55 pub path: Option<String>,
57 pub username: Option<String>,
59 pub password: Option<String>,
61 pub url: Option<String>,
63 pub extra: Vec<(String, String)>,
66}
67
68impl Credential {
69 pub fn parse(input: &str) -> Self {
75 let mut cred = Credential::default();
76 for line in input.lines() {
77 let line = line.trim_end_matches('\r');
78 if line.is_empty() {
79 break;
80 }
81 let Some((key, value)) = line.split_once('=') else {
82 continue;
83 };
84 cred.set(key, value);
85 }
86 cred
87 }
88
89 pub fn parse_bytes(bytes: &[u8]) -> Self {
91 Self::parse(&String::from_utf8_lossy(bytes))
92 }
93
94 pub fn serialize(&self) -> String {
100 let mut out = String::new();
101 for (key, value) in self.iter_pairs() {
102 out.push_str(&key);
103 out.push('=');
104 out.push_str(&value);
105 out.push('\n');
106 }
107 out
108 }
109
110 fn set(&mut self, key: &str, value: &str) {
114 match key {
115 "protocol" => self.protocol = Some(value.to_string()),
116 "host" => self.host = Some(value.to_string()),
117 "path" => self.path = Some(value.to_string()),
118 "username" => self.username = Some(value.to_string()),
119 "password" => self.password = Some(value.to_string()),
120 "url" => self.url = Some(value.to_string()),
121 _ => {
122 if key.ends_with("[]") {
123 self.extra.push((key.to_string(), value.to_string()));
124 } else if let Some(slot) =
125 self.extra.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
126 {
127 *slot = value.to_string();
128 } else {
129 self.extra.push((key.to_string(), value.to_string()));
130 }
131 }
132 }
133 }
134
135 fn extra_get(&self, key: &str) -> Option<&str> {
137 self.extra
138 .iter()
139 .find(|(k, _)| k == key)
140 .map(|(_, v)| v.as_str())
141 }
142
143 fn iter_pairs(&self) -> Vec<(String, String)> {
145 let mut pairs = Vec::new();
146 if let Some(v) = &self.protocol {
147 pairs.push(("protocol".to_string(), v.clone()));
148 }
149 if let Some(v) = &self.host {
150 pairs.push(("host".to_string(), v.clone()));
151 }
152 if let Some(v) = &self.path {
153 pairs.push(("path".to_string(), v.clone()));
154 }
155 if let Some(v) = &self.username {
156 pairs.push(("username".to_string(), v.clone()));
157 }
158 if let Some(v) = &self.password {
159 pairs.push(("password".to_string(), v.clone()));
160 }
161 if let Some(v) = &self.url {
162 pairs.push(("url".to_string(), v.clone()));
163 }
164 for (k, v) in &self.extra {
165 pairs.push((k.clone(), v.clone()));
166 }
167 pairs
168 }
169
170 pub fn is_complete(&self) -> bool {
172 self.username.as_deref().is_some_and(|s| !s.is_empty())
173 && self.password.as_deref().is_some_and(|s| !s.is_empty())
174 }
175
176 pub fn target_url(&self) -> Option<String> {
181 if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
182 return Some(u.to_string());
183 }
184 let protocol = self.protocol.as_deref()?;
185 let host = self.host.as_deref()?;
186 let mut url = format!("{protocol}://");
187 if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
188 url.push_str(username);
189 url.push('@');
190 }
191 url.push_str(host);
192 if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
193 if !path.starts_with('/') {
194 url.push('/');
195 }
196 url.push_str(path);
197 }
198 Some(url)
199 }
200
201 fn merge_response(&mut self, response: &Credential) {
206 if self.username.is_none() {
207 self.username = response.username.clone();
208 }
209 if self.password.is_none() {
210 self.password = response.password.clone();
211 }
212 if self.protocol.is_none() {
213 self.protocol = response.protocol.clone();
214 }
215 if self.host.is_none() {
216 self.host = response.host.clone();
217 }
218 if self.path.is_none() {
219 self.path = response.path.clone();
220 }
221 for (k, v) in &response.extra {
222 if k == "quit" {
224 self.set(k, v);
225 }
226 }
227 }
228
229 fn wants_quit(&self) -> bool {
231 matches!(self.extra_get("quit"), Some("1") | Some("true"))
232 }
233}
234
235pub trait CredentialProvider {
243 fn fill(&self, input: &Credential) -> Result<Credential>;
248
249 fn approve(&self, cred: &Credential) -> Result<()>;
251
252 fn reject(&self, cred: &Credential) -> Result<()>;
254}
255
256pub struct HelperCredentialProvider {
269 config: ConfigSet,
270}
271
272impl HelperCredentialProvider {
273 pub fn new(config: ConfigSet) -> Self {
275 Self { config }
276 }
277
278 fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
280 credential_helpers(&self.config, target_url)
281 }
282}
283
284impl CredentialProvider for HelperCredentialProvider {
285 fn fill(&self, input: &Credential) -> Result<Credential> {
286 let mut filled = input.clone();
287 if filled.is_complete() {
288 return Ok(filled);
289 }
290 let target_url = filled.target_url();
291 for helper in self.helpers(target_url.as_deref()) {
292 let response = invoke_helper(&helper, "get", &filled)?;
293 if response.wants_quit() {
294 return Err(Error::Message(format!(
295 "credential helper '{helper}' told us to quit"
296 )));
297 }
298 filled.merge_response(&response);
299 if filled.is_complete() {
300 return Ok(filled);
301 }
302 }
303 Err(Error::Message(NON_INTERACTIVE_MESSAGE.to_string()))
306 }
307
308 fn approve(&self, cred: &Credential) -> Result<()> {
309 let target_url = cred.target_url();
310 for helper in self.helpers(target_url.as_deref()) {
311 invoke_helper(&helper, "store", cred)?;
312 }
313 Ok(())
314 }
315
316 fn reject(&self, cred: &Credential) -> Result<()> {
317 let target_url = cred.target_url();
318 for helper in self.helpers(target_url.as_deref()) {
319 invoke_helper(&helper, "erase", cred)?;
320 }
321 Ok(())
322 }
323}
324
325fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
338 let mut out = Vec::new();
339 for entry in config.entries() {
340 let key = &entry.key;
341 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
342 continue;
343 }
344 let Some(first_dot) = key.find('.') else {
345 continue;
346 };
347 let Some(last_dot) = key.rfind('.') else {
348 continue;
349 };
350 let section = &key[..first_dot];
351 let variable = &key[last_dot + 1..];
352 if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
353 continue;
354 }
355 if first_dot != last_dot {
356 let subsection = &key[first_dot + 1..last_dot];
357 if percent_decode_lossy(subsection).contains('\n') {
358 continue;
359 }
360 let Some(target) = target_url else {
361 continue;
362 };
363 if !credential_url_matches(subsection, target) {
364 continue;
365 }
366 }
367 let value = entry.value.as_deref().unwrap_or("");
368 if value.trim().is_empty() {
369 out.clear();
370 } else {
371 out.push(value.to_string());
372 }
373 }
374 out
375}
376
377fn credential_url_matches(pattern: &str, target: &str) -> bool {
378 let pattern = percent_decode_lossy(pattern);
379 if pattern.contains('\n') {
380 return false;
381 }
382 let pattern = pattern.trim_end_matches('/');
383 let pattern_no_user = strip_url_userinfo(pattern);
384 let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
385 let target = target.trim_end_matches('/');
386 let target_no_user = strip_url_userinfo(target);
387 let target_no_scheme = strip_url_scheme(target);
388 let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
389 let target_path = target_path_component(target);
390
391 let matches = |pattern: &str| {
392 if pattern.starts_with('/') {
393 return credential_prefix_matches(pattern, target_path);
394 }
395 if pattern.ends_with("://") {
396 return target.starts_with(pattern) || target_no_user.starts_with(pattern);
397 }
398 if pattern.contains('*') {
399 return credential_wildcard_matches(pattern, target)
400 || credential_wildcard_matches(pattern, &target_no_user)
401 || credential_wildcard_matches(pattern, target_no_scheme)
402 || credential_wildcard_matches(pattern, target_no_scheme_no_user);
403 }
404 credential_prefix_matches(pattern, target)
405 || credential_prefix_matches(pattern, &target_no_user)
406 || credential_prefix_matches(pattern, target_no_scheme)
407 || credential_prefix_matches(pattern, target_no_scheme_no_user)
408 };
409 matches(pattern)
410 || (pattern_no_user != pattern && matches(&pattern_no_user))
411 || pattern_after_user.is_some_and(matches)
412}
413
414fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
415 candidate
416 .strip_prefix(pattern)
417 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
418}
419
420fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
421 let Some((prefix, suffix)) = pattern.split_once('*') else {
422 return false;
423 };
424 let Some(rest) = candidate.strip_prefix(prefix) else {
425 return false;
426 };
427 rest.find(suffix).is_some_and(|idx| {
428 let after = &rest[idx + suffix.len()..];
429 after.is_empty() || after.starts_with('/')
430 })
431}
432
433fn strip_url_scheme(url: &str) -> &str {
434 url.split_once("://").map_or(url, |(_, rest)| rest)
435}
436
437fn strip_url_userinfo(url: &str) -> String {
438 let Some((scheme, rest)) = url.split_once("://") else {
439 return url
440 .rsplit_once('@')
441 .map_or(url, |(_, host)| host)
442 .to_string();
443 };
444 rest.rsplit_once('@')
445 .map_or_else(|| url.to_string(), |(_, host)| format!("{scheme}://{host}"))
446}
447
448fn target_path_component(url: &str) -> &str {
449 let rest = strip_url_scheme(url);
450 let idx = rest
451 .char_indices()
452 .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
453 .unwrap_or(rest.len());
454 &rest[idx..]
455}
456
457fn percent_decode_lossy(input: &str) -> String {
458 let mut out = Vec::with_capacity(input.len());
459 let bytes = input.as_bytes();
460 let mut idx = 0;
461 while idx < bytes.len() {
462 if bytes[idx] == b'%' && idx + 2 < bytes.len() {
463 if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
464 out.push((hi << 4) | lo);
465 idx += 3;
466 continue;
467 }
468 }
469 out.push(bytes[idx]);
470 idx += 1;
471 }
472 String::from_utf8_lossy(&out).into_owned()
473}
474
475fn hex_value(byte: u8) -> Option<u8> {
476 match byte {
477 b'0'..=b'9' => Some(byte - b'0'),
478 b'a'..=b'f' => Some(byte - b'a' + 10),
479 b'A'..=b'F' => Some(byte - b'A' + 10),
480 _ => None,
481 }
482}
483
484fn credential_helper_exec_path_candidates() -> Vec<PathBuf> {
488 let mut v = Vec::new();
489 if let Ok(ep) = std::env::var("GIT_EXEC_PATH") {
490 let p = PathBuf::from(ep.trim());
491 if p.is_dir() {
492 v.push(p);
493 }
494 }
495 for candidate in [
496 "/usr/libexec/git-core",
497 "/Library/Developer/CommandLineTools/usr/libexec/git-core",
498 "/opt/homebrew/opt/git/libexec/git-core",
499 "/opt/homebrew/libexec/git-core",
500 "/usr/lib/git-core",
501 "/usr/local/libexec/git-core",
502 ] {
503 let p = PathBuf::from(candidate);
504 if p.is_dir() {
505 v.push(p);
506 }
507 }
508 v
509}
510
511fn resolve_credential_helper_executable(helper_program: &str) -> PathBuf {
515 if helper_program.contains('/') {
516 return PathBuf::from(helper_program);
517 }
518 if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
519 let exe_name = format!("git-credential-{suffix}");
520 for ep in credential_helper_exec_path_candidates() {
521 let candidate = ep.join(&exe_name);
522 if candidate.is_file() {
523 return candidate;
524 }
525 }
526 }
527 PathBuf::from(helper_program)
528}
529
530fn invoke_helper(helper: &str, action: &str, creds: &Credential) -> Result<Credential> {
547 let helper_words = shell_words::split(helper)
548 .map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
549 let (first_word, extra_args) = match helper_words.split_first() {
550 Some((first, rest)) => (first.as_str(), rest),
551 None => ("", &[][..]),
552 };
553
554 let mut child = if let Some(shell_cmd) = helper.strip_prefix('!') {
555 Command::new("sh")
556 .arg("-c")
557 .arg(format!("{shell_cmd} {action}"))
558 .stdin(Stdio::piped())
559 .stdout(Stdio::piped())
560 .stderr(Stdio::inherit())
561 .spawn()
562 .map_err(|e| {
563 Error::Message(format!("failed to run credential helper shell '{helper}': {e}"))
564 })?
565 } else if matches!(
566 first_word,
567 "store" | "cache" | "git-credential-store" | "git-credential-cache"
568 ) {
569 let subcmd = if first_word.ends_with("store") {
570 "credential-store"
571 } else {
572 "credential-cache"
573 };
574 let exe = std::env::current_exe()
575 .map_err(|e| Error::Message(format!("resolve current executable: {e}")))?;
576 let mut cmd = Command::new(exe);
577 cmd.arg(subcmd);
578 for arg in extra_args {
579 cmd.arg(arg);
580 }
581 cmd.arg(action);
582 cmd.stdin(Stdio::piped())
583 .stdout(Stdio::piped())
584 .stderr(Stdio::inherit())
585 .spawn()
586 .map_err(|e| {
587 Error::Message(format!("failed to run built-in credential helper '{subcmd}': {e}"))
588 })?
589 } else {
590 let helper_program = if first_word.contains('/') || first_word.starts_with("git-credential-")
591 {
592 first_word.to_string()
594 } else {
595 format!("git-credential-{first_word}")
597 };
598 let resolved = resolve_credential_helper_executable(&helper_program);
599 let mut cmd = Command::new(&resolved);
600 for arg in extra_args {
601 cmd.arg(arg);
602 }
603 cmd.arg(action);
604 cmd.stdin(Stdio::piped())
605 .stdout(Stdio::piped())
606 .stderr(Stdio::inherit())
607 .spawn()
608 .map_err(|e| {
609 Error::Message(format!("failed to run credential helper '{helper_program}': {e}"))
610 })?
611 };
612
613 {
614 let stdin = child
615 .stdin
616 .as_mut()
617 .ok_or_else(|| Error::Message("credential helper missing stdin".to_string()))?;
618 stdin.write_all(creds.serialize().as_bytes())?;
619 stdin.write_all(b"\n")?;
621 }
622
623 let output = child
624 .wait_with_output()
625 .map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
626 if !output.status.success() {
627 return Err(Error::Message(format!(
628 "credential helper '{helper}' exited with status {}",
629 output.status
630 )));
631 }
632
633 Ok(Credential::parse_bytes(&output.stdout))
634}
635
636pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
641 credential_config_value(config, target_url, "useHttpPath")
642 .as_deref()
643 .map(|value| parse_bool(value).unwrap_or(false))
644 .unwrap_or(false)
645}
646
647fn credential_config_value(
648 config: &ConfigSet,
649 target_url: Option<&str>,
650 variable_name: &str,
651) -> Option<String> {
652 let mut out = None;
653 for entry in config.entries() {
654 let key = &entry.key;
655 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
656 continue;
657 }
658 let Some(first_dot) = key.find('.') else {
659 continue;
660 };
661 let Some(last_dot) = key.rfind('.') else {
662 continue;
663 };
664 let section = &key[..first_dot];
665 let variable = &key[last_dot + 1..];
666 if !section.eq_ignore_ascii_case("credential")
667 || !variable.eq_ignore_ascii_case(variable_name)
668 {
669 continue;
670 }
671 if first_dot != last_dot {
672 let subsection = &key[first_dot + 1..last_dot];
673 if percent_decode_lossy(subsection).contains('\n') {
674 continue;
675 }
676 let Some(target) = target_url else {
677 continue;
678 };
679 if !credential_url_matches(subsection, target) {
680 continue;
681 }
682 }
683 out = entry.value.clone();
684 }
685 out
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn parse_round_trips_named_fields() {
694 let input = "protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
695 let cred = Credential::parse(input);
696 assert_eq!(cred.protocol.as_deref(), Some("https"));
697 assert_eq!(cred.host.as_deref(), Some("example.com"));
698 assert_eq!(cred.username.as_deref(), Some("alice"));
699 assert_eq!(cred.password.as_deref(), Some("secret"));
700 assert!(cred.extra.is_empty());
702 }
703
704 #[test]
705 fn serialize_uses_canonical_order() {
706 let cred = Credential {
707 protocol: Some("https".into()),
708 host: Some("h".into()),
709 username: Some("u".into()),
710 password: Some("p".into()),
711 ..Default::default()
712 };
713 assert_eq!(cred.serialize(), "protocol=https\nhost=h\nusername=u\npassword=p\n");
714 }
715
716 #[test]
717 fn target_url_reconstructed_from_fields() {
718 let cred = Credential {
719 protocol: Some("https".into()),
720 host: Some("github.com".into()),
721 path: Some("o/r.git".into()),
722 ..Default::default()
723 };
724 assert_eq!(cred.target_url().as_deref(), Some("https://github.com/o/r.git"));
725 }
726}