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) = self
125 .extra
126 .iter_mut()
127 .find(|(k, _)| k == key)
128 .map(|(_, v)| v)
129 {
130 *slot = value.to_string();
131 } else {
132 self.extra.push((key.to_string(), value.to_string()));
133 }
134 }
135 }
136 }
137
138 fn extra_get(&self, key: &str) -> Option<&str> {
140 self.extra
141 .iter()
142 .find(|(k, _)| k == key)
143 .map(|(_, v)| v.as_str())
144 }
145
146 fn iter_pairs(&self) -> Vec<(String, String)> {
148 let mut pairs = Vec::new();
149 if let Some(v) = &self.protocol {
150 pairs.push(("protocol".to_string(), v.clone()));
151 }
152 if let Some(v) = &self.host {
153 pairs.push(("host".to_string(), v.clone()));
154 }
155 if let Some(v) = &self.path {
156 pairs.push(("path".to_string(), v.clone()));
157 }
158 if let Some(v) = &self.username {
159 pairs.push(("username".to_string(), v.clone()));
160 }
161 if let Some(v) = &self.password {
162 pairs.push(("password".to_string(), v.clone()));
163 }
164 if let Some(v) = &self.url {
165 pairs.push(("url".to_string(), v.clone()));
166 }
167 for (k, v) in &self.extra {
168 pairs.push((k.clone(), v.clone()));
169 }
170 pairs
171 }
172
173 pub fn is_complete(&self) -> bool {
175 self.username.as_deref().is_some_and(|s| !s.is_empty())
176 && self.password.as_deref().is_some_and(|s| !s.is_empty())
177 }
178
179 pub fn target_url(&self) -> Option<String> {
184 if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
185 return Some(u.to_string());
186 }
187 let protocol = self.protocol.as_deref()?;
188 let host = self.host.as_deref()?;
189 let mut url = format!("{protocol}://");
190 if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
191 url.push_str(username);
192 url.push('@');
193 }
194 url.push_str(host);
195 if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
196 if !path.starts_with('/') {
197 url.push('/');
198 }
199 url.push_str(path);
200 }
201 Some(url)
202 }
203
204 fn merge_response(&mut self, response: &Credential) {
209 if self.username.is_none() {
210 self.username = response.username.clone();
211 }
212 if self.password.is_none() {
213 self.password = response.password.clone();
214 }
215 if self.protocol.is_none() {
216 self.protocol = response.protocol.clone();
217 }
218 if self.host.is_none() {
219 self.host = response.host.clone();
220 }
221 if self.path.is_none() {
222 self.path = response.path.clone();
223 }
224 for (k, v) in &response.extra {
225 if k == "quit" {
227 self.set(k, v);
228 }
229 }
230 }
231
232 fn wants_quit(&self) -> bool {
234 matches!(self.extra_get("quit"), Some("1") | Some("true"))
235 }
236}
237
238pub trait CredentialProvider {
246 fn fill(&self, input: &Credential) -> Result<Credential>;
251
252 fn approve(&self, cred: &Credential) -> Result<()>;
254
255 fn reject(&self, cred: &Credential) -> Result<()>;
257}
258
259pub struct HelperCredentialProvider {
272 config: ConfigSet,
273}
274
275impl HelperCredentialProvider {
276 pub fn new(config: ConfigSet) -> Self {
278 Self { config }
279 }
280
281 fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
283 credential_helpers(&self.config, target_url)
284 }
285}
286
287impl CredentialProvider for HelperCredentialProvider {
288 fn fill(&self, input: &Credential) -> Result<Credential> {
289 let mut filled = input.clone();
290 if filled.is_complete() {
291 return Ok(filled);
292 }
293 let target_url = filled.target_url();
294 for helper in self.helpers(target_url.as_deref()) {
295 let response = invoke_helper(&helper, "get", &filled)?;
296 if response.wants_quit() {
297 return Err(Error::Message(format!(
298 "credential helper '{helper}' told us to quit"
299 )));
300 }
301 filled.merge_response(&response);
302 if filled.is_complete() {
303 return Ok(filled);
304 }
305 }
306 Err(Error::Message(NON_INTERACTIVE_MESSAGE.to_string()))
309 }
310
311 fn approve(&self, cred: &Credential) -> Result<()> {
312 let target_url = cred.target_url();
313 for helper in self.helpers(target_url.as_deref()) {
314 invoke_helper(&helper, "store", cred)?;
315 }
316 Ok(())
317 }
318
319 fn reject(&self, cred: &Credential) -> Result<()> {
320 let target_url = cred.target_url();
321 for helper in self.helpers(target_url.as_deref()) {
322 invoke_helper(&helper, "erase", cred)?;
323 }
324 Ok(())
325 }
326}
327
328fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
341 let mut out = Vec::new();
342 for entry in config.entries() {
343 let key = &entry.key;
344 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
345 continue;
346 }
347 let Some(first_dot) = key.find('.') else {
348 continue;
349 };
350 let Some(last_dot) = key.rfind('.') else {
351 continue;
352 };
353 let section = &key[..first_dot];
354 let variable = &key[last_dot + 1..];
355 if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
356 continue;
357 }
358 if first_dot != last_dot {
359 let subsection = &key[first_dot + 1..last_dot];
360 if percent_decode_lossy(subsection).contains('\n') {
361 continue;
362 }
363 let Some(target) = target_url else {
364 continue;
365 };
366 if !credential_url_matches(subsection, target) {
367 continue;
368 }
369 }
370 let value = entry.value.as_deref().unwrap_or("");
371 if value.trim().is_empty() {
372 out.clear();
373 } else {
374 out.push(value.to_string());
375 }
376 }
377 out
378}
379
380fn credential_url_matches(pattern: &str, target: &str) -> bool {
381 let pattern = percent_decode_lossy(pattern);
382 if pattern.contains('\n') {
383 return false;
384 }
385 let pattern = pattern.trim_end_matches('/');
386 let pattern_no_user = strip_url_userinfo(pattern);
387 let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
388 let target = target.trim_end_matches('/');
389 let target_no_user = strip_url_userinfo(target);
390 let target_no_scheme = strip_url_scheme(target);
391 let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
392 let target_path = target_path_component(target);
393
394 let matches = |pattern: &str| {
395 if pattern.starts_with('/') {
396 return credential_prefix_matches(pattern, target_path);
397 }
398 if pattern.ends_with("://") {
399 return target.starts_with(pattern) || target_no_user.starts_with(pattern);
400 }
401 if pattern.contains('*') {
402 return credential_wildcard_matches(pattern, target)
403 || credential_wildcard_matches(pattern, &target_no_user)
404 || credential_wildcard_matches(pattern, target_no_scheme)
405 || credential_wildcard_matches(pattern, target_no_scheme_no_user);
406 }
407 credential_prefix_matches(pattern, target)
408 || credential_prefix_matches(pattern, &target_no_user)
409 || credential_prefix_matches(pattern, target_no_scheme)
410 || credential_prefix_matches(pattern, target_no_scheme_no_user)
411 };
412 matches(pattern)
413 || (pattern_no_user != pattern && matches(&pattern_no_user))
414 || pattern_after_user.is_some_and(matches)
415}
416
417fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
418 candidate
419 .strip_prefix(pattern)
420 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
421}
422
423fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
424 let Some((prefix, suffix)) = pattern.split_once('*') else {
425 return false;
426 };
427 let Some(rest) = candidate.strip_prefix(prefix) else {
428 return false;
429 };
430 rest.find(suffix).is_some_and(|idx| {
431 let after = &rest[idx + suffix.len()..];
432 after.is_empty() || after.starts_with('/')
433 })
434}
435
436fn strip_url_scheme(url: &str) -> &str {
437 url.split_once("://").map_or(url, |(_, rest)| rest)
438}
439
440fn strip_url_userinfo(url: &str) -> String {
441 let Some((scheme, rest)) = url.split_once("://") else {
442 return url
443 .rsplit_once('@')
444 .map_or(url, |(_, host)| host)
445 .to_string();
446 };
447 rest.rsplit_once('@')
448 .map_or_else(|| url.to_string(), |(_, host)| format!("{scheme}://{host}"))
449}
450
451fn target_path_component(url: &str) -> &str {
452 let rest = strip_url_scheme(url);
453 let idx = rest
454 .char_indices()
455 .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
456 .unwrap_or(rest.len());
457 &rest[idx..]
458}
459
460fn percent_decode_lossy(input: &str) -> String {
461 let mut out = Vec::with_capacity(input.len());
462 let bytes = input.as_bytes();
463 let mut idx = 0;
464 while idx < bytes.len() {
465 if bytes[idx] == b'%' && idx + 2 < bytes.len() {
466 if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
467 out.push((hi << 4) | lo);
468 idx += 3;
469 continue;
470 }
471 }
472 out.push(bytes[idx]);
473 idx += 1;
474 }
475 String::from_utf8_lossy(&out).into_owned()
476}
477
478fn hex_value(byte: u8) -> Option<u8> {
479 match byte {
480 b'0'..=b'9' => Some(byte - b'0'),
481 b'a'..=b'f' => Some(byte - b'a' + 10),
482 b'A'..=b'F' => Some(byte - b'A' + 10),
483 _ => None,
484 }
485}
486
487fn credential_helper_exec_path_candidates() -> Vec<PathBuf> {
491 let mut v = Vec::new();
492 if let Ok(ep) = std::env::var("GIT_EXEC_PATH") {
493 let p = PathBuf::from(ep.trim());
494 if p.is_dir() {
495 v.push(p);
496 }
497 }
498 for candidate in [
499 "/usr/libexec/git-core",
500 "/Library/Developer/CommandLineTools/usr/libexec/git-core",
501 "/opt/homebrew/opt/git/libexec/git-core",
502 "/opt/homebrew/libexec/git-core",
503 "/usr/lib/git-core",
504 "/usr/local/libexec/git-core",
505 ] {
506 let p = PathBuf::from(candidate);
507 if p.is_dir() {
508 v.push(p);
509 }
510 }
511 v
512}
513
514fn resolve_credential_helper_executable(helper_program: &str) -> PathBuf {
518 if helper_program.contains('/') {
519 return PathBuf::from(helper_program);
520 }
521 if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
522 let exe_name = format!("git-credential-{suffix}");
523 for ep in credential_helper_exec_path_candidates() {
524 let candidate = ep.join(&exe_name);
525 if candidate.is_file() {
526 return candidate;
527 }
528 }
529 }
530 PathBuf::from(helper_program)
531}
532
533fn invoke_helper(helper: &str, action: &str, creds: &Credential) -> Result<Credential> {
550 let helper_words = shell_words::split(helper)
551 .map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
552 let (first_word, extra_args) = match helper_words.split_first() {
553 Some((first, rest)) => (first.as_str(), rest),
554 None => ("", &[][..]),
555 };
556
557 let mut child = if let Some(shell_cmd) = helper.strip_prefix('!') {
558 Command::new("sh")
559 .arg("-c")
560 .arg(format!("{shell_cmd} {action}"))
561 .stdin(Stdio::piped())
562 .stdout(Stdio::piped())
563 .stderr(Stdio::inherit())
564 .spawn()
565 .map_err(|e| {
566 Error::Message(format!(
567 "failed to run credential helper shell '{helper}': {e}"
568 ))
569 })?
570 } else if matches!(
571 first_word,
572 "store" | "cache" | "git-credential-store" | "git-credential-cache"
573 ) {
574 let subcmd = if first_word.ends_with("store") {
575 "credential-store"
576 } else {
577 "credential-cache"
578 };
579 let exe = std::env::current_exe()
580 .map_err(|e| Error::Message(format!("resolve current executable: {e}")))?;
581 let mut cmd = Command::new(exe);
582 cmd.arg(subcmd);
583 for arg in extra_args {
584 cmd.arg(arg);
585 }
586 cmd.arg(action);
587 cmd.stdin(Stdio::piped())
588 .stdout(Stdio::piped())
589 .stderr(Stdio::inherit())
590 .spawn()
591 .map_err(|e| {
592 Error::Message(format!(
593 "failed to run built-in credential helper '{subcmd}': {e}"
594 ))
595 })?
596 } else {
597 let helper_program =
598 if first_word.contains('/') || first_word.starts_with("git-credential-") {
599 first_word.to_string()
601 } else {
602 format!("git-credential-{first_word}")
604 };
605 let resolved = resolve_credential_helper_executable(&helper_program);
606 let mut cmd = Command::new(&resolved);
607 for arg in extra_args {
608 cmd.arg(arg);
609 }
610 cmd.arg(action);
611 cmd.stdin(Stdio::piped())
612 .stdout(Stdio::piped())
613 .stderr(Stdio::inherit())
614 .spawn()
615 .map_err(|e| {
616 Error::Message(format!(
617 "failed to run credential helper '{helper_program}': {e}"
618 ))
619 })?
620 };
621
622 {
623 let stdin = child
624 .stdin
625 .as_mut()
626 .ok_or_else(|| Error::Message("credential helper missing stdin".to_string()))?;
627 stdin.write_all(creds.serialize().as_bytes())?;
628 stdin.write_all(b"\n")?;
630 }
631
632 let output = child
633 .wait_with_output()
634 .map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
635 if !output.status.success() {
636 return Err(Error::Message(format!(
637 "credential helper '{helper}' exited with status {}",
638 output.status
639 )));
640 }
641
642 Ok(Credential::parse_bytes(&output.stdout))
643}
644
645pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
650 credential_config_value(config, target_url, "useHttpPath")
651 .as_deref()
652 .map(|value| parse_bool(value).unwrap_or(false))
653 .unwrap_or(false)
654}
655
656fn credential_config_value(
657 config: &ConfigSet,
658 target_url: Option<&str>,
659 variable_name: &str,
660) -> Option<String> {
661 let mut out = None;
662 for entry in config.entries() {
663 let key = &entry.key;
664 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
665 continue;
666 }
667 let Some(first_dot) = key.find('.') else {
668 continue;
669 };
670 let Some(last_dot) = key.rfind('.') else {
671 continue;
672 };
673 let section = &key[..first_dot];
674 let variable = &key[last_dot + 1..];
675 if !section.eq_ignore_ascii_case("credential")
676 || !variable.eq_ignore_ascii_case(variable_name)
677 {
678 continue;
679 }
680 if first_dot != last_dot {
681 let subsection = &key[first_dot + 1..last_dot];
682 if percent_decode_lossy(subsection).contains('\n') {
683 continue;
684 }
685 let Some(target) = target_url else {
686 continue;
687 };
688 if !credential_url_matches(subsection, target) {
689 continue;
690 }
691 }
692 out = entry.value.clone();
693 }
694 out
695}
696
697#[cfg(windows)]
710pub mod windows_store {
711 use std::ffi::OsStr;
712 use std::os::windows::ffi::OsStrExt;
713
714 use windows_sys::Win32::Foundation::{GetLastError, ERROR_NOT_FOUND};
715 use windows_sys::Win32::Security::Credentials::{
716 CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_LOCAL_MACHINE,
717 CRED_TYPE_GENERIC,
718 };
719
720 use super::Credential;
721 use crate::error::{Error, Result};
722
723 fn target_name(cred: &Credential) -> Result<String> {
725 let protocol = cred
726 .protocol
727 .as_deref()
728 .filter(|p| !p.is_empty())
729 .ok_or_else(|| Error::Message("credential is missing its protocol".to_owned()))?;
730 let host = cred
731 .host
732 .as_deref()
733 .filter(|h| !h.is_empty())
734 .ok_or_else(|| Error::Message("credential is missing its host".to_owned()))?;
735 Ok(format!("git:{protocol}://{host}"))
736 }
737
738 fn wide(s: &str) -> Vec<u16> {
740 OsStr::new(s)
741 .encode_wide()
742 .chain(std::iter::once(0))
743 .collect()
744 }
745
746 unsafe fn wstr_to_string(ptr: *const u16) -> String {
752 if ptr.is_null() {
753 return String::new();
754 }
755 let mut len = 0usize;
756 while *ptr.add(len) != 0 {
757 len += 1;
758 }
759 String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
760 }
761
762 pub fn get(cred: &Credential) -> Result<Option<Credential>> {
764 let target = wide(&target_name(cred)?);
765 let mut pcred: *mut CREDENTIALW = std::ptr::null_mut();
766 let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut pcred) };
769 if ok == 0 {
770 let err = unsafe { GetLastError() };
772 if err == ERROR_NOT_FOUND {
773 return Ok(None);
774 }
775 return Err(Error::Message(format!(
776 "reading from the Windows Credential Manager failed (error {err})"
777 )));
778 }
779
780 let (username, password) = unsafe {
783 let c = &*pcred;
784 let username = wstr_to_string(c.UserName);
785 let password = if c.CredentialBlob.is_null() || c.CredentialBlobSize == 0 {
786 String::new()
787 } else {
788 let bytes =
789 std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize);
790 String::from_utf8_lossy(bytes).into_owned()
791 };
792 (username, password)
793 };
794 unsafe { CredFree(pcred as *const core::ffi::c_void) };
796
797 let mut out = Credential::default();
798 if !username.is_empty() {
799 out.username = Some(username);
800 }
801 if !password.is_empty() {
802 out.password = Some(password);
803 }
804 Ok(Some(out))
805 }
806
807 pub fn store(cred: &Credential) -> Result<()> {
810 let password = match cred.password.as_deref() {
811 Some(p) if !p.is_empty() => p,
812 _ => return Ok(()),
813 };
814 let mut target = wide(&target_name(cred)?);
815 let mut username = wide(cred.username.as_deref().unwrap_or(""));
816 let blob = password.as_bytes();
817
818 let mut credential: CREDENTIALW = unsafe { std::mem::zeroed() };
821 credential.Type = CRED_TYPE_GENERIC;
822 credential.TargetName = target.as_mut_ptr();
823 credential.CredentialBlobSize = blob.len() as u32;
824 credential.CredentialBlob = blob.as_ptr() as *mut u8;
825 credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
826 credential.UserName = username.as_mut_ptr();
827
828 let ok = unsafe { CredWriteW(&credential as *const CREDENTIALW, 0) };
830 if ok == 0 {
831 let err = unsafe { GetLastError() };
833 return Err(Error::Message(format!(
834 "writing to the Windows Credential Manager failed (error {err})"
835 )));
836 }
837 Ok(())
838 }
839
840 pub fn erase(cred: &Credential) -> Result<()> {
842 let target = wide(&target_name(cred)?);
843 let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
845 if ok == 0 {
846 let err = unsafe { GetLastError() };
848 if err != ERROR_NOT_FOUND {
849 return Err(Error::Message(format!(
850 "deleting from the Windows Credential Manager failed (error {err})"
851 )));
852 }
853 }
854 Ok(())
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861
862 #[test]
863 fn parse_round_trips_named_fields() {
864 let input =
865 "protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
866 let cred = Credential::parse(input);
867 assert_eq!(cred.protocol.as_deref(), Some("https"));
868 assert_eq!(cred.host.as_deref(), Some("example.com"));
869 assert_eq!(cred.username.as_deref(), Some("alice"));
870 assert_eq!(cred.password.as_deref(), Some("secret"));
871 assert!(cred.extra.is_empty());
873 }
874
875 #[test]
876 fn serialize_uses_canonical_order() {
877 let cred = Credential {
878 protocol: Some("https".into()),
879 host: Some("h".into()),
880 username: Some("u".into()),
881 password: Some("p".into()),
882 ..Default::default()
883 };
884 assert_eq!(
885 cred.serialize(),
886 "protocol=https\nhost=h\nusername=u\npassword=p\n"
887 );
888 }
889
890 #[test]
891 fn target_url_reconstructed_from_fields() {
892 let cred = Credential {
893 protocol: Some("https".into()),
894 host: Some("github.com".into()),
895 path: Some("o/r.git".into()),
896 ..Default::default()
897 };
898 assert_eq!(
899 cred.target_url().as_deref(),
900 Some("https://github.com/o/r.git")
901 );
902 }
903}