1use std::collections::HashSet;
38use std::ffi::OsString;
39use std::path::{Path, PathBuf};
40use std::sync::{Arc, OnceLock};
41
42use crate::command_runner::{
43 system_command_runner, CommandEnvironment, CommandRunner, CommandSpec, CommandStdin,
44 CommandStdio, ShellInvocation,
45};
46use crate::config::{parse_bool, parse_path, ConfigSet};
47use crate::environment::Environment;
48use crate::error::{Error, Result};
49
50pub const NON_INTERACTIVE_MESSAGE: &str = "credentials required but unavailable (non-interactive)";
54
55#[must_use]
74#[cfg_attr(not(feature = "http-ureq"), allow(dead_code))]
75pub(crate) fn askpass_program(env: &Environment, config: &ConfigSet) -> Option<PathBuf> {
76 let program = env
77 .var_os("GIT_ASKPASS")
78 .or_else(|| {
79 config
80 .get("core.askPass")
81 .map(|value| parse_path(env, &value).into())
82 })
83 .or_else(|| env.var_os("SSH_ASKPASS"))?;
84 (!program.is_empty()).then(|| PathBuf::from(program))
85}
86
87#[derive(Clone, Debug, Default, PartialEq, Eq)]
96pub struct Credential {
97 pub protocol: Option<String>,
99 pub host: Option<String>,
101 pub path: Option<String>,
103 pub username: Option<String>,
105 pub password: Option<String>,
107 pub url: Option<String>,
114 pub extra: Vec<(String, String)>,
117}
118
119impl Credential {
120 pub fn parse(input: &str) -> Self {
126 let mut cred = Credential::default();
127 for line in input.lines() {
128 let line = line.trim_end_matches('\r');
129 if line.is_empty() {
130 break;
131 }
132 let Some((key, value)) = line.split_once('=') else {
133 continue;
134 };
135 cred.set(key, value);
136 }
137 cred
138 }
139
140 #[must_use]
165 pub fn from_url(url: &str, config: &ConfigSet) -> Option<Self> {
166 let parsed = url::Url::parse(url).ok()?;
167 let host = parsed.host_str().filter(|host| !host.is_empty())?;
168 let host = match parsed.port() {
169 Some(port) => format!("{host}:{port}"),
170 None => host.to_owned(),
171 };
172 let protocol = parsed.scheme();
173 let url = url_without_userinfo(url);
174 let is_http = matches!(protocol, "http" | "https");
175 let path = (!is_http || use_http_path(config, Some(&url)))
176 .then(|| percent_decode_lossy(parsed.path().trim_matches('/')))
177 .filter(|path| !path.is_empty());
178 let username = Some(parsed.username())
179 .filter(|user| !user.is_empty())
180 .map(percent_decode_lossy);
181 Some(Credential {
182 protocol: Some(protocol.to_owned()),
183 host: Some(host),
184 path,
185 password: parsed.password().map(percent_decode_lossy),
186 username,
187 url: Some(url),
188 ..Default::default()
189 })
190 }
191
192 pub fn parse_bytes(bytes: &[u8]) -> Self {
194 Self::parse(&String::from_utf8_lossy(bytes))
195 }
196
197 pub fn serialize(&self) -> String {
203 let mut out = String::new();
204 for (key, value) in self.iter_pairs() {
205 out.push_str(&key);
206 out.push('=');
207 out.push_str(&value);
208 out.push('\n');
209 }
210 out
211 }
212
213 fn set(&mut self, key: &str, value: &str) {
217 match key {
218 "protocol" => self.protocol = Some(value.to_string()),
219 "host" => self.host = Some(value.to_string()),
220 "path" => self.path = Some(value.to_string()),
221 "username" => self.username = Some(value.to_string()),
222 "password" => self.password = Some(value.to_string()),
223 "url" => self.url = Some(value.to_string()),
224 _ => {
225 if key.ends_with("[]") {
226 self.extra.push((key.to_string(), value.to_string()));
227 } else if let Some(slot) = self
228 .extra
229 .iter_mut()
230 .find(|(k, _)| k == key)
231 .map(|(_, v)| v)
232 {
233 *slot = value.to_string();
234 } else {
235 self.extra.push((key.to_string(), value.to_string()));
236 }
237 }
238 }
239 }
240
241 fn extra_get(&self, key: &str) -> Option<&str> {
243 self.extra
244 .iter()
245 .find(|(k, _)| k == key)
246 .map(|(_, v)| v.as_str())
247 }
248
249 fn iter_pairs(&self) -> Vec<(String, String)> {
251 let mut pairs = Vec::new();
252 if let Some(v) = &self.protocol {
253 pairs.push(("protocol".to_string(), v.clone()));
254 }
255 if let Some(v) = &self.host {
256 pairs.push(("host".to_string(), v.clone()));
257 }
258 if let Some(v) = &self.path {
259 pairs.push(("path".to_string(), v.clone()));
260 }
261 if let Some(v) = &self.username {
262 pairs.push(("username".to_string(), v.clone()));
263 }
264 if let Some(v) = &self.password {
265 pairs.push(("password".to_string(), v.clone()));
266 }
267 if let Some(v) = &self.url {
268 pairs.push(("url".to_string(), v.clone()));
269 }
270 for (k, v) in &self.extra {
271 pairs.push((k.clone(), v.clone()));
272 }
273 pairs
274 }
275
276 pub fn is_complete(&self) -> bool {
278 self.username.as_deref().is_some_and(|s| !s.is_empty())
279 && self.password.as_deref().is_some_and(|s| !s.is_empty())
280 }
281
282 pub fn target_url(&self) -> Option<String> {
287 if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
288 return Some(u.to_string());
289 }
290 let protocol = self.protocol.as_deref()?;
291 let host = self.host.as_deref()?;
292 let mut url = format!("{protocol}://");
293 if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
294 url.push_str(username);
295 url.push('@');
296 }
297 url.push_str(host);
298 if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
299 if !path.starts_with('/') {
300 url.push('/');
301 }
302 url.push_str(path);
303 }
304 Some(url)
305 }
306
307 fn prompt(&self, field: &str, sanitize: bool) -> String {
313 let Some(protocol) = self.protocol.as_deref() else {
314 return format!("{field}: ");
315 };
316 let part = |value: &str, escape: fn(u8) -> bool| {
317 if sanitize {
318 percent_encode_where(value, escape)
319 } else {
320 value.to_owned()
321 }
322 };
323 let mut target = format!("{protocol}://");
324 if let Some(user) = self.username.as_deref().filter(|u| !u.is_empty()) {
325 target.push_str(&part(user, |b| {
326 b == b'/' || URL_UNSAFE_PROMPT_BYTES.contains(&b)
327 }));
328 target.push('@');
329 }
330 if let Some(host) = self.host.as_deref() {
331 target.push_str(&part(host, |b| {
332 !b.is_ascii_alphanumeric() && !b"-.:[]".contains(&b)
333 }));
334 }
335 if let Some(path) = self.path.as_deref() {
336 target.push('/');
337 target.push_str(&part(path, |b| URL_UNSAFE_PROMPT_BYTES.contains(&b)));
338 }
339 format!("{field} for '{target}': ")
340 }
341
342 fn merge_response(&mut self, response: &Credential) {
347 if self.username.is_none() {
348 self.username = response.username.clone();
349 }
350 if self.password.is_none() {
351 self.password = response.password.clone();
352 }
353 if self.protocol.is_none() {
354 self.protocol = response.protocol.clone();
355 }
356 if self.host.is_none() {
357 self.host = response.host.clone();
358 }
359 if self.path.is_none() {
360 self.path = response.path.clone();
361 }
362 for (k, v) in &response.extra {
363 if k == "quit" {
365 self.set(k, v);
366 }
367 }
368 }
369
370 fn wants_quit(&self) -> bool {
372 matches!(self.extra_get("quit"), Some("1") | Some("true"))
373 }
374}
375
376pub trait CredentialProvider {
384 fn fill(&self, input: &Credential) -> Result<Credential>;
389
390 fn approve(&self, cred: &Credential) -> Result<()>;
392
393 fn reject(&self, cred: &Credential) -> Result<()>;
395}
396
397pub struct HelperCredentialProvider {
412 config: ConfigSet,
413 command_runner: Arc<dyn CommandRunner>,
414 probed_git_exec_path: OnceLock<Vec<PathBuf>>,
415 askpass: Option<PathBuf>,
416}
417
418impl HelperCredentialProvider {
419 pub fn new(config: ConfigSet) -> Self {
421 Self {
422 config,
423 command_runner: system_command_runner(),
424 probed_git_exec_path: OnceLock::new(),
425 askpass: None,
426 }
427 }
428
429 #[must_use]
431 pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
432 self.command_runner = runner;
433 self
434 }
435
436 #[must_use]
446 pub fn with_askpass(mut self, program: Option<PathBuf>) -> Self {
447 self.askpass = program;
448 self
449 }
450
451 #[must_use]
460 pub fn helpers_for(&self, cred: &Credential) -> Vec<String> {
461 self.helpers(cred.target_url().as_deref())
462 }
463
464 fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
466 credential_helpers(&self.config, target_url)
467 }
468
469 fn helper_request(&self, cred: &Credential) -> Credential {
474 let mut request = cred.clone();
475 let Some(url) = request.url.take() else {
476 return request;
477 };
478 if request.protocol.is_none() && request.host.is_none() {
479 if let Some(from_url) = Credential::from_url(&url, &self.config) {
480 request.protocol = from_url.protocol;
481 request.host = from_url.host;
482 request.path = request.path.or(from_url.path);
483 }
484 }
485 request
486 }
487
488 fn prompts_allowed(&self) -> bool {
490 self.config
491 .get("credential.interactive")
492 .is_none_or(|value| value != "never" && parse_bool(&value).unwrap_or(true))
493 }
494
495 fn exec_path_candidates(&self) -> Vec<PathBuf> {
496 credential_helper_exec_path_candidates(
497 self.command_runner.as_ref(),
498 &self.probed_git_exec_path,
499 )
500 }
501
502 #[must_use]
505 pub fn helper_installed(&self, name: &str) -> bool {
506 let program = format!("git-credential-{name}");
507 let on_path = crate::environment::Environment::empty()
508 .var_os("PATH")
509 .map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
510 .unwrap_or_default();
511 self.exec_path_candidates()
512 .iter()
513 .chain(on_path.iter())
514 .any(|dir| credential_helper_executable_at(&dir.join(&program)).is_some())
515 }
516
517 #[must_use]
524 pub fn suggested_helper(&self) -> Option<&'static str> {
525 SUGGESTED_HELPERS
526 .iter()
527 .copied()
528 .find(|name| self.helper_installed(name))
529 }
530}
531
532#[cfg(target_os = "macos")]
535pub const SUGGESTED_HELPERS: &[&str] = &["osxkeychain", "store"];
536#[cfg(all(unix, not(target_os = "macos")))]
539pub const SUGGESTED_HELPERS: &[&str] = &["libsecret", "store"];
540#[cfg(not(unix))]
543pub const SUGGESTED_HELPERS: &[&str] = &["manager", "store"];
544
545impl CredentialProvider for HelperCredentialProvider {
546 fn fill(&self, input: &Credential) -> Result<Credential> {
547 let mut filled = input.clone();
548 if filled.is_complete() {
549 return Ok(filled);
550 }
551 let target_url = filled.target_url();
552 let helper_list = self.helpers(target_url.as_deref());
553 for helper in &helper_list {
554 let response = invoke_helper(
555 self.command_runner.as_ref(),
556 helper,
557 "get",
558 &self.helper_request(&filled),
559 &self.exec_path_candidates(),
560 )?;
561 if response.wants_quit() {
562 return Err(Error::Message(format!(
563 "credential helper '{helper}' told us to quit"
564 )));
565 }
566 filled.merge_response(&response);
567 if filled.is_complete() {
568 return Ok(filled);
569 }
570 }
571 let Some(askpass) = self.askpass.as_deref().filter(|_| self.prompts_allowed()) else {
572 return Err(Error::CredentialsUnavailable {
573 helpers: helper_list,
574 });
575 };
576 let sanitize =
577 credential_config_value(&self.config, target_url.as_deref(), "sanitizePrompt")
578 .is_none_or(|value| parse_bool(&value).unwrap_or(true));
579 if filled.username.is_none() {
580 let prompt = filled.prompt("Username", sanitize);
581 filled.username = Some(run_askpass(self.command_runner.as_ref(), askpass, &prompt)?);
582 }
583 if filled.password.is_none() {
584 let prompt = filled.prompt("Password", sanitize);
585 filled.password = Some(run_askpass(self.command_runner.as_ref(), askpass, &prompt)?);
586 }
587 Ok(filled)
588 }
589
590 fn approve(&self, cred: &Credential) -> Result<()> {
591 let target_url = cred.target_url();
592 let request = self.helper_request(cred);
593 for helper in self.helpers(target_url.as_deref()) {
594 invoke_helper(
595 self.command_runner.as_ref(),
596 &helper,
597 "store",
598 &request,
599 &self.exec_path_candidates(),
600 )?;
601 }
602 Ok(())
603 }
604
605 fn reject(&self, cred: &Credential) -> Result<()> {
606 let target_url = cred.target_url();
607 let request = self.helper_request(cred);
608 for helper in self.helpers(target_url.as_deref()) {
609 invoke_helper(
610 self.command_runner.as_ref(),
611 &helper,
612 "erase",
613 &request,
614 &self.exec_path_candidates(),
615 )?;
616 }
617 Ok(())
618 }
619}
620
621fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
634 let mut out = Vec::new();
635 for entry in config.entries() {
636 let key = &entry.key;
637 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
638 continue;
639 }
640 let Some(first_dot) = key.find('.') else {
641 continue;
642 };
643 let Some(last_dot) = key.rfind('.') else {
644 continue;
645 };
646 let section = &key[..first_dot];
647 let variable = &key[last_dot + 1..];
648 if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
649 continue;
650 }
651 if first_dot != last_dot {
652 let subsection = &key[first_dot + 1..last_dot];
653 if percent_decode_lossy(subsection).contains('\n') {
654 continue;
655 }
656 let Some(target) = target_url else {
657 continue;
658 };
659 if !credential_url_matches(subsection, target) {
660 continue;
661 }
662 }
663 let value = entry.value.as_deref().unwrap_or("");
664 if value.trim().is_empty() {
665 out.clear();
666 } else {
667 out.push(value.to_string());
668 }
669 }
670 out
671}
672
673fn credential_url_matches(pattern: &str, target: &str) -> bool {
674 let pattern = percent_decode_lossy(pattern);
675 if pattern.contains('\n') {
676 return false;
677 }
678 let pattern = pattern.trim_end_matches('/');
679 let pattern_no_user = url_without_userinfo(pattern);
680 let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
681 let target = target.trim_end_matches('/');
682 let target_no_user = url_without_userinfo(target);
683 let target_no_scheme = strip_url_scheme(target);
684 let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
685 let target_path = target_path_component(target);
686
687 let matches = |pattern: &str| {
688 if pattern.starts_with('/') {
689 return credential_prefix_matches(pattern, target_path);
690 }
691 if pattern.ends_with("://") {
692 return target.starts_with(pattern) || target_no_user.starts_with(pattern);
693 }
694 if pattern.contains('*') {
695 return credential_wildcard_matches(pattern, target)
696 || credential_wildcard_matches(pattern, &target_no_user)
697 || credential_wildcard_matches(pattern, target_no_scheme)
698 || credential_wildcard_matches(pattern, target_no_scheme_no_user);
699 }
700 credential_prefix_matches(pattern, target)
701 || credential_prefix_matches(pattern, &target_no_user)
702 || credential_prefix_matches(pattern, target_no_scheme)
703 || credential_prefix_matches(pattern, target_no_scheme_no_user)
704 };
705 matches(pattern)
706 || (pattern_no_user != pattern && matches(&pattern_no_user))
707 || pattern_after_user.is_some_and(matches)
708}
709
710fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
711 candidate
712 .strip_prefix(pattern)
713 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
714}
715
716fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
717 let Some((prefix, suffix)) = pattern.split_once('*') else {
718 return false;
719 };
720 let Some(rest) = candidate.strip_prefix(prefix) else {
721 return false;
722 };
723 rest.find(suffix).is_some_and(|idx| {
724 let after = &rest[idx + suffix.len()..];
725 after.is_empty() || after.starts_with('/')
726 })
727}
728
729fn strip_url_scheme(url: &str) -> &str {
730 url.split_once("://").map_or(url, |(_, rest)| rest)
731}
732
733pub(crate) fn url_without_userinfo(url: &str) -> String {
740 let Some((scheme, rest)) = url.split_once("://") else {
741 let (authority, suffix) = split_authority_suffix(url);
742 let authority = strip_userinfo_from_authority(authority);
743 return format!("{authority}{suffix}");
744 };
745 let (authority, suffix) = split_authority_suffix(rest);
746 let authority = strip_userinfo_from_authority(authority);
747 format!("{scheme}://{authority}{suffix}")
748}
749
750fn split_authority_suffix(url: &str) -> (&str, &str) {
751 let authority_end = url
752 .char_indices()
753 .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
754 .map(|(idx, _)| idx)
755 .unwrap_or(url.len());
756 url.split_at(authority_end)
757}
758
759fn strip_userinfo_from_authority(authority: &str) -> &str {
760 authority
761 .rsplit_once('@')
762 .map_or(authority, |(_, host_port)| host_port)
763}
764
765#[cfg_attr(not(feature = "http-ureq"), allow(dead_code))]
767pub(crate) fn redact_embedded_url_credentials(message: &str) -> String {
768 let mut out = String::with_capacity(message.len());
769 let mut rest = message;
770 while let Some(scheme_sep) = rest.find("://") {
771 let before = &rest[..scheme_sep];
772 let scheme_start = before
773 .char_indices()
774 .rfind(|(_, c)| !c.is_ascii_alphanumeric() && *c != '-' && *c != '+' && *c != '.')
775 .map(|(i, c)| i + c.len_utf8())
776 .unwrap_or(0);
777 out.push_str(&rest[..scheme_start]);
778 let url_part = &rest[scheme_start..];
779 let url_end = url_part
780 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ')' | ']' | ','))
781 .unwrap_or(url_part.len());
782 let url = &url_part[..url_end];
783 out.push_str(&url_without_userinfo(url));
784 rest = &url_part[url_end..];
785 }
786 out.push_str(rest);
787 out
788}
789
790fn target_path_component(url: &str) -> &str {
791 let rest = strip_url_scheme(url);
792 let idx = rest
793 .char_indices()
794 .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
795 .unwrap_or(rest.len());
796 &rest[idx..]
797}
798
799fn percent_decode_lossy(input: &str) -> String {
800 let mut out = Vec::with_capacity(input.len());
801 let bytes = input.as_bytes();
802 let mut idx = 0;
803 while idx < bytes.len() {
804 if bytes[idx] == b'%' && idx + 2 < bytes.len() {
805 if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
806 out.push((hi << 4) | lo);
807 idx += 3;
808 continue;
809 }
810 }
811 out.push(bytes[idx]);
812 idx += 1;
813 }
814 String::from_utf8_lossy(&out).into_owned()
815}
816
817const URL_UNSAFE_PROMPT_BYTES: &[u8] = b" <>\"%{}|\\^`:?#[]@!$&'()*+,;=";
819
820fn percent_encode_where(value: &str, escape: fn(u8) -> bool) -> String {
823 use std::fmt::Write as _;
824 let mut out = String::with_capacity(value.len());
825 for byte in value.bytes() {
826 if byte <= 0x1f || byte >= 0x7f || escape(byte) {
827 let _ = write!(out, "%{byte:02X}");
828 } else {
829 out.push(char::from(byte));
830 }
831 }
832 out
833}
834
835fn hex_value(byte: u8) -> Option<u8> {
836 match byte {
837 b'0'..=b'9' => Some(byte - b'0'),
838 b'a'..=b'f' => Some(byte - b'a' + 10),
839 b'A'..=b'F' => Some(byte - b'A' + 10),
840 _ => None,
841 }
842}
843
844fn credential_helper_bin_dirs_from_exec_path(exec_path: &Path) -> Vec<PathBuf> {
849 let mut bins = Vec::new();
850 let Some(parent) = exec_path.parent() else {
851 return bins;
852 };
853 bins.push(parent.join("bin"));
854 if exec_path.file_name().is_some_and(|name| name == "git-core")
855 && parent.file_name().is_some_and(|name| name == "libexec")
856 {
857 if let Some(toolchain_root) = parent.parent() {
858 bins.push(toolchain_root.join("bin"));
859 }
860 }
861 bins
862}
863
864fn push_exec_path_candidate(out: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, dir: PathBuf) {
865 if !dir.is_dir() || !seen.insert(dir.clone()) {
866 return;
867 }
868 out.push(dir.clone());
869 for bin in credential_helper_bin_dirs_from_exec_path(&dir) {
870 if bin.is_dir() && seen.insert(bin.clone()) {
871 out.push(bin);
872 }
873 }
874}
875
876fn credential_helper_exec_path_candidates(
880 runner: &dyn CommandRunner,
881 probed_git_exec_path: &OnceLock<Vec<PathBuf>>,
882) -> Vec<PathBuf> {
883 let mut v = Vec::new();
884 let mut seen = HashSet::new();
885 if let Some(ep) = crate::environment::Environment::empty().var("GIT_EXEC_PATH") {
886 push_exec_path_candidate(&mut v, &mut seen, PathBuf::from(ep.trim()));
887 }
888 for dir in probed_git_exec_path_dirs(runner, probed_git_exec_path) {
889 push_exec_path_candidate(&mut v, &mut seen, dir);
890 }
891 for candidate in [
892 "/usr/libexec/git-core",
893 "/Library/Developer/CommandLineTools/usr/libexec/git-core",
894 "/opt/homebrew/opt/git/libexec/git-core",
895 "/opt/homebrew/libexec/git-core",
896 "/usr/lib/git-core",
897 "/usr/local/libexec/git-core",
898 ] {
899 push_exec_path_candidate(&mut v, &mut seen, PathBuf::from(candidate));
900 }
901 v
902}
903
904fn probed_git_exec_path_dirs(
905 runner: &dyn CommandRunner,
906 probed_git_exec_path: &OnceLock<Vec<PathBuf>>,
907) -> Vec<PathBuf> {
908 probed_git_exec_path
909 .get_or_init(|| probe_git_exec_path(runner))
910 .clone()
911}
912
913fn probe_git_exec_path(runner: &dyn CommandRunner) -> Vec<PathBuf> {
914 let spec = CommandSpec {
915 program: PathBuf::from("git"),
916 args: vec![OsString::from("--exec-path")],
917 shell: None,
918 cwd: None,
919 env: CommandEnvironment::inherit_process_only(),
920 stdin: CommandStdin::Null,
921 stdout: CommandStdio::Pipe,
922 stderr: CommandStdio::Null,
923 };
924 let Ok(child) = runner.spawn(&spec) else {
925 return Vec::new();
926 };
927 let Ok(output) = child.wait_with_output() else {
928 return Vec::new();
929 };
930 if !output.status.success {
931 return Vec::new();
932 }
933 let dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
934 if dir.is_dir() {
935 vec![dir]
936 } else {
937 Vec::new()
938 }
939}
940
941fn credential_helper_executable_at(path: &Path) -> Option<PathBuf> {
942 if path.is_file() {
943 return Some(path.to_path_buf());
944 }
945 #[cfg(windows)]
946 {
947 let with_exe = path.with_extension("exe");
948 if with_exe.is_file() {
949 return Some(with_exe);
950 }
951 }
952 None
953}
954
955fn resolve_credential_helper_executable(
959 helper_program: &str,
960 exec_path_candidates: &[PathBuf],
961) -> PathBuf {
962 if helper_program.contains('/') {
963 return PathBuf::from(helper_program);
964 }
965 if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
966 let exe_name = format!("git-credential-{suffix}");
967 for ep in exec_path_candidates {
968 let candidate = ep.join(&exe_name);
969 if let Some(resolved) = credential_helper_executable_at(&candidate) {
970 return resolved;
971 }
972 }
973 }
974 PathBuf::from(helper_program)
975}
976
977fn helper_request(creds: &Credential) -> std::borrow::Cow<'_, Credential> {
1000 if creds.url.is_none() || creds.protocol.is_none() || creds.host.is_none() {
1001 return std::borrow::Cow::Borrowed(creds);
1002 }
1003 let mut request = creds.clone();
1004 request.url = None;
1005 std::borrow::Cow::Owned(request)
1006}
1007
1008fn invoke_helper(
1009 runner: &dyn CommandRunner,
1010 helper: &str,
1011 action: &str,
1012 creds: &Credential,
1013 exec_path_candidates: &[PathBuf],
1014) -> Result<Credential> {
1015 let helper_words = shell_words::split(helper)
1016 .map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
1017 let (first_word, extra_args) = match helper_words.split_first() {
1018 Some((first, rest)) => (first.as_str(), rest),
1019 None => ("", &[][..]),
1020 };
1021
1022 let mut stdin_body = helper_request(creds).serialize();
1023 stdin_body.push('\n');
1024 let stdin_bytes = stdin_body.into_bytes();
1025
1026 let spec = if let Some(shell_cmd) = helper.strip_prefix('!') {
1027 CommandSpec {
1028 program: "sh".into(),
1029 args: Vec::new(),
1030 shell: Some(ShellInvocation::DashC {
1031 script: format!("{shell_cmd} {action}"),
1032 argv0: None,
1033 args: Vec::new(),
1034 }),
1035 cwd: None,
1036 env: CommandEnvironment::inherit_process_only(),
1037 stdin: CommandStdin::Pipe(stdin_bytes),
1038 stdout: CommandStdio::Pipe,
1039 stderr: CommandStdio::Inherit,
1040 }
1041 } else {
1042 let helper_program =
1043 if first_word.contains('/') || first_word.starts_with("git-credential-") {
1044 first_word.to_string()
1045 } else {
1046 format!("git-credential-{first_word}")
1047 };
1048 let resolved = resolve_credential_helper_executable(&helper_program, exec_path_candidates);
1049 let mut args: Vec<OsString> = extra_args
1050 .iter()
1051 .map(|a| OsString::from(a.as_str()))
1052 .collect();
1053 args.push(OsString::from(action));
1054 CommandSpec {
1055 program: resolved,
1056 args,
1057 shell: None,
1058 cwd: None,
1059 env: CommandEnvironment::inherit_process_only(),
1060 stdin: CommandStdin::Pipe(stdin_bytes),
1061 stdout: CommandStdio::Pipe,
1062 stderr: CommandStdio::Inherit,
1063 }
1064 };
1065
1066 let output = runner
1067 .spawn(&spec)
1068 .map_err(|e| Error::Message(format!("failed to run credential helper '{helper}': {e}")))?
1069 .wait_with_output()
1070 .map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
1071 if !output.status.success {
1072 return Err(Error::Message(format!(
1073 "credential helper '{helper}' exited with status {}",
1074 output.status.code.unwrap_or(-1)
1075 )));
1076 }
1077
1078 Ok(Credential::parse_bytes(&output.stdout))
1079}
1080
1081fn run_askpass(runner: &dyn CommandRunner, program: &Path, prompt: &str) -> Result<String> {
1084 let spec = CommandSpec {
1085 program: program.to_path_buf(),
1086 args: vec![OsString::from(prompt)],
1087 shell: None,
1088 cwd: None,
1089 env: CommandEnvironment::inherit_process_only(),
1090 stdin: CommandStdin::Null,
1091 stdout: CommandStdio::Pipe,
1092 stderr: CommandStdio::Inherit,
1093 };
1094 let output = runner
1095 .spawn(&spec)
1096 .and_then(|child| child.wait_with_output())
1097 .map_err(|e| {
1098 Error::Message(format!(
1099 "could not run askpass program '{}': {e}",
1100 program.display()
1101 ))
1102 })?;
1103 if !output.status.success {
1104 return Err(Error::Message(format!(
1105 "askpass program '{}' exited with status {}",
1106 program.display(),
1107 output.status.code.unwrap_or(-1)
1108 )));
1109 }
1110 let answer = String::from_utf8_lossy(&output.stdout);
1111 Ok(answer
1112 .split(['\r', '\n'])
1113 .next()
1114 .unwrap_or_default()
1115 .to_owned())
1116}
1117
1118pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
1123 credential_config_value(config, target_url, "useHttpPath")
1124 .as_deref()
1125 .map(|value| parse_bool(value).unwrap_or(false))
1126 .unwrap_or(false)
1127}
1128
1129fn credential_config_value(
1130 config: &ConfigSet,
1131 target_url: Option<&str>,
1132 variable_name: &str,
1133) -> Option<String> {
1134 let mut out = None;
1135 for entry in config.entries() {
1136 let key = &entry.key;
1137 if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
1138 continue;
1139 }
1140 let Some(first_dot) = key.find('.') else {
1141 continue;
1142 };
1143 let Some(last_dot) = key.rfind('.') else {
1144 continue;
1145 };
1146 let section = &key[..first_dot];
1147 let variable = &key[last_dot + 1..];
1148 if !section.eq_ignore_ascii_case("credential")
1149 || !variable.eq_ignore_ascii_case(variable_name)
1150 {
1151 continue;
1152 }
1153 if first_dot != last_dot {
1154 let subsection = &key[first_dot + 1..last_dot];
1155 if percent_decode_lossy(subsection).contains('\n') {
1156 continue;
1157 }
1158 let Some(target) = target_url else {
1159 continue;
1160 };
1161 if !credential_url_matches(subsection, target) {
1162 continue;
1163 }
1164 }
1165 out = entry.value.clone();
1166 }
1167 out
1168}
1169
1170#[cfg(windows)]
1183pub mod windows_store {
1184 use std::ffi::OsStr;
1185 use std::os::windows::ffi::OsStrExt;
1186
1187 use windows_sys::Win32::Foundation::{GetLastError, ERROR_NOT_FOUND};
1188 use windows_sys::Win32::Security::Credentials::{
1189 CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_LOCAL_MACHINE,
1190 CRED_TYPE_GENERIC,
1191 };
1192
1193 use super::Credential;
1194 use crate::error::{Error, Result};
1195
1196 fn target_name(cred: &Credential) -> Result<String> {
1198 let protocol = cred
1199 .protocol
1200 .as_deref()
1201 .filter(|p| !p.is_empty())
1202 .ok_or_else(|| Error::Message("credential is missing its protocol".to_owned()))?;
1203 let host = cred
1204 .host
1205 .as_deref()
1206 .filter(|h| !h.is_empty())
1207 .ok_or_else(|| Error::Message("credential is missing its host".to_owned()))?;
1208 Ok(format!("git:{protocol}://{host}"))
1209 }
1210
1211 fn wide(s: &str) -> Vec<u16> {
1213 OsStr::new(s)
1214 .encode_wide()
1215 .chain(std::iter::once(0))
1216 .collect()
1217 }
1218
1219 unsafe fn wstr_to_string(ptr: *const u16) -> String {
1225 if ptr.is_null() {
1226 return String::new();
1227 }
1228 let mut len = 0usize;
1229 while *ptr.add(len) != 0 {
1230 len += 1;
1231 }
1232 String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
1233 }
1234
1235 pub fn get(cred: &Credential) -> Result<Option<Credential>> {
1237 let target = wide(&target_name(cred)?);
1238 let mut pcred: *mut CREDENTIALW = std::ptr::null_mut();
1239 let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut pcred) };
1242 if ok == 0 {
1243 let err = unsafe { GetLastError() };
1245 if err == ERROR_NOT_FOUND {
1246 return Ok(None);
1247 }
1248 return Err(Error::Message(format!(
1249 "reading from the Windows Credential Manager failed (error {err})"
1250 )));
1251 }
1252
1253 let (username, password) = unsafe {
1256 let c = &*pcred;
1257 let username = wstr_to_string(c.UserName);
1258 let password = if c.CredentialBlob.is_null() || c.CredentialBlobSize == 0 {
1259 String::new()
1260 } else {
1261 let bytes =
1262 std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize);
1263 String::from_utf8_lossy(bytes).into_owned()
1264 };
1265 (username, password)
1266 };
1267 unsafe { CredFree(pcred as *const core::ffi::c_void) };
1269
1270 let mut out = Credential::default();
1271 if !username.is_empty() {
1272 out.username = Some(username);
1273 }
1274 if !password.is_empty() {
1275 out.password = Some(password);
1276 }
1277 Ok(Some(out))
1278 }
1279
1280 pub fn store(cred: &Credential) -> Result<()> {
1283 let password = match cred.password.as_deref() {
1284 Some(p) if !p.is_empty() => p,
1285 _ => return Ok(()),
1286 };
1287 let mut target = wide(&target_name(cred)?);
1288 let mut username = wide(cred.username.as_deref().unwrap_or(""));
1289 let blob = password.as_bytes();
1290
1291 let mut credential: CREDENTIALW = unsafe { std::mem::zeroed() };
1294 credential.Type = CRED_TYPE_GENERIC;
1295 credential.TargetName = target.as_mut_ptr();
1296 credential.CredentialBlobSize = blob.len() as u32;
1297 credential.CredentialBlob = blob.as_ptr() as *mut u8;
1298 credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
1299 credential.UserName = username.as_mut_ptr();
1300
1301 let ok = unsafe { CredWriteW(&credential as *const CREDENTIALW, 0) };
1303 if ok == 0 {
1304 let err = unsafe { GetLastError() };
1306 return Err(Error::Message(format!(
1307 "writing to the Windows Credential Manager failed (error {err})"
1308 )));
1309 }
1310 Ok(())
1311 }
1312
1313 pub fn erase(cred: &Credential) -> Result<()> {
1315 let target = wide(&target_name(cred)?);
1316 let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
1318 if ok == 0 {
1319 let err = unsafe { GetLastError() };
1321 if err != ERROR_NOT_FOUND {
1322 return Err(Error::Message(format!(
1323 "deleting from the Windows Credential Manager failed (error {err})"
1324 )));
1325 }
1326 }
1327 Ok(())
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use crate::command_runner::{RecordedResponse, RecordingRunner};
1335
1336 #[test]
1337 fn helpers_get_decomposed_fields_without_the_request_url() {
1338 let request = Credential {
1339 protocol: Some("http".into()),
1340 host: Some("127.0.0.1:8080".into()),
1341 url: Some("http://127.0.0.1:8080/app.git/info/refs?service=git-upload-pack".into()),
1342 ..Default::default()
1343 };
1344 assert_eq!(
1345 helper_request(&request).serialize(),
1346 "protocol=http\nhost=127.0.0.1:8080\n"
1347 );
1348
1349 let url_only = Credential {
1351 url: Some("https://example.com/".into()),
1352 ..Default::default()
1353 };
1354 assert_eq!(
1355 helper_request(&url_only).serialize(),
1356 "url=https://example.com/\n"
1357 );
1358 }
1359
1360 #[cfg(unix)]
1361 #[test]
1362 fn helper_that_exits_without_reading_stdin_still_answers() {
1363 let creds = Credential {
1366 protocol: Some("https".into()),
1367 host: Some("example.com".into()),
1368 password: Some("x".repeat(1 << 20)),
1369 ..Default::default()
1370 };
1371 let response = invoke_helper(
1372 system_command_runner().as_ref(),
1373 "!echo quit=1; exit 0; :",
1374 "get",
1375 &creds,
1376 &[],
1377 )
1378 .expect("helper");
1379 assert!(response.wants_quit());
1380 }
1381
1382 #[test]
1383 fn url_without_userinfo_strips_https_credentials() {
1384 let raw = "https://x-access-token:SECRETTOKEN@github.com/octocat/repo.git/info/refs?service=git-upload-pack";
1385 let redacted = url_without_userinfo(raw);
1386 assert_eq!(
1387 redacted,
1388 "https://github.com/octocat/repo.git/info/refs?service=git-upload-pack"
1389 );
1390 assert!(!redacted.contains("SECRETTOKEN"));
1391 assert!(!redacted.contains("x-access-token"));
1392 }
1393
1394 #[test]
1395 fn url_without_userinfo_preserves_at_in_path_query_and_fragment() {
1396 let path_at = "https://example.com/org/repo@v1.git/info/refs?service=git-upload-pack";
1397 assert_eq!(
1398 url_without_userinfo(path_at),
1399 "https://example.com/org/repo@v1.git/info/refs?service=git-upload-pack"
1400 );
1401
1402 let query_at = "https://example.com/repo.git/info/refs?notify=user@example.net";
1403 assert_eq!(
1404 url_without_userinfo(query_at),
1405 "https://example.com/repo.git/info/refs?notify=user@example.net"
1406 );
1407
1408 let fragment_at = "https://example.com/repo.git#section@anchor";
1409 assert_eq!(
1410 url_without_userinfo(fragment_at),
1411 "https://example.com/repo.git#section@anchor"
1412 );
1413 }
1414
1415 #[test]
1416 fn url_without_userinfo_strips_credentials_but_keeps_path_at() {
1417 let raw =
1418 "https://token:SECRET@127.0.0.1:8080/org/repo@v1.git/info/refs?service=git-upload-pack";
1419 let redacted = url_without_userinfo(raw);
1420 assert_eq!(
1421 redacted,
1422 "https://127.0.0.1:8080/org/repo@v1.git/info/refs?service=git-upload-pack"
1423 );
1424 assert!(!redacted.contains("SECRET"));
1425 assert!(!redacted.contains("token"));
1426 }
1427
1428 #[cfg_attr(
1429 not(feature = "full-tests"),
1430 ignore = "starts system git or is heavy; run with --features full-tests"
1431 )]
1432 #[test]
1433 fn redact_embedded_url_credentials_in_message() {
1434 let msg = "GET failed: https://user:pass@example.com/repo.git (timeout)";
1435 let out = redact_embedded_url_credentials(msg);
1436 assert!(out.contains("https://example.com/repo.git"));
1437 assert!(!out.contains("user:pass"));
1438 }
1439
1440 #[test]
1441 fn parse_round_trips_named_fields() {
1442 let input =
1443 "protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
1444 let cred = Credential::parse(input);
1445 assert_eq!(cred.protocol.as_deref(), Some("https"));
1446 assert_eq!(cred.host.as_deref(), Some("example.com"));
1447 assert_eq!(cred.username.as_deref(), Some("alice"));
1448 assert_eq!(cred.password.as_deref(), Some("secret"));
1449 assert!(cred.extra.is_empty());
1451 }
1452
1453 #[test]
1454 fn serialize_uses_canonical_order() {
1455 let cred = Credential {
1456 protocol: Some("https".into()),
1457 host: Some("h".into()),
1458 username: Some("u".into()),
1459 password: Some("p".into()),
1460 ..Default::default()
1461 };
1462 assert_eq!(
1463 cred.serialize(),
1464 "protocol=https\nhost=h\nusername=u\npassword=p\n"
1465 );
1466 }
1467
1468 #[test]
1469 fn target_url_reconstructed_from_fields() {
1470 let cred = Credential {
1471 protocol: Some("https".into()),
1472 host: Some("github.com".into()),
1473 path: Some("o/r.git".into()),
1474 ..Default::default()
1475 };
1476 assert_eq!(
1477 cred.target_url().as_deref(),
1478 Some("https://github.com/o/r.git")
1479 );
1480 }
1481
1482 #[test]
1483 fn from_url_omits_http_path_unless_use_http_path_applies() {
1484 let plain = ConfigSet::new();
1485 let cred = Credential::from_url("http://127.0.0.1:9418/hw.git", &plain).expect("cred");
1486 assert_eq!(cred.protocol.as_deref(), Some("http"));
1487 assert_eq!(cred.host.as_deref(), Some("127.0.0.1:9418"));
1488 assert_eq!(cred.path, None);
1489 assert_eq!(cred.url.as_deref(), Some("http://127.0.0.1:9418/hw.git"));
1490
1491 let mut scoped = ConfigSet::new();
1492 scoped
1493 .add_command_override("credential.https://example.com/team.useHttpPath", "true")
1494 .unwrap();
1495 let in_scope =
1496 Credential::from_url("https://example.com/team/a%20b.git/", &scoped).expect("cred");
1497 assert_eq!(in_scope.path.as_deref(), Some("team/a b.git"));
1498 let out_of_scope =
1499 Credential::from_url("https://example.com/other/repo.git", &scoped).expect("cred");
1500 assert_eq!(out_of_scope.path, None);
1501 }
1502
1503 #[test]
1504 fn from_url_keeps_path_for_non_http_protocols_and_rejects_hostless_urls() {
1505 let cfg = ConfigSet::new();
1506 let cred = Credential::from_url("ftp://files.example/pub/repo.git", &cfg).expect("cred");
1507 assert_eq!(cred.path.as_deref(), Some("pub/repo.git"));
1508 assert_eq!(Credential::from_url("not a url", &cfg), None);
1509 assert_eq!(Credential::from_url("file:///tmp/repo.git", &cfg), None);
1510 }
1511
1512 #[test]
1513 fn helper_request_drops_url_and_fills_missing_fields_from_it() {
1514 let provider = HelperCredentialProvider::new(ConfigSet::new());
1515 let decomposed =
1516 Credential::from_url("http://127.0.0.1:9418/hw.git", &ConfigSet::new()).expect("cred");
1517 assert_eq!(
1518 provider.helper_request(&decomposed).serialize(),
1519 "protocol=http\nhost=127.0.0.1:9418\n"
1520 );
1521
1522 let url_only = Credential {
1523 url: Some("https://example.com/o/r.git".into()),
1524 username: Some("alice".into()),
1525 ..Default::default()
1526 };
1527 assert_eq!(
1528 provider.helper_request(&url_only).serialize(),
1529 "protocol=https\nhost=example.com\nusername=alice\n"
1530 );
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn helpers_never_receive_the_url_key() {
1536 let tmp = tempfile::tempdir().expect("tempdir");
1537 let log = tmp.path().join("helper.log");
1538 let mut cfg = ConfigSet::new();
1539 cfg.add_command_override(
1540 "credential.http://127.0.0.1:9418/hw.git.helper",
1541 &format!(
1542 "!cat >>'{}'; echo username=u; echo password=p; :",
1543 log.display()
1544 ),
1545 )
1546 .unwrap();
1547 let provider = HelperCredentialProvider::new(cfg.clone());
1548 let input = Credential::from_url("http://127.0.0.1:9418/hw.git", &cfg).expect("cred");
1549
1550 let filled = provider
1551 .fill(&input)
1552 .expect("path-scoped helper still matches");
1553 assert_eq!(filled.username.as_deref(), Some("u"));
1554 provider.approve(&filled).expect("store");
1555
1556 assert_eq!(
1557 std::fs::read_to_string(&log).expect("helper log"),
1558 "protocol=http\nhost=127.0.0.1:9418\n\n\
1559 protocol=http\nhost=127.0.0.1:9418\nusername=u\npassword=p\n\n"
1560 );
1561 }
1562
1563 fn env_with(vars: &[(&str, &str)]) -> Environment {
1564 Environment::from_vars(
1565 vars.iter()
1566 .map(|(k, v)| (OsString::from(k), OsString::from(v))),
1567 PathBuf::from("/"),
1568 )
1569 }
1570
1571 fn config_with(entries: &[(&str, &str)]) -> ConfigSet {
1572 let mut cfg = ConfigSet::new();
1573 for (key, value) in entries {
1574 cfg.add_command_override(key, value).unwrap();
1575 }
1576 cfg
1577 }
1578
1579 #[test]
1580 fn askpass_program_prefers_git_askpass_then_core_askpass_then_ssh_askpass() {
1581 let cfg = config_with(&[("core.askPass", "/cfg/askpass")]);
1582 let all = env_with(&[("GIT_ASKPASS", "/env/git"), ("SSH_ASKPASS", "/env/ssh")]);
1583 assert_eq!(askpass_program(&all, &cfg), Some(PathBuf::from("/env/git")));
1584 let ssh_only = env_with(&[("SSH_ASKPASS", "/env/ssh")]);
1585 assert_eq!(
1586 askpass_program(&ssh_only, &cfg),
1587 Some(PathBuf::from("/cfg/askpass"))
1588 );
1589 assert_eq!(
1590 askpass_program(&ssh_only, &ConfigSet::new()),
1591 Some(PathBuf::from("/env/ssh"))
1592 );
1593 assert_eq!(
1594 askpass_program(&Environment::empty(), &ConfigSet::new()),
1595 None
1596 );
1597 }
1598
1599 #[test]
1600 fn askpass_program_empty_value_disables_askpass_and_core_askpass_expands_home() {
1601 let cfg = config_with(&[("core.askPass", "~/bin/askpass")]);
1602 let empty_git = env_with(&[("GIT_ASKPASS", ""), ("SSH_ASKPASS", "/env/ssh")]);
1603 assert_eq!(askpass_program(&empty_git, &cfg), None);
1604 let home = env_with(&[("HOME", "/home/ci")]);
1605 assert_eq!(
1606 askpass_program(&home, &cfg),
1607 Some(PathBuf::from("/home/ci/bin/askpass"))
1608 );
1609 let empty_cfg = config_with(&[("core.askPass", "")]);
1610 assert_eq!(
1611 askpass_program(&env_with(&[("SSH_ASKPASS", "/env/ssh")]), &empty_cfg),
1612 None
1613 );
1614 }
1615
1616 fn askpass_provider(
1617 cfg: ConfigSet,
1618 responses: Vec<RecordedResponse>,
1619 ) -> (HelperCredentialProvider, Arc<RecordingRunner>) {
1620 let runner = Arc::new(RecordingRunner::default());
1621 for response in responses {
1622 runner.push_response(response);
1623 }
1624 let provider = HelperCredentialProvider::new(cfg)
1625 .with_command_runner(runner.clone())
1626 .with_askpass(Some(PathBuf::from("/ci/askpass")));
1627 (provider, runner)
1628 }
1629
1630 fn answer(text: &str) -> RecordedResponse {
1631 RecordedResponse::Output {
1632 stdout: text.as_bytes().to_vec(),
1633 code: 0,
1634 }
1635 }
1636
1637 fn askpass_prompts(runner: &RecordingRunner) -> Vec<String> {
1638 runner
1639 .specs()
1640 .iter()
1641 .map(|spec| {
1642 assert_eq!(spec.program, PathBuf::from("/ci/askpass"));
1643 assert!(spec.shell.is_none(), "askpass runs without a shell");
1644 assert!(matches!(spec.stdin, CommandStdin::Null));
1645 assert_eq!(spec.args.len(), 1, "the prompt is the only argument");
1646 spec.args[0].to_string_lossy().into_owned()
1647 })
1648 .collect()
1649 }
1650
1651 #[test]
1652 fn fill_asks_askpass_for_username_then_password_and_keeps_the_first_line() {
1653 let (provider, runner) = askpass_provider(
1654 ConfigSet::new(),
1655 vec![answer("ci\nignored\n"), answer("s3cret\r\n")],
1656 );
1657 let input =
1658 Credential::from_url("http://127.0.0.1:9431/hello.git", &ConfigSet::new()).unwrap();
1659 let filled = provider.fill(&input).expect("askpass fills");
1660 assert_eq!(filled.username.as_deref(), Some("ci"));
1661 assert_eq!(filled.password.as_deref(), Some("s3cret"));
1662 assert_eq!(
1663 askpass_prompts(&runner),
1664 [
1665 "Username for 'http://127.0.0.1:9431': ",
1666 "Password for 'http://ci@127.0.0.1:9431': ",
1667 ]
1668 );
1669 }
1670
1671 #[test]
1672 fn fill_asks_only_for_the_password_when_the_url_names_the_user() {
1673 let cfg = config_with(&[("credential.useHttpPath", "true")]);
1674 let (provider, runner) = askpass_provider(cfg.clone(), vec![answer("tok")]);
1675 let input = Credential::from_url("https://ci@example.com/o/r.git", &cfg).unwrap();
1676 let filled = provider.fill(&input).expect("askpass fills");
1677 assert_eq!(filled.username.as_deref(), Some("ci"));
1678 assert_eq!(filled.password.as_deref(), Some("tok"));
1679 assert_eq!(
1680 askpass_prompts(&runner),
1681 ["Password for 'https://ci@example.com/o/r.git': "]
1682 );
1683 }
1684
1685 #[test]
1686 fn askpass_prompt_is_sanitized_unless_disabled() {
1687 let cred = Credential {
1688 protocol: Some("https".into()),
1689 host: Some("example.com".into()),
1690 username: Some("a b/c\x1b".into()),
1691 path: Some("o/r e.git".into()),
1692 ..Default::default()
1693 };
1694 assert_eq!(
1695 cred.prompt("Password", true),
1696 "Password for 'https://a%20b%2Fc%1B@example.com/o/r%20e.git': "
1697 );
1698 assert_eq!(
1699 cred.prompt("Password", false),
1700 "Password for 'https://a b/c\x1b@example.com/o/r e.git': "
1701 );
1702 assert_eq!(Credential::default().prompt("Username", true), "Username: ");
1703
1704 let (provider, runner) = askpass_provider(
1705 config_with(&[("credential.sanitizePrompt", "false")]),
1706 vec![answer("p")],
1707 );
1708 let input = Credential {
1709 username: Some("a b".into()),
1710 ..cred
1711 };
1712 provider.fill(&input).expect("askpass fills");
1713 assert_eq!(
1714 askpass_prompts(&runner),
1715 ["Password for 'https://a b@example.com/o/r e.git': "]
1716 );
1717 }
1718
1719 #[test]
1720 fn url_scoped_sanitize_prompt_applies_only_to_matching_urls() {
1721 let cfg = config_with(&[("credential.https://example.com.sanitizePrompt", "false")]);
1722 for (url, expected) in [
1723 (
1724 "https://a%20b@example.com/r.git",
1725 "Password for 'https://a b@example.com': ",
1726 ),
1727 (
1728 "https://a%20b@other.example/r.git",
1729 "Password for 'https://a%20b@other.example': ",
1730 ),
1731 ] {
1732 let (provider, runner) = askpass_provider(cfg.clone(), vec![answer("p")]);
1733 let input = Credential::from_url(url, &cfg).unwrap();
1734 provider.fill(&input).expect("askpass fills");
1735 assert_eq!(askpass_prompts(&runner), [expected], "{url}");
1736 }
1737 }
1738
1739 #[test]
1740 fn empty_askpass_answers_are_kept_like_git() {
1741 let (provider, runner) = askpass_provider(ConfigSet::new(), vec![answer("\n"), answer("")]);
1742 let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1743 let filled = provider
1744 .fill(&input)
1745 .expect("empty answers are still answers");
1746 assert_eq!(filled.username.as_deref(), Some(""));
1747 assert_eq!(filled.password.as_deref(), Some(""));
1748 assert_eq!(runner.specs().len(), 2, "each field is asked once");
1749 }
1750
1751 #[test]
1752 fn failing_askpass_is_an_error_naming_the_program() {
1753 let (provider, _runner) =
1754 askpass_provider(ConfigSet::new(), vec![RecordedResponse::Exit(3)]);
1755 let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1756 let err = provider.fill(&input).expect_err("askpass failed");
1757 let message = err.to_string();
1758 assert!(message.contains("/ci/askpass"), "{message}");
1759 assert!(message.contains("status 3"), "{message}");
1760 }
1761
1762 #[test]
1763 fn no_askpass_or_credential_interactive_never_keeps_fill_non_interactive() {
1764 let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1765 let err = HelperCredentialProvider::new(ConfigSet::new())
1766 .fill(&input)
1767 .expect_err("nothing can answer");
1768 assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1769
1770 for value in ["never", "false"] {
1771 let (provider, runner) = askpass_provider(
1772 config_with(&[("credential.interactive", value)]),
1773 vec![answer("unused")],
1774 );
1775 let err = provider.fill(&input).expect_err("askpass disabled");
1776 assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1777 assert!(
1778 runner.specs().is_empty(),
1779 "askpass must not run for {value}"
1780 );
1781 }
1782 }
1783
1784 #[test]
1785 fn unavailable_credentials_name_the_helpers_that_were_asked() {
1786 let input = Credential::from_url("https://example.com/r.git", &ConfigSet::new()).unwrap();
1787 let err = HelperCredentialProvider::new(ConfigSet::new())
1788 .fill(&input)
1789 .expect_err("no helper");
1790 assert!(
1791 matches!(&err, Error::CredentialsUnavailable { helpers } if helpers.is_empty()),
1792 "{err:?}"
1793 );
1794
1795 let runner = Arc::new(RecordingRunner::default());
1796 runner.push_response(answer(""));
1797 let err = HelperCredentialProvider::new(config_with(&[("credential.helper", "!empty")]))
1798 .with_command_runner(runner.clone())
1799 .fill(&input)
1800 .expect_err("the helper has nothing");
1801 assert_eq!(err.to_string(), NON_INTERACTIVE_MESSAGE);
1802 assert!(
1803 runner
1804 .specs()
1805 .iter()
1806 .any(|spec| format!("{spec:?}").contains("empty")),
1807 "the helper was asked"
1808 );
1809 let failure = crate::error::AuthFailure::from_fill_error(err);
1810 assert_eq!(
1811 failure,
1812 crate::error::AuthFailure::CredentialsUnavailable {
1813 detail: NON_INTERACTIVE_MESSAGE.to_owned(),
1814 helpers: vec!["!empty".to_owned()],
1815 }
1816 );
1817
1818 let other =
1819 crate::error::AuthFailure::from_fill_error(Error::Message("helper crashed".into()));
1820 assert!(matches!(
1821 other,
1822 crate::error::AuthFailure::CredentialsUnavailable { detail, helpers }
1823 if detail == "helper crashed" && helpers.is_empty()
1824 ));
1825 }
1826
1827 #[test]
1828 fn from_url_takes_decoded_userinfo_and_keeps_it_out_of_the_url() {
1829 let cfg = ConfigSet::new();
1830 let cred =
1831 Credential::from_url("https://ci%40corp:p%3Ass@example.com/r.git", &cfg).unwrap();
1832 assert_eq!(cred.username.as_deref(), Some("ci@corp"));
1833 assert_eq!(cred.password.as_deref(), Some("p:ss"));
1834 assert_eq!(cred.url.as_deref(), Some("https://example.com/r.git"));
1835 assert!(cred.is_complete());
1836
1837 let user_only = Credential::from_url("https://ci@example.com/r.git", &cfg).unwrap();
1838 assert_eq!(user_only.username.as_deref(), Some("ci"));
1839 assert_eq!(user_only.password, None);
1840 }
1841
1842 #[cfg(unix)]
1843 #[test]
1844 fn helpers_answer_before_askpass_runs() {
1845 let tmp = tempfile::tempdir().expect("tempdir");
1846 let askpass = tmp.path().join("askpass");
1847 let log = tmp.path().join("askpass.log");
1848 std::fs::write(
1849 &askpass,
1850 format!(
1851 "#!/bin/sh\necho \"$1\" >>'{}'\necho from-askpass\n",
1852 log.display()
1853 ),
1854 )
1855 .unwrap();
1856 let mut perms = std::fs::metadata(&askpass).unwrap().permissions();
1857 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1858 std::fs::set_permissions(&askpass, perms).unwrap();
1859
1860 let cfg = config_with(&[("credential.helper", "!echo username=helper-user; :")]);
1861 let provider = HelperCredentialProvider::new(cfg.clone()).with_askpass(Some(askpass));
1862 let input = Credential::from_url("https://example.com/r.git", &cfg).unwrap();
1863 let filled = provider.fill(&input).expect("helper plus askpass");
1864 assert_eq!(filled.username.as_deref(), Some("helper-user"));
1865 assert_eq!(filled.password.as_deref(), Some("from-askpass"));
1866 assert_eq!(
1867 std::fs::read_to_string(&log).unwrap(),
1868 "Password for 'https://helper-user@example.com': \n"
1869 );
1870
1871 let complete =
1872 config_with(&[("credential.helper", "!echo username=u; echo password=p; :")]);
1873 let provider = HelperCredentialProvider::new(complete.clone())
1874 .with_askpass(Some(tmp.path().join("askpass")));
1875 provider.fill(&input).expect("helper answers");
1876 assert_eq!(
1877 std::fs::read_to_string(&log).unwrap().lines().count(),
1878 1,
1879 "askpass must not run when a helper completes the credential"
1880 );
1881 }
1882
1883 #[test]
1884 fn git_for_windows_exec_path_adds_mingw64_bin_not_libexec_bin() {
1885 let exec_path = PathBuf::from("C:/Program Files/Git/mingw64/libexec/git-core");
1887 let bins = credential_helper_bin_dirs_from_exec_path(&exec_path);
1888 let mingw_bin = PathBuf::from("C:/Program Files/Git/mingw64/bin");
1889 assert!(
1890 bins.contains(&mingw_bin),
1891 "GCM lives in mingw64/bin, got {bins:?}"
1892 );
1893 let libexec_bin = PathBuf::from("C:/Program Files/Git/mingw64/libexec/bin");
1894 assert!(
1895 bins.contains(&libexec_bin),
1896 "libexec/bin is still listed for other layouts: {bins:?}"
1897 );
1898 }
1899
1900 #[test]
1901 fn resolve_manager_finds_helper_in_git_for_windows_layout() {
1902 use std::collections::HashSet;
1903 use std::fs;
1904
1905 let tmp = tempfile::tempdir().expect("tempdir");
1906 let git_core = tmp.path().join("mingw64/libexec/git-core");
1907 let mingw_bin = tmp.path().join("mingw64/bin");
1908 fs::create_dir_all(&git_core).expect("git-core dir");
1909 fs::create_dir_all(&mingw_bin).expect("mingw bin dir");
1910 fs::write(mingw_bin.join("git-credential-manager"), b"").expect("fake gcm");
1911
1912 let mut candidates = Vec::new();
1913 let mut seen = HashSet::new();
1914 push_exec_path_candidate(&mut candidates, &mut seen, git_core);
1915
1916 let resolved = candidates.iter().find_map(|ep| {
1917 let candidate = ep.join("git-credential-manager");
1918 credential_helper_executable_at(&candidate)
1919 });
1920 assert_eq!(
1921 resolved,
1922 Some(mingw_bin.join("git-credential-manager")),
1923 "manager helper must resolve via mingw64/bin, searched {candidates:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn installed_helpers_are_found_in_the_exec_path() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let program = dir.path().join("git-credential-osxkeychain");
1931 std::fs::write(&program, "#!/bin/sh\n").unwrap();
1932 let provider = HelperCredentialProvider::new(ConfigSet::default());
1933 provider
1934 .probed_git_exec_path
1935 .set(vec![dir.path().to_path_buf()])
1936 .unwrap();
1937 assert!(provider.helper_installed("osxkeychain"));
1938 assert!(!provider.helper_installed("no-such-helper-anywhere"));
1939 let suggested = provider.suggested_helper();
1940 assert!(
1941 suggested.is_none_or(|name| SUGGESTED_HELPERS.contains(&name)),
1942 "{suggested:?}"
1943 );
1944 #[cfg(target_os = "macos")]
1945 assert_eq!(suggested, Some("osxkeychain"));
1946 }
1947}