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