#![warn(missing_docs)]
use std::collections::BTreeMap;
use std::path::{PathBuf, Path};
#[cfg(feature = "log")]
mod log {
pub use ::log::warn;
pub use ::log::debug;
pub use ::log::trace;
}
#[cfg(feature = "log")]
use crate::log::*;
#[cfg(not(feature = "log"))]
#[macro_use]
mod log {
macro_rules! warn {
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
}
macro_rules! debug {
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
}
macro_rules! trace {
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
}
}
mod base64_decode;
mod default_prompt;
mod prompter;
mod ssh_key;
pub use prompter::Prompter;
#[derive(Clone)]
pub struct GitAuthenticator {
plaintext_credentials: BTreeMap<String, PlaintextCredentials>,
try_cred_helper: bool,
try_password_prompt: u32,
usernames: BTreeMap<String, String>,
try_ssh_agent: bool,
ssh_keys: Vec<PrivateKeyFile>,
prompt_ssh_key_password: bool,
prompter: Box<dyn prompter::ClonePrompter>,
}
impl std::fmt::Debug for GitAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitAuthenticator")
.field("plaintext_credentials", &self.plaintext_credentials)
.field("try_cred_helper", &self.try_cred_helper)
.field("try_password_prompt", &self.try_password_prompt)
.field("usernames", &self.usernames)
.field("try_ssh_agent", &self.try_ssh_agent)
.field("ssh_keys", &self.ssh_keys)
.field("prompt_ssh_key_password", &self.prompt_ssh_key_password)
.finish()
}
}
impl Default for GitAuthenticator {
fn default() -> Self {
Self::new()
}
}
impl GitAuthenticator {
pub fn new() -> Self {
Self::new_empty()
.try_cred_helper(true)
.try_password_prompt(3)
.add_default_username()
.try_ssh_agent(true)
.add_default_ssh_keys()
.prompt_ssh_key_password(true)
}
pub fn new_empty() -> Self {
Self {
try_ssh_agent: false,
try_cred_helper: false,
plaintext_credentials: BTreeMap::new(),
try_password_prompt: 0,
usernames: BTreeMap::new(),
ssh_keys: Vec::new(),
prompt_ssh_key_password: false,
prompter: prompter::wrap_prompter(default_prompt::DefaultPrompter),
}
}
pub fn add_plaintext_credentials(mut self, domain: impl Into<String>, username: impl Into<String>, password: impl Into<String>) -> Self {
let domain = domain.into();
let username = username.into();
let password = password.into();
self.plaintext_credentials.insert(domain, PlaintextCredentials {
username,
password,
});
self
}
pub fn try_cred_helper(mut self, enable: bool) -> Self {
self.try_cred_helper = enable;
self
}
pub fn try_password_prompt(mut self, max_count: u32) -> Self {
self.try_password_prompt = max_count;
self
}
pub fn set_prompter<P: Prompter + Clone + Send + 'static>(mut self, prompter: P) -> Self {
self.prompter = prompter::wrap_prompter(prompter);
self
}
pub fn add_username(mut self, domain: impl Into<String>, username: impl Into<String>) -> Self {
let domain = domain.into();
let username = username.into();
self.usernames.insert(domain, username);
self
}
pub fn add_default_username(self) -> Self {
if let Ok(username) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
self.add_username("*", username)
} else {
self
}
}
pub fn try_ssh_agent(mut self, enable: bool) -> Self {
self.try_ssh_agent = enable;
self
}
pub fn add_ssh_key_from_file(mut self, private_key: impl Into<PathBuf>, password: impl Into<Option<String>>) -> Self {
let private_key = private_key.into();
let public_key = get_pub_key_path(&private_key);
let password = password.into();
self.ssh_keys.push(PrivateKeyFile {
private_key,
public_key,
password,
});
self
}
pub fn add_default_ssh_keys(mut self) -> Self {
let ssh_dir = match dirs::home_dir() {
Some(x) => x.join(".ssh"),
None => return self,
};
let candidates = [
"id_rsa",
"id_ecdsa",
"id_ecdsa_sk",
"id_ed25519",
"id_ed25519_sk",
"id_dsa",
];
for candidate in candidates {
let private_key = ssh_dir.join(candidate);
if !private_key.is_file() {
continue;
}
self = self.add_ssh_key_from_file(private_key, None);
}
self
}
pub fn prompt_ssh_key_password(mut self, enable: bool) -> Self {
self.prompt_ssh_key_password = enable;
self
}
pub fn credentials<'a>(
&'a self,
git_config: &'a git2::Config,
) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
make_credentials_callback(self, git_config)
}
pub fn clone_repo(&self, url: impl AsRef<str>, into: impl AsRef<Path>) -> Result<git2::Repository, git2::Error> {
let url = url.as_ref();
let into = into.as_ref();
let git_config = git2::Config::open_default()?;
let mut repo_builder = git2::build::RepoBuilder::new();
let mut fetch_options = git2::FetchOptions::new();
let mut remote_callbacks = git2::RemoteCallbacks::new();
remote_callbacks.credentials(self.credentials(&git_config));
fetch_options.remote_callbacks(remote_callbacks);
repo_builder.fetch_options(fetch_options);
repo_builder.clone(url, into)
}
pub fn fetch(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str], reflog_msg: Option<&str>) -> Result<(), git2::Error> {
let git_config = repo.config()?;
let mut fetch_options = git2::FetchOptions::new();
let mut remote_callbacks = git2::RemoteCallbacks::new();
remote_callbacks.credentials(self.credentials(&git_config));
fetch_options.remote_callbacks(remote_callbacks);
remote.fetch(refspecs, Some(&mut fetch_options), reflog_msg)
}
pub fn download(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
let git_config = repo.config()?;
let mut fetch_options = git2::FetchOptions::new();
let mut remote_callbacks = git2::RemoteCallbacks::new();
remote_callbacks.credentials(self.credentials(&git_config));
fetch_options.remote_callbacks(remote_callbacks);
remote.download(refspecs, Some(&mut fetch_options))
}
pub fn push(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
let git_config = repo.config()?;
let mut push_options = git2::PushOptions::new();
let mut remote_callbacks = git2::RemoteCallbacks::new();
remote_callbacks.credentials(self.credentials(&git_config));
push_options.remote_callbacks(remote_callbacks);
remote.push(refspecs, Some(&mut push_options))
}
fn get_username(&self, url: &str) -> Option<&str> {
if let Some(domain) = domain_from_url(url) {
if let Some(username) = self.usernames.get(domain) {
return Some(username);
}
}
self.usernames.get("*").map(|x| x.as_str())
}
fn get_plaintext_credentials(&self, url: &str) -> Option<&PlaintextCredentials> {
if let Some(domain) = domain_from_url(url) {
if let Some(credentials) = self.plaintext_credentials.get(domain) {
return Some(credentials);
}
}
self.plaintext_credentials.get("*")
}
}
fn make_credentials_callback<'a>(
authenticator: &'a GitAuthenticator,
git_config: &'a git2::Config,
) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
let mut try_cred_helper = authenticator.try_cred_helper;
let mut try_password_prompt = authenticator.try_password_prompt;
let mut try_ssh_agent = authenticator.try_ssh_agent;
let mut ssh_keys = authenticator.ssh_keys.iter();
let mut prompter = authenticator.prompter.clone();
move |url: &str, username: Option<&str>, allowed: git2::CredentialType| {
trace!("credentials callback called with url: {url:?}, username: {username:?}, allowed_credentials: {allowed:?}");
if allowed.contains(git2::CredentialType::USERNAME) {
if let Some(username) = authenticator.get_username(url) {
debug!("credentials_callback: returning username: {username:?}");
match git2::Cred::username(username) {
Ok(x) => return Ok(x),
Err(e) => {
debug!("credentials_callback: failed to wrap username: {e}");
return Err(e);
},
}
}
}
if allowed.contains(git2::CredentialType::SSH_KEY) {
if let Some(username) = username {
if try_ssh_agent {
try_ssh_agent = false;
debug!("credentials_callback: trying ssh_key_from_agent with username: {username:?}");
match git2::Cred::ssh_key_from_agent(username) {
Ok(x) => return Ok(x),
Err(e) => debug!("credentials_callback: failed to use SSH agent: {e}"),
}
}
#[allow(clippy::while_let_on_iterator)] while let Some(key) = ssh_keys.next() {
debug!("credentials_callback: trying ssh key, username: {username:?}, private key: {:?}", key.private_key);
let prompter = Some(prompter.as_prompter_mut())
.filter(|_| authenticator.prompt_ssh_key_password);
match key.to_credentials(username, prompter, git_config) {
Ok(x) => return Ok(x),
Err(e) => debug!("credentials_callback: failed to use SSH key from file {:?}: {e}", key.private_key),
}
}
}
}
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Some(credentials) = authenticator.get_plaintext_credentials(url) {
debug!("credentials_callback: trying plain text credentials with username: {:?}", credentials.username);
match credentials.to_credentials() {
Ok(x) => return Ok(x),
Err(e) => {
debug!("credentials_callback: failed to wrap plain text credentials: {e}");
return Err(e);
},
}
}
if try_cred_helper {
try_cred_helper = false;
debug!("credentials_callback: trying credential_helper");
match git2::Cred::credential_helper(git_config, url, username) {
Ok(x) => return Ok(x),
Err(e) => debug!("credentials_callback: failed to use credential helper: {e}"),
}
}
if try_password_prompt > 0 {
try_password_prompt -= 1;
let credentials = PlaintextCredentials::prompt(
prompter.as_prompter_mut(),
username,
url,
git_config
);
if let Some(credentials) = credentials {
return credentials.to_credentials();
}
}
}
Err(git2::Error::from_str("all authentication attempts failed"))
}
}
#[derive(Debug, Clone)]
struct PrivateKeyFile {
private_key: PathBuf,
public_key: Option<PathBuf>,
password: Option<String>,
}
impl PrivateKeyFile {
fn to_credentials(&self, username: &str, prompter: Option<&mut dyn Prompter>, git_config: &git2::Config) -> Result<git2::Cred, git2::Error> {
if let Some(password) = &self.password {
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, Some(password))
} else if let Some(prompter) = prompter {
let password = match ssh_key::analyze_ssh_key_file(&self.private_key) {
Err(e) => {
warn!("Failed to analyze SSH key: {}: {}", self.private_key.display(), e);
None
},
Ok(key_info) => {
if key_info.format == ssh_key::KeyFormat::Unknown {
warn!("Unknown key format for key: {}", self.private_key.display());
}
if key_info.encrypted {
prompter.prompt_ssh_key_passphrase(&self.private_key, git_config)
} else {
None
}
},
};
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, password.as_deref())
} else {
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, None)
}
}
}
#[derive(Debug, Clone)]
struct PlaintextCredentials {
username: String,
password: String,
}
impl PlaintextCredentials {
fn prompt(prompter: &mut dyn Prompter, username: Option<&str>, url: &str, git_config: &git2::Config) -> Option<Self> {
if let Some(username) = username {
let password = prompter.prompt_password(username, url, git_config)?;
Some(Self {
username: username.into(),
password,
})
} else {
let (username, password) = prompter.prompt_username_password(url, git_config)?;
Some(Self {
username,
password,
})
}
}
fn to_credentials(&self) -> Result<git2::Cred, git2::Error> {
git2::Cred::userpass_plaintext(&self.username, &self.password)
}
}
fn get_pub_key_path(priv_key_path: &Path) -> Option<PathBuf> {
let name = priv_key_path.file_name()?;
let name = name.to_str()?;
let pub_key_path = priv_key_path.with_file_name(format!("{name}.pub"));
if pub_key_path.is_file() {
Some(pub_key_path)
} else {
None
}
}
fn domain_from_url(url: &str) -> Option<&str> {
let (head, tail) = url.split_once(':')?;
if let Some(tail) = tail.strip_prefix("//") {
let (_credentials, tail) = tail.split_once('@').unwrap_or(("", tail));
let (host, _path) = tail.split_once('/').unwrap_or((tail, ""));
Some(host)
} else {
let (_credentials, host) = head.split_once('@').unwrap_or(("", head));
Some(host)
}
}
#[cfg(test)]
mod test {
use super::*;
use assert2::assert;
#[test]
fn test_domain_from_url() {
assert!(let Some("host") = domain_from_url("user@host:path"));
assert!(let Some("host") = domain_from_url("host:path"));
assert!(let Some("host") = domain_from_url("host:path@with:stuff"));
assert!(let Some("host") = domain_from_url("ssh://user:pass@host/path"));
assert!(let Some("host") = domain_from_url("ssh://user@host/path"));
assert!(let Some("host") = domain_from_url("ssh://host/path"));
assert!(let None = domain_from_url("some/relative/path"));
assert!(let None = domain_from_url("some/relative/path@with-at-sign"));
}
#[test]
fn test_that_authenticator_is_send() {
let authenticator = GitAuthenticator::new();
let thread = std::thread::spawn(move || {
drop(authenticator);
});
thread.join().unwrap();
}
}