1
0
Fork 0

Adding upstream version 0.5.5.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 05:16:34 +01:00
parent dde4be91ba
commit d2d6608958
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
17 changed files with 2615 additions and 0 deletions

110
src/base64_decode.rs Normal file
View file

@ -0,0 +1,110 @@
/// An error that can occur during base64 decoding.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Error {
InvalidBase64Char(u8),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBase64Char(value) => write!(f, "Invalid base64 character: {:?}", char::from_u32(*value as u32).unwrap()),
}
}
}
/// Decode a base64 string.
///
/// Padding in the input is optional.
pub fn base64_decode(input: &[u8]) -> Result<Vec<u8>, Error> {
let input = match input.iter().rposition(|&byte| byte != b'=' && !byte.is_ascii_whitespace()) {
Some(x) => &input[..=x],
None => return Ok(Vec::new()),
};
let mut output = Vec::with_capacity((input.len() + 3) / 4 * 3);
let mut decoder = Base64Decoder::new();
for &byte in input {
if byte.is_ascii_whitespace() {
continue;
}
if let Some(byte) = decoder.feed(byte)? {
output.push(byte);
}
}
Ok(output)
}
/// Get the 6 bit value for a base64 character.
fn base64_value(byte: u8) -> Result<u8, Error> {
match byte {
b'A'..=b'Z' => Ok(byte - b'A'),
b'a'..=b'z' => Ok(byte - b'a' + 26),
b'0'..=b'9' => Ok(byte - b'0' + 52),
b'+' => Ok(62),
b'/' => Ok(63),
byte => Err(Error::InvalidBase64Char(byte)),
}
}
/// Decoder for base64 data.
struct Base64Decoder {
/// The current buffer.
buffer: u16,
/// The number of valid bits in the buffer.
valid_bits: u8,
}
impl Base64Decoder {
/// Create a new base64 decoder.
fn new() -> Self {
Self {
buffer: 0,
valid_bits: 0,
}
}
/// Feed a base64 character to the decoder.
///
/// Returns `Ok(Some(u8))` if a new character is fully decoded.
/// Returns `Ok(None)` if there is no new character available yet.
fn feed(&mut self, byte: u8) -> Result<Option<u8>, Error> {
debug_assert!(self.valid_bits < 8);
// Paste the new 6 bit value at the least significant position in the buffer.
self.buffer |= (base64_value(byte)? as u16) << (10 - self.valid_bits);
// Bump the number of valid bits.
self.valid_bits += 6;
// Consume the most significant byte if it is complete.
Ok(self.consume_buffer_front())
}
/// Consume the first character in the buffer.
fn consume_buffer_front(&mut self) -> Option<u8> {
if self.valid_bits >= 8 {
let value = self.buffer >> 8 & 0xFF;
self.buffer <<= 8;
self.valid_bits -= 8;
Some(value as u8)
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::*;
use assert2::assert;
#[test]
fn test_decode_base64() {
assert!(let Ok(b"0") = base64_decode(b"MA").as_deref());
assert!(let Ok(b"0") = base64_decode(b"MA=").as_deref());
assert!(let Ok(b"0") = base64_decode(b"MA==").as_deref());
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw").as_deref());
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw=").as_deref());
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw==").as_deref());
}
}

183
src/default_prompt.rs Normal file
View file

@ -0,0 +1,183 @@
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(feature = "log")]
use crate::log::*;
#[derive(Copy, Clone)]
pub(crate) struct DefaultPrompter;
impl crate::Prompter for DefaultPrompter {
fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)> {
prompt_username_password(url, git_config)
.map_err(|e| log_error("username and password", &e))
.ok()
}
fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String> {
prompt_password(username, url, git_config)
.map_err(|e| log_error("password", &e))
.ok()
}
fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String> {
prompt_ssh_key_passphrase(private_key_path, git_config)
.map_err(|e| log_error("SSH key passphrase", &e))
.ok()
}
}
fn log_error(kind: &str, error: &Error) {
warn!("Failed to prompt the user for {kind}: {error}");
if let Error::AskpassExitStatus(error) = error {
if let Some(extra_message) = error.extra_message() {
for line in extra_message.lines() {
warn!("askpass: {line}");
}
}
}
}
/// Error that can occur when prompting for a password.
pub enum Error {
/// Failed to run the askpass command.
AskpassCommand(std::io::Error),
/// Askpass command exitted with a non-zero error code.
AskpassExitStatus(AskpassExitStatusError),
/// Password contains invalid UTF-8.
InvalidUtf8(std::string::FromUtf8Error),
/// Failed to open a handle to the main terminal of the process.
OpenTerminal(std::io::Error),
/// Failed to read/write to the terminal.
ReadWriteTerminal(std::io::Error),
}
/// The askpass process exited with a non-zero exit code.
pub struct AskpassExitStatusError {
/// The exit status of the askpass process.
pub status: std::process::ExitStatus,
/// The standard error of the askpass process.
pub stderr: Result<String, std::string::FromUtf8Error>,
}
impl AskpassExitStatusError {
/// Get the extra error message, if any.
///
/// This will give the standard error of the askpass process if it exited with an error.
pub fn extra_message(&self) -> Option<&str> {
self.stderr.as_deref().ok()
}
}
/// Prompt the user for a username and password for a particular URL.
///
/// This uses the askpass helper if configured,
/// and falls back to prompting on the terminal otherwise.
fn prompt_username_password(url: &str, git_config: &git2::Config) -> Result<(String, String), Error> {
if let Some(askpass) = askpass_command(git_config) {
let username = askpass_prompt(&askpass, &format!("Username for {url}"))?;
let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
Ok((username, password))
} else {
let mut terminal = terminal_prompt::Terminal::open()
.map_err(Error::OpenTerminal)?;
writeln!(terminal, "Authentication needed for {url}")
.map_err(Error::ReadWriteTerminal)?;
let username = terminal.prompt("Username: ")
.map_err(Error::ReadWriteTerminal)?;
let password = terminal.prompt_sensitive("Password: ")
.map_err(Error::ReadWriteTerminal)?;
Ok((username, password))
}
}
/// Prompt the user for a password for a particular URL and username.
///
/// This uses the askpass helper if configured,
/// and falls back to prompting on the terminal otherwise.
fn prompt_password(_username: &str, url: &str, git_config: &git2::Config) -> Result<String, Error> {
if let Some(askpass) = askpass_command(git_config) {
let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
Ok(password)
} else {
let mut terminal = terminal_prompt::Terminal::open()
.map_err(Error::OpenTerminal)?;
writeln!(terminal, "Authentication needed for {url}")
.map_err(Error::ReadWriteTerminal)?;
let password = terminal.prompt_sensitive("Password: ")
.map_err(Error::ReadWriteTerminal)?;
Ok(password)
}
}
/// Prompt the user for the password of an encrypted SSH key.
///
/// This uses the askpass helper if configured,
/// and falls back to prompting on the terminal otherwise.
fn prompt_ssh_key_passphrase(private_key_path: &Path, git_config: &git2::Config) -> Result<String, Error> {
if let Some(askpass) = askpass_command(git_config) {
askpass_prompt(&askpass, &format!("Password for {}", private_key_path.display()))
} else {
let mut terminal = terminal_prompt::Terminal::open()
.map_err(Error::OpenTerminal)?;
writeln!(terminal, "Password needed for {}", private_key_path.display())
.map_err(Error::ReadWriteTerminal)?;
terminal.prompt_sensitive("Password: ")
.map_err(Error::ReadWriteTerminal)
}
}
/// Get the configured askpass program, if any.
fn askpass_command(git_config: &git2::Config) -> Option<PathBuf> {
if let Some(command) = std::env::var_os("GIT_ASKPASS") {
Some(command.into())
} else if let Ok(command) = git_config.get_path("core.askPass") {
return Some(command)
} else if let Some(command) = std::env::var_os("SSH_ASKPASS") {
return Some(command.into());
} else {
None
}
}
/// Prompt the user using the given askpass program.
fn askpass_prompt(program: &Path, prompt: &str) -> Result<String, Error> {
let output = std::process::Command::new(program)
.arg(prompt)
.output()
.map_err(Error::AskpassCommand)?;
if output.status.success() {
let password = String::from_utf8(output.stdout)
.map_err(Error::InvalidUtf8)?;
Ok(password)
} else {
// Do not keep stdout, it could contain a password D:
Err(Error::AskpassExitStatus(AskpassExitStatusError {
status: output.status,
stderr: String::from_utf8(output.stderr),
}))
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AskpassCommand(e) => write!(f, "Failed to run askpass command: {e}"),
Self::AskpassExitStatus(e) => write!(f, "{e}"),
Self::InvalidUtf8(_) => write!(f, "User response contains invalid UTF-8"),
Self::OpenTerminal(e) => write!(f, "Failed to open terminal: {e}"),
Self::ReadWriteTerminal(e) => write!(f, "Failed to read/write to terminal: {e}"),
}
}
}
impl std::fmt::Display for AskpassExitStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Program exitted with {}", self.status)
}
}

