1#![warn(missing_docs)]
96
97use std::collections::BTreeMap;
98use std::path::{PathBuf, Path};
99
100#[cfg(feature = "log")]
101mod log {
102 pub use ::log::warn;
103 pub use ::log::debug;
104 pub use ::log::trace;
105}
106
107#[cfg(feature = "log")]
108use crate::log::*;
109
110#[cfg(not(feature = "log"))]
111#[macro_use]
112mod log {
113 macro_rules! warn {
114 ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
115 }
116
117 macro_rules! debug {
118 ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
119 }
120
121 macro_rules! trace {
122 ($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
123 }
124}
125
126mod base64_decode;
127mod default_prompt;
128mod prompter;
129mod ssh_key;
130
131pub use prompter::Prompter;
132
133#[derive(Clone)]
135pub struct GitAuthenticator {
136 plaintext_credentials: BTreeMap<String, PlaintextCredentials>,
138
139 try_cred_helper: bool,
141
142 try_password_prompt: u32,
144
145 usernames: BTreeMap<String, String>,
147
148 try_ssh_agent: bool,
150
151 ssh_keys: Vec<PrivateKeyFile>,
153
154 prompt_ssh_key_password: bool,
156
157 prompter: Box<dyn prompter::ClonePrompter>,
159}
160
161impl std::fmt::Debug for GitAuthenticator {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 f.debug_struct("GitAuthenticator")
164 .field("plaintext_credentials", &self.plaintext_credentials)
165 .field("try_cred_helper", &self.try_cred_helper)
166 .field("try_password_prompt", &self.try_password_prompt)
167 .field("usernames", &self.usernames)
168 .field("try_ssh_agent", &self.try_ssh_agent)
169 .field("ssh_keys", &self.ssh_keys)
170 .field("prompt_ssh_key_password", &self.prompt_ssh_key_password)
171 .finish()
172 }
173}
174
175impl Default for GitAuthenticator {
176 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl GitAuthenticator {
185 pub fn new() -> Self {
200 Self::new_empty()
201 .try_cred_helper(true)
202 .try_password_prompt(3)
203 .add_default_username()
204 .try_ssh_agent(true)
205 .add_default_ssh_keys()
206 .prompt_ssh_key_password(true)
207 }
208
209 pub fn new_empty() -> Self {
211 Self {
212 try_ssh_agent: false,
213 try_cred_helper: false,
214 plaintext_credentials: BTreeMap::new(),
215 try_password_prompt: 0,
216 usernames: BTreeMap::new(),
217 ssh_keys: Vec::new(),
218 prompt_ssh_key_password: false,
219 prompter: prompter::wrap_prompter(default_prompt::DefaultPrompter),
220 }
221 }
222
223 pub fn add_plaintext_credentials(mut self, domain: impl Into<String>, username: impl Into<String>, password: impl Into<String>) -> Self {
227 let domain = domain.into();
228 let username = username.into();
229 let password = password.into();
230 self.plaintext_credentials.insert(domain, PlaintextCredentials {
231 username,
232 password,
233 });
234 self
235 }
236
237 pub fn try_cred_helper(mut self, enable: bool) -> Self {
241 self.try_cred_helper = enable;
242 self
243 }
244
245 pub fn try_password_prompt(mut self, max_count: u32) -> Self {
258 self.try_password_prompt = max_count;
259 self
260 }
261
262 pub fn set_prompter<P: Prompter + Clone + Send + 'static>(mut self, prompter: P) -> Self {
276 self.prompter = prompter::wrap_prompter(prompter);
277 self
278 }
279
280 pub fn add_username(mut self, domain: impl Into<String>, username: impl Into<String>) -> Self {
287 let domain = domain.into();
288 let username = username.into();
289 self.usernames.insert(domain, username);
290 self
291 }
292
293 pub fn add_default_username(self) -> Self {
297 if let Ok(username) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
298 self.add_username("*", username)
299 } else {
300 self
301 }
302 }
303
304 pub fn try_ssh_agent(mut self, enable: bool) -> Self {
306 self.try_ssh_agent = enable;
307 self
308 }
309
310 pub fn add_ssh_key_from_file(mut self, private_key: impl Into<PathBuf>, password: impl Into<Option<String>>) -> Self {
323 let private_key = private_key.into();
324 let public_key = get_pub_key_path(&private_key);
325 let password = password.into();
326 self.ssh_keys.push(PrivateKeyFile {
327 private_key,
328 public_key,
329 password,
330 });
331 self
332 }
333
334 pub fn add_default_ssh_keys(mut self) -> Self {
345 let ssh_dir = match dirs::home_dir() {
346 Some(x) => x.join(".ssh"),
347 None => return self,
348 };
349
350 let candidates = [
351 "id_rsa",
352 "id_ecdsa",
353 "id_ecdsa_sk",
354 "id_ed25519",
355 "id_ed25519_sk",
356 "id_dsa",
357 ];
358
359 for candidate in candidates {
360 let private_key = ssh_dir.join(candidate);
361 if !private_key.is_file() {
362 continue;
363 }
364 self = self.add_ssh_key_from_file(private_key, None);
365 }
366
367 self
368 }
369
370 pub fn prompt_ssh_key_password(mut self, enable: bool) -> Self {
381 self.prompt_ssh_key_password = enable;
382 self
383 }
384
385 pub fn credentials<'a>(
406 &'a self,
407 git_config: &'a git2::Config,
408 ) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
409 make_credentials_callback(self, git_config)
410 }
411
412 pub fn clone_repo(&self, url: impl AsRef<str>, into: impl AsRef<Path>) -> Result<git2::Repository, git2::Error> {
417 let url = url.as_ref();
418 let into = into.as_ref();
419
420 let git_config = git2::Config::open_default()?;
421 let mut repo_builder = git2::build::RepoBuilder::new();
422 let mut fetch_options = git2::FetchOptions::new();
423 let mut remote_callbacks = git2::RemoteCallbacks::new();
424
425 remote_callbacks.credentials(self.credentials(&git_config));
426 fetch_options.remote_callbacks(remote_callbacks);
427 repo_builder.fetch_options(fetch_options);
428
429 repo_builder.clone(url, into)
430 }
431
432
433 pub fn fetch(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str], reflog_msg: Option<&str>) -> Result<(), git2::Error> {
438 let git_config = repo.config()?;
439 let mut fetch_options = git2::FetchOptions::new();
440 let mut remote_callbacks = git2::RemoteCallbacks::new();
441
442 remote_callbacks.credentials(self.credentials(&git_config));
443 fetch_options.remote_callbacks(remote_callbacks);
444 remote.fetch(refspecs, Some(&mut fetch_options), reflog_msg)
445 }
446
447 pub fn download(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
455 let git_config = repo.config()?;
456 let mut fetch_options = git2::FetchOptions::new();
457 let mut remote_callbacks = git2::RemoteCallbacks::new();
458
459 remote_callbacks.credentials(self.credentials(&git_config));
460 fetch_options.remote_callbacks(remote_callbacks);
461 remote.download(refspecs, Some(&mut fetch_options))
462 }
463
464 pub fn push(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
469 let git_config = repo.config()?;
470 let mut push_options = git2::PushOptions::new();
471 let mut remote_callbacks = git2::RemoteCallbacks::new();
472
473 remote_callbacks.credentials(self.credentials(&git_config));
474 push_options.remote_callbacks(remote_callbacks);
475
476 remote.push(refspecs, Some(&mut push_options))
477 }
478
479 fn get_username(&self, url: &str) -> Option<&str> {
481 if let Some(domain) = domain_from_url(url) {
482 if let Some(username) = self.usernames.get(domain) {
483 return Some(username);
484 }
485 }
486 self.usernames.get("*").map(|x| x.as_str())
487 }
488
489 fn get_plaintext_credentials(&self, url: &str) -> Option<&PlaintextCredentials> {
491 if let Some(domain) = domain_from_url(url) {
492 if let Some(credentials) = self.plaintext_credentials.get(domain) {
493 return Some(credentials);
494 }
495 }
496 self.plaintext_credentials.get("*")
497 }
498}
499
500fn make_credentials_callback<'a>(
501 authenticator: &'a GitAuthenticator,
502 git_config: &'a git2::Config,
503) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
504 let mut try_cred_helper = authenticator.try_cred_helper;
505 let mut try_password_prompt = authenticator.try_password_prompt;
506 let mut try_ssh_agent = authenticator.try_ssh_agent;
507 let mut ssh_keys = authenticator.ssh_keys.iter();
508 let mut prompter = authenticator.prompter.clone();
509
510 move |url: &str, username: Option<&str>, allowed: git2::CredentialType| {
511 trace!("credentials callback called with url: {url:?}, username: {username:?}, allowed_credentials: {allowed:?}");
512
513 if allowed.contains(git2::CredentialType::USERNAME) {
520 if let Some(username) = authenticator.get_username(url) {
521 debug!("credentials_callback: returning username: {username:?}");
522 match git2::Cred::username(username) {
523 Ok(x) => return Ok(x),
524 Err(e) => {
525 debug!("credentials_callback: failed to wrap username: {e}");
526 return Err(e);
527 },
528 }
529 }
530 }
531
532 if allowed.contains(git2::CredentialType::SSH_KEY) {
534 if let Some(username) = username {
535 if try_ssh_agent {
536 try_ssh_agent = false;
537 debug!("credentials_callback: trying ssh_key_from_agent with username: {username:?}");
538 match git2::Cred::ssh_key_from_agent(username) {
539 Ok(x) => return Ok(x),
540 Err(e) => debug!("credentials_callback: failed to use SSH agent: {e}"),
541 }
542 }
543
544 #[allow(clippy::while_let_on_iterator)] while let Some(key) = ssh_keys.next() {
546 debug!("credentials_callback: trying ssh key, username: {username:?}, private key: {:?}", key.private_key);
547 let prompter = Some(prompter.as_prompter_mut())
548 .filter(|_| authenticator.prompt_ssh_key_password);
549 match key.to_credentials(username, prompter, git_config) {
550 Ok(x) => return Ok(x),
551 Err(e) => debug!("credentials_callback: failed to use SSH key from file {:?}: {e}", key.private_key),
552 }
553 }
554 }
555 }
556
557 if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
559 if let Some(credentials) = authenticator.get_plaintext_credentials(url) {
561 debug!("credentials_callback: trying plain text credentials with username: {:?}", credentials.username);
562 match credentials.to_credentials() {
563 Ok(x) => return Ok(x),
564 Err(e) => {
565 debug!("credentials_callback: failed to wrap plain text credentials: {e}");
566 return Err(e);
567 },
568 }
569 }
570
571 if try_cred_helper {
573 try_cred_helper = false;
574 debug!("credentials_callback: trying credential_helper");
575 match git2::Cred::credential_helper(git_config, url, username) {
576 Ok(x) => return Ok(x),
577 Err(e) => debug!("credentials_callback: failed to use credential helper: {e}"),
578 }
579 }
580
581 if try_password_prompt > 0 {
583 try_password_prompt -= 1;
584 let credentials = PlaintextCredentials::prompt(
585 prompter.as_prompter_mut(),
586 username,
587 url,
588 git_config
589 );
590 if let Some(credentials) = credentials {
591 return credentials.to_credentials();
592 }
593 }
594 }
595
596 Err(git2::Error::from_str("all authentication attempts failed"))
597 }
598}
599
600#[derive(Debug, Clone)]
601struct PrivateKeyFile {
602 private_key: PathBuf,
603 public_key: Option<PathBuf>,
604 password: Option<String>,
605}
606
607impl PrivateKeyFile {
608 fn to_credentials(&self, username: &str, prompter: Option<&mut dyn Prompter>, git_config: &git2::Config) -> Result<git2::Cred, git2::Error> {
609 if let Some(password) = &self.password {
610 git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, Some(password))
611 } else if let Some(prompter) = prompter {
612 let password = match ssh_key::analyze_ssh_key_file(&self.private_key) {
613 Err(e) => {
614 warn!("Failed to analyze SSH key: {}: {}", self.private_key.display(), e);
615 None
616 },
617 Ok(key_info) => {
618 if key_info.format == ssh_key::KeyFormat::Unknown {
619 warn!("Unknown key format for key: {}", self.private_key.display());
620 }
621 if key_info.encrypted {
622 prompter.prompt_ssh_key_passphrase(&self.private_key, git_config)
623 } else {
624 None
625 }
626 },
627 };
628 git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, password.as_deref())
629 } else {
630 git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, None)
631 }
632 }
633}
634
635#[derive(Debug, Clone)]
636struct PlaintextCredentials {
637 username: String,
638 password: String,
639}
640
641impl PlaintextCredentials {
642 fn prompt(prompter: &mut dyn Prompter, username: Option<&str>, url: &str, git_config: &git2::Config) -> Option<Self> {
643 if let Some(username) = username {
644 let password = prompter.prompt_password(username, url, git_config)?;
645 Some(Self {
646 username: username.into(),
647 password,
648 })
649 } else {
650 let (username, password) = prompter.prompt_username_password(url, git_config)?;
651 Some(Self {
652 username,
653 password,
654 })
655 }
656 }
657
658 fn to_credentials(&self) -> Result<git2::Cred, git2::Error> {
659 git2::Cred::userpass_plaintext(&self.username, &self.password)
660 }
661}
662
663fn get_pub_key_path(priv_key_path: &Path) -> Option<PathBuf> {
664 let name = priv_key_path.file_name()?;
665 let name = name.to_str()?;
666 let pub_key_path = priv_key_path.with_file_name(format!("{name}.pub"));
667 if pub_key_path.is_file() {
668 Some(pub_key_path)
669 } else {
670 None
671 }
672}
673
674fn domain_from_url(url: &str) -> Option<&str> {
675 let (head, tail) = url.split_once(':')?;
682
683 if let Some(tail) = tail.strip_prefix("//") {
685 let (_credentials, tail) = tail.split_once('@').unwrap_or(("", tail));
686 let (host, _path) = tail.split_once('/').unwrap_or((tail, ""));
687 Some(host)
688 } else {
690 let (_credentials, host) = head.split_once('@').unwrap_or(("", head));
691 Some(host)
692 }
693}
694
695#[cfg(test)]
696mod test {
697 use super::*;
698 use assert2::assert;
699
700 #[test]
701 fn test_domain_from_url() {
702 assert!(let Some("host") = domain_from_url("user@host:path"));
703 assert!(let Some("host") = domain_from_url("host:path"));
704 assert!(let Some("host") = domain_from_url("host:path@with:stuff"));
705
706 assert!(let Some("host") = domain_from_url("ssh://user:pass@host/path"));
707 assert!(let Some("host") = domain_from_url("ssh://user@host/path"));
708 assert!(let Some("host") = domain_from_url("ssh://host/path"));
709
710 assert!(let None = domain_from_url("some/relative/path"));
711 assert!(let None = domain_from_url("some/relative/path@with-at-sign"));
712 }
713
714 #[test]
715 fn test_that_authenticator_is_send() {
716 let authenticator = GitAuthenticator::new();
717 let thread = std::thread::spawn(move || {
718 drop(authenticator);
719 });
720 thread.join().unwrap();
721 }
722}