719
src/lib.rs Normal file
View file

@ -0,0 +1,719 @@
//! Easy authentication for [`git2`].
//!
//! Authentication with [`git2`] can be quite difficult to implement correctly.
//! This crate aims to make it easy.
//!
//! # Features
//!
//! * Has a small dependency tree.
//! * Can query the SSH agent for private key authentication.
//! * Can get SSH keys from files.
//! * Can prompt the user for passwords for encrypted SSH keys.
//! * Only supported for OpenSSH private keys.
//! * Can query the git credential helper for usernames and passwords.
//! * Can use pre-provided plain usernames and passwords.
//! * Can prompt the user for credentials as a last resort.
//! * Allows you to fully customize all user prompts.
//!
//! The default user prompts will:
//! * Use the git `askpass` helper if it is configured.
//! * Fall back to prompting the user on the terminal if there is no `askpass` program configured.
//! * Skip the prompt if there is also no terminal available for the process.
//!
//! # Creating an authenticator and enabling authentication mechanisms
//!
//! You can create use [`GitAuthenticator::new()`] (or [`default()`][`GitAuthenticator::default()`]) to create a ready-to-use authenticator.
//! Using one of these constructors will enable all supported authentication mechanisms.
//! You can still add more private key files from non-default locations to try if desired.
//!
//! You can also use [`GitAuthenticator::new_empty()`] to create an authenticator without any authentication mechanism enabled.
//! Then you can selectively enable authentication mechanisms and add custom private key files.
//!
//! # Using the authenticator
//!
//! For the most flexibility, you can get a [`git2::Credentials`] callback using the [`GitAuthenticator::credentials()`] function.
//! You can use it with any git operation that requires authentication.
//! Doing this gives you full control to set other options and callbacks for the git operation.
//!
//! If you don't need to set other options or callbacks, you can also use the convenience functions on [`GitAuthenticator`].
//! They wrap git operations with the credentials callback set:
//!
//! * [`GitAuthenticator::clone_repo()`]
//! * [`GitAuthenticator::fetch()`]
//! * [`GitAuthenticator::download()`]
//! * [`GitAuthenticator::push()`]
//!
//! # Customizing user prompts
//!
//! All user prompts can be fully customized by calling [`GitAuthenticator::set_prompter()`].
//! This allows you to override the way that the user is prompted for credentials or passphrases.
//!
//! If you have a fancy user interface, you can use a custom prompter to integrate the prompts with your user interface.
//!
//! # Example: Clone a repository
//!
//! ```no_run
//! # fn main() -> Result<(), git2::Error> {
//! use auth_git2::GitAuthenticator;
//! use std::path::Path;
//!
//! let url = "https://github.com/de-vri-es/auth-git2-rs";
//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
//!
//! let auth = GitAuthenticator::default();
//! let mut repo = auth.clone_repo(url, into);
//! # let _ = repo;
//! # Ok(())
//! # }
//! ```
//!
//! # Example: Clone a repository with full control over fetch options
//!
//! ```no_run
//! # fn main() -> Result<(), git2::Error> {
//! use auth_git2::GitAuthenticator;
//! use std::path::Path;
//!
//! let auth = GitAuthenticator::default();
//! 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(auth.credentials(&git_config));
//! fetch_options.remote_callbacks(remote_callbacks);
//! repo_builder.fetch_options(fetch_options);
//!
//! let url = "https://github.com/de-vri-es/auth-git2-rs";
//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
//! let mut repo = repo_builder.clone(url, into);
//! # let _ = repo;
//! # Ok(())
//! # }
//! ```
#![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;
/// Configurable authenticator to use with [`git2`].
#[derive(Clone)]
pub struct GitAuthenticator {
/// Map of domain names to plaintext credentials.
plaintext_credentials: BTreeMap<String, PlaintextCredentials>,
/// Try getting username/password from the git credential helper.
try_cred_helper: bool,
/// Number of times to ask the user for a username/password on the terminal.
try_password_prompt: u32,
/// Map of domain names to usernames to try for SSH connections if no username was specified.
usernames: BTreeMap<String, String>,
/// Try to use the SSH agent to get a working SSH key.
try_ssh_agent: bool,
/// SSH keys to use from file.
ssh_keys: Vec<PrivateKeyFile>,
/// Prompt for passwords for encrypted SSH keys.
prompt_ssh_key_password: bool,
/// Custom prompter to use.
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 {
/// Create a new authenticator with all supported options enabled.
///
/// This is the same as [`GitAuthenticator::new()`].
fn default() -> Self {
Self::new()
}
}
impl GitAuthenticator {
/// Create a new authenticator with all supported options enabled.
///
/// This is equivalent to:
/// ```
/// # use auth_git2::GitAuthenticator;
/// GitAuthenticator::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() -> 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)
}
/// Create a new authenticator with all authentication options disabled.
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),
}
}
/// Set the username + password to use for a specific domain.
///
/// Use the special value "*" for the domain name to add fallback credentials when there is no exact match for the domain.
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
}
/// Configure if the git credentials helper should be used.
///
/// See the git documentation of the `credential.helper` configuration options for more details.
pub fn try_cred_helper(mut self, enable: bool) -> Self {
self.try_cred_helper = enable;
self
}
/// Configure the number of times we should prompt the user for a username/password.
///
/// Setting this value to `0` disables password prompts.
///
/// By default, if an `askpass` helper is configured, it will be used for the prompts.
/// Otherwise, the user will be prompted directly on the terminal of the current process.
/// If there is also no terminal available, the prompt is skipped.
///
/// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
/// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
///
/// You can override the prompt behaviour by calling [`Self::set_prompter()`].
pub fn try_password_prompt(mut self, max_count: u32) -> Self {
self.try_password_prompt = max_count;
self
}
/// Use a custom [`Prompter`] to prompt the user for credentials and passphrases.
///
/// If you set a custom prompter,
/// the authenticator will no longer try to use the `askpass` helper or prompt the user on the terminal.
/// Instead, the provided prompter will be called.
///
/// Note that prompts must still be enabled with [`Self::try_password_prompt()`] and [`Self::prompt_ssh_key_password()`].
/// If prompts are disabled, your custom prompter will not be called.
///
/// You can use this function to integrate the prompts with your own user interface
/// or simply to tweak the way the user is prompted on the terminal.
///
/// A unique clone of the prompter will be used for each [`git2::Credentials`] callback returned by [`Self::credentials()`].
pub fn set_prompter<P: Prompter + Clone + Send + 'static>(mut self, prompter: P) -> Self {
self.prompter = prompter::wrap_prompter(prompter);
self
}
/// Add a username to try for authentication for a specific domain.
///
/// Some authentication mechanisms need a username, but not all valid git URLs specify one.
/// You can add one or more usernames to try in that situation.
///
/// You can use the special domain name "*" to set a fallback username for domains that do not have a specific username set.
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
}
/// Add the default username to try.
///
/// The default username if read from the `USER` or `USERNAME` environment variable.
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
}
}
/// Configure if the SSH agent should be used for public key authentication.
pub fn try_ssh_agent(mut self, enable: bool) -> Self {
self.try_ssh_agent = enable;
self
}
/// Add a private key to use for public key authentication.
///
/// The key will be read from disk by `git2`, so it must still exist when the authentication is performed.
///
/// You can provide a password for decryption of the private key.
/// If no password is provided and the `Self::prompt_ssh_key_password()` is enabled,
/// the user will be prompted for the passphrase of encrypted keys.
/// Note that currently only the `OpenSSH` private key format is supported for detecting that a key is encrypted.
///
/// A matching `.pub` file will also be read if it exists.
/// For example, if you add the private key `"foo/my_ssh_id"`,
/// then `"foo/my_ssh_id.pub"` will be used too, if it exists.
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
}
/// Add all default SSH keys for public key authentication.
///
/// This will add all of the following files, if they exist:
///
/// * `"$HOME/.ssh/id_rsa"`
/// * `"$HOME/.ssh/id_ecdsa"`
/// * `"$HOME/.ssh/id_ecdsa_sk"`
/// * `"$HOME/.ssh/id_ed25519"`
/// * `"$HOME/.ssh/id_ed25519_sk"`
/// * `"$HOME/.ssh/id_dsa"`
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
}
/// Prompt for passwords for encrypted SSH keys if needed.
///
/// By default, if an `askpass` helper is configured, it will be used for the prompts.
/// Otherwise, the user will be prompted directly on the terminal of the current process.
/// If there is also no terminal available, the prompt is skipped.
///
/// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
/// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
///
/// You can override the prompt behaviour by calling [`Self::set_prompter()`].
pub fn prompt_ssh_key_password(mut self, enable: bool) -> Self {
self.prompt_ssh_key_password = enable;
self
}
/// Get the credentials callback to use for [`git2::Credentials`].
///
/// # Example: Fetch from a remote with authentication
/// ```no_run
/// # fn foo(repo: &mut git2::Repository) -> Result<(), git2::Error> {
/// use auth_git2::GitAuthenticator;
///
/// let auth = GitAuthenticator::default();
/// let git_config = repo.config()?;
/// let mut fetch_options = git2::FetchOptions::new();
/// let mut remote_callbacks = git2::RemoteCallbacks::new();
///
/// remote_callbacks.credentials(auth.credentials(&git_config));
/// fetch_options.remote_callbacks(remote_callbacks);
///
/// repo.find_remote("origin")?
/// .fetch(&["main"], Some(&mut fetch_options), None)?;
/// # Ok(())
/// # }
/// ```
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)
}
/// Clone a repository using the git authenticator.
///
/// If you need more control over the clone options,
/// use [`Self::credentials()`] with a [`git2::build::RepoBuilder`].
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)
}
/// Fetch from a remote using the git authenticator.
///
/// If you need more control over the fetch options,
/// use [`Self::credentials()`] with [`git2::Remote::fetch()`].
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)
}
/// Download and index the packfile from a remote using the git authenticator.
///
/// If you need more control over the download options,
/// use [`Self::credentials()`] with [`git2::Remote::download()`].
///
/// This function does not update the remote tracking branches.
/// Consider using [`Self::fetch()`] if that is what you want.
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))
}
/// Push to a remote using the git authenticator.
///
/// If you need more control over the push options,
/// use [`Self::credentials()`] with [`git2::Remote::push()`].
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))
}
/// Get the configured username for a URL.
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())
}
/// Get the configured plaintext credentials for a URL.
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 git2 is asking for a username, we got an SSH url without username specified.
// After we supply a username, it will ask for the real credentials.
//
// Sadly, we can not switch usernames during an authentication session,
// so to try different usernames, we need to retry the git operation multiple times.
// If this happens, we'll bail and go into stage 2.
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);
},
}
}
}
// Try public key authentication.
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)] // Incorrect lint: we're not consuming the 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),
}
}
}
}
// Sometimes libgit2 will ask for a username/password in plaintext.
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
// Try provided plaintext credentials first.
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);
},
}
}
// Try the git credential helper.
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}"),
}
}
// Prompt the user on the terminal.
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.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> {
// We support:
// Relative paths
// Real URLs: scheme://[user[:pass]@]host/path
// SSH URLs: [user@]host:path.
// If there is no colon: URL is a relative path and there is no domain (or need for credentials).
let (head, tail) = url.split_once(':')?;
// Real URL
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)
// SSH "URL"
} 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();
}
}

65
src/prompter.rs Normal file
View file

@ -0,0 +1,65 @@
use std::path::Path;
/// Trait for customizing user prompts.
///
/// You can provide an implementor of this trait to customize the way a user is prompted for credentials and passphrases.
pub trait Prompter: Send {
/// Promp the user for a username and password.
///
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)>;
/// Promp the user for a password when the username is already known.
///
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String>;
/// Promp the user for the passphrase of an encrypted SSH key.
///
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String>;
}
/// Wrap a clonable [`Prompter`] in a `Box<dyn MakePrompter>`.
pub(crate) fn wrap_prompter<P>(prompter: P) -> Box<dyn ClonePrompter>
where
P: Prompter + Clone + 'static,
{
Box::new(prompter)
}
/// Trait to allow making clones of a `Box<dyn Prompter + Send>`.
pub(crate) trait ClonePrompter: Prompter {
/// Clone the `Box<dyn ClonePrompter>`.
fn dyn_clone(&self) -> Box<dyn ClonePrompter>;
/// Get `self` as plain `Prompter`.
fn as_prompter(&self) -> &dyn Prompter;
/// Get `self` as plain `Prompter`.
fn as_prompter_mut(&mut self) -> &mut dyn Prompter;
}
/// Implement `ClonePrompter` for clonable Prompters.
impl<P> ClonePrompter for P
where
P: Prompter + Clone + 'static,
{
fn dyn_clone(&self) -> Box<dyn ClonePrompter> {
Box::new(self.clone())
}
fn as_prompter(&self) -> &dyn Prompter {
self
}
fn as_prompter_mut(&mut self) -> &mut dyn Prompter {
self
}
}
impl Clone for Box<dyn ClonePrompter> {
fn clone(&self) -> Self {
self.dyn_clone()
}
}

156
src/ssh_key.rs Normal file
View file

@ -0,0 +1,156 @@
use std::path::Path;
use crate::base64_decode;
/// An error that can occur when analyzing SSH keys.
#[derive(Debug)]
pub enum Error {
/// Failed to open the key file.
OpenFile(std::io::Error),
/// Failed to read from the key file.
ReadFile(std::io::Error),
/// Missing PEM trailer in the file (there was a PEM header).
MissingPemTrailer,
/// The key is not valid somehow.
MalformedKey,
/// There was an invalid base64 blob in the key.
Base64(base64_decode::Error),
}
/// The format of a key file.
pub enum KeyFormat {
/// We don't know what format it is.
Unknown,
/// It's an openssh-key-v1 file.
///
/// See https://coolaj86.com/articles/the-openssh-private-key-format/ for a description of the format.
OpensshKeyV1,
}
/// Information about a key file.
pub struct KeyInfo {
/// The format of the key file.
pub format: KeyFormat,
/// Is the key encrypted?
pub encrypted: bool,
}
/// Analyze an SSH key file.
pub fn analyze_ssh_key_file(priv_key_path: &Path) -> Result<KeyInfo, Error> {
use std::io::Read;
let mut buffer = Vec::new();
let mut file = std::fs::File::open(priv_key_path)
.map_err(Error::OpenFile)?;
file.read_to_end(&mut buffer)
.map_err(Error::ReadFile)?;
analyze_pem_openssh_key(&buffer)
}
/// Analyze a PEM encoded openssh-key-v1 file.
fn analyze_pem_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
let data = trim_bytes(data);
let data = match data.strip_prefix(b"-----BEGIN OPENSSH PRIVATE KEY-----") {
Some(x) => x,
None => return Ok(KeyInfo { format: KeyFormat::Unknown, encrypted: false }),
};
let data = match data.strip_suffix(b"-----END OPENSSH PRIVATE KEY-----") {
Some(x) => x,
None => return Err(Error::MissingPemTrailer),
};
let data = base64_decode::base64_decode(data).map_err(Error::Base64)?;
analyze_binary_openssh_key(&data)
}
/// Analyze a binary openss-key-v1 blob.
fn analyze_binary_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
let tail = data.strip_prefix(b"openssh-key-v1\0")
.ok_or(Error::MalformedKey)?;
if tail.len() <= 4 {
return Err(Error::MalformedKey);
}
let (cipher_len, tail) = tail.split_at(4);
let cipher_len = u32::from_be_bytes(cipher_len.try_into().unwrap()) as usize;
if tail.len() < cipher_len {
return Err(Error::MalformedKey);
}
let cipher = &tail[..cipher_len];
let encrypted = cipher != b"none";
Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted })
}
/// Trim whitespace from the start and end of a byte slice.
fn trim_bytes(data: &[u8]) -> &[u8] {
let data = match data.iter().position(|b| !b.is_ascii_whitespace()) {
Some(x) => &data[x..],
None => return b"",
};
let data = match data.iter().rposition(|b| !b.is_ascii_whitespace()) {
Some(x) => &data[..=x],
None => return b"",
};
data
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OpenFile(e) => write!(f, "Failed to open file: {e}"),
Self::ReadFile(e) => write!(f, "Failed to read from file: {e}"),
Self::MissingPemTrailer => write!(f, "Missing PEM trailer in key file"),
Self::MalformedKey => write!(f, "Invalid or malformed key file"),
Self::Base64(e) => write!(f, "Invalid base64 in key file: {e}"),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use assert2::assert;
#[test]
fn test_is_encrypted_pem_openssh_key() {
// Encrypted OpenSSH key.
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
"b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
"6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n",
"8eQFG1zKYlhtLLz2GC3Sou+C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
"rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
"JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
"XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
"-----END OPENSSH PRIVATE KEY-----\n",
).as_bytes()));
// Encrypted OpenSSH key with extra random whitespace.
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
" \n\t\r-----BEGIN OPENSSH PRIVATE KEY-----\n",
"b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
"6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n \r",
"8eQFG1zKYlhtLLz2GC3Sou+ C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
"rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
"JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
"XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
"-----END OPENSSH PRIVATE KEY-----",
).as_bytes()));
// Unencrypted OpenSSH key.
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: false }) = analyze_pem_openssh_key(concat!(
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
"QyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3AAAAJhSNRa9UjUW\n",
"vQAAAAtzc2gtZWQyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3A\n",
"AAAECZObXz1xTSvl4vpLsMVTuhjroyDteKlW+Uun0yIMl7edMozT5FjMQugt7C/mflSgQ+\n",
"GYKnCSu1czgalZRUX7DcAAAAEW1hYXJ0ZW5AbWFnbmV0cm9uAQIDBA==\n",
"-----END OPENSSH PRIVATE KEY-----\n",
).as_bytes()));
}
}