Skip to content

Commit

Permalink
Merge pull request #2 from de-vri-es/custom-prompts
Browse files Browse the repository at this point in the history
Custom prompts
  • Loading branch information
de-vri-es committed Oct 8, 2023
2 parents 27fcf77 + fa0985d commit 54c1841
Show file tree
Hide file tree
Showing 9 changed files with 429 additions and 110 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Unreleased
- [add][minor] Add support for customizing user prompts with `GitAuthenticator::set_prompter()`.

# Version 0.5.2 - 2023-09-09
- [change][patch] Fix typo and formatting of nested list in documentation.

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ license = "BSD-2-Clause"
authors = ["Maarten de Vries <maarten@de-vri.es>"]
repository = "https://github.com/de-vri-es/auth-git2-rs"
documentation = "https://docs.rs/auth-git2"
keywords = ["git", "auth", "git2", "authentication", "ssh"]
keywords = ["git", "auth", "credentials", "git2", "authentication", "ssh"]
categories = ["authentication"]

edition = "2021"
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ This crate aims to make it easy.

## Features

* Small dependency tree.
* Query the SSH agent for private key authentication.
* Get SSH keys from files.
* Prompt the user for passwords for encrypted SSH keys.
* 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.
* Query the git credential helper for usernames and passwords.
* Use pre-provided plain usernames and passwords.
* Use the git askpass helper to ask the user for credentials.
* Fallback to prompting the user on the terminal if there is no askpass helper.
* 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

Expand All @@ -25,7 +30,6 @@ You can still add more private key files from non-default locations to try if de

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.
and selectively enable authentication methods or add private key files.

## Using the authenticator

Expand All @@ -40,6 +44,13 @@ They wrap git operations with the credentials callback set:
* [`GitAuthenticator::fetch()`]
* [`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

```rust
Expand Down Expand Up @@ -84,3 +95,4 @@ let mut repo = repo_builder.clone(url, into);
[`GitAuthenticator::clone_repo()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.clone_repo
[`GitAuthenticator::fetch()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.fetch
[`GitAuthenticator::push()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.push
[`GitAuthenticator::set_prompter()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.set_prompter
1 change: 1 addition & 0 deletions README.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
[`GitAuthenticator::clone_repo()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.clone_repo
[`GitAuthenticator::fetch()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.fetch
[`GitAuthenticator::push()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.push
[`GitAuthenticator::set_prompter()`]: https://docs.rs/auth-git2/latest/auth_git2/struct.GitAuthenticator.html#method.set_prompter
139 changes: 139 additions & 0 deletions examples/custom-prompt-clone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use std::path::{Path, PathBuf};

#[derive(Copy, Clone)]
struct YadPrompter;

impl auth_git2::Prompter for YadPrompter {
fn prompt_username_password(&mut self, url: &str, _git_config: &git2::Config) -> Option<(String, String)> {
let mut items = yad_prompt(
"Git authentication",
&format!("Authentication required for {url}"),
&["Username", "Password:H"],
).ok()?.into_iter();
let username = items.next()?;
let password = items.next()?;
Some((username, password))
}

fn prompt_password(&mut self, username: &str, url: &str, _git_config: &git2::Config) -> Option<String> {
let mut items = yad_prompt(
"Git authentication",
&format!("Authentication required for {url}"),
&[&format!("Username: {username}:LBL"), "Password:H"],
).ok()?.into_iter();
let password = items.next()?;
Some(password)
}

fn prompt_ssh_key_passphrase(&mut self, private_key_path: &std::path::Path, _git_config: &git2::Config) -> Option<String> {
let mut items = yad_prompt(
"Git authentication",
&format!("Passphrase required for {}", private_key_path.display()),
&["Passphrase:H"],
).ok()?.into_iter();
let passphrase = items.next()?;
Some(passphrase)
}
}

fn yad_prompt(title: &str, text: &str, fields: &[&str]) -> Result<Vec<String>, ()> {
let mut command = std::process::Command::new("yad");
command
.arg("--title")
.arg(title)
.arg("--text")
.arg(text)
.arg("--form")
.arg("--separator=\n");
for field in fields {
command.arg("--field");
command.arg(field);
}

let output = command
.stderr(std::process::Stdio::inherit())
.output()
.map_err(|e| log::error!("Failed to run `yad`: {e}"))?;

if !output.status.success() {
log::debug!("yad exited with {}", output.status);
return Err(());
}

let output = String::from_utf8(output.stdout)
.map_err(|_| log::warn!("Invalid UTF-8 in response from yad"))?;

let mut items: Vec<_> = output.splitn(fields.len() + 1, '\n')
.take(fields.len())
.map(|x| x.to_owned())
.collect();
if let Some(last) = items.pop() {
if !last.is_empty() {
items.push(last)
}
}

if items.len() != fields.len() {
log::error!("asked yad for {} values but got only {}", fields.len(), items.len());
Err(())
} else {
Ok(items)
}
}

#[derive(clap::Parser)]
struct Options {
/// Show more verbose statement.
#[clap(long, short)]
#[clap(global = true)]
#[clap(action = clap::ArgAction::Count)]
verbose: u8,

/// The URL of the repository to clone.
#[clap(value_name = "URL")]
repo: String,

/// The path where to clone the repository.
#[clap(value_name = "PATH")]
local_path: Option<PathBuf>,
}

fn main() {
if let Err(()) = do_main(clap::Parser::parse()) {
std::process::exit(1);
}
}

fn log_level(verbose: u8) -> log::LevelFilter {
match verbose {
0 => log::LevelFilter::Info,
1 => log::LevelFilter::Debug,
2.. => log::LevelFilter::Trace,
}
}

fn do_main(options: Options) -> Result<(), ()> {
let log_level = log_level(options.verbose);
env_logger::builder()
.parse_default_env()
.filter_module(module_path!(), log_level)
.filter_module("auth_git2", log_level)
.init();

let local_path = options.local_path.as_deref()
.unwrap_or_else(|| Path::new(repo_name_from_url(&options.repo)));

log::info!("Cloning {} into {}", options.repo, local_path.display());

let auth = auth_git2::GitAuthenticator::default()
.set_prompter(YadPrompter);
auth.clone_repo(&options.repo, local_path)
.map_err(|e| log::error!("Failed to clone {}: {}", options.repo, e))?;
Ok(())
}

fn repo_name_from_url(url: &str) -> &str {
url.rsplit_once('/')
.map(|(_head, tail)| tail)
.unwrap_or(url)
}
9 changes: 5 additions & 4 deletions examples/git.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};

#[derive(clap::Parser)]
struct Options {
Expand Down Expand Up @@ -100,7 +100,7 @@ fn do_main(options: Options) -> Result<(), ()> {

fn clone(command: CloneCommand) -> Result<(), ()> {
let local_path = command.local_path.as_deref()
.unwrap_or_else(|| repo_name_from_url(&command.repo).as_ref());
.unwrap_or_else(|| Path::new(repo_name_from_url(&command.repo)));

log::info!("Cloning {} into {}", command.repo, local_path.display());

Expand Down Expand Up @@ -140,6 +140,7 @@ fn push(command: PushCommand) -> Result<(), ()> {
}

fn repo_name_from_url(url: &str) -> &str {
let (_head, tail) = url.rsplit_once('/').unwrap_or(("", url));
tail
url.rsplit_once('/')
.map(|(_head, tail)| tail)
.unwrap_or(url)
}
100 changes: 65 additions & 35 deletions src/askpass.rs → src/default_prompt.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::PlaintextCredentials;
#[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 {
Expand Down Expand Up @@ -30,18 +65,6 @@ pub struct AskpassExitStatusError {
pub stderr: Result<String, std::string::FromUtf8Error>,
}

impl Error {
/// 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> {
match self {
Self::AskpassExitStatus(e) => e.extra_message(),
_ => None,
}
}
}

impl AskpassExitStatusError {
/// Get the extra error message, if any.
///
Expand All @@ -51,54 +74,61 @@ impl AskpassExitStatusError {
}
}

/// Prompt the user for login credentials for a particular URL.
/// 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.
///
/// If a username is already provided, the user is only prompted for a password.
pub(crate) fn prompt_credentials(username: Option<&str>, url: &str, git_config: &git2::Config) -> Result<PlaintextCredentials, Error> {
/// 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 username = match username {
Some(x) => x.into(),
None => askpass_prompt(&askpass, &format!("Username for {url}"))?,
};
let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
Ok(PlaintextCredentials {
username,
password,
})
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 username = match username {
Some(x) => x.into(),
None => terminal.prompt("Username: ").map_err(Error::ReadWriteTerminal)?,
};
let password = terminal.prompt_sensitive("Password: ")
.map_err(Error::ReadWriteTerminal)?;
Ok(PlaintextCredentials {
username,
password,
})
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.
pub(crate) fn prompt_ssh_key_password(private_key_path: &Path, git_config: &git2::Config) -> Result<String, Error> {
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)
terminal.prompt_sensitive("Password: ")
.map_err(Error::ReadWriteTerminal)
}
}

Expand Down Expand Up @@ -139,7 +169,7 @@ impl std::fmt::Display for Error {
match self {
Self::AskpassCommand(e) => write!(f, "Failed to run askpass command: {e}"),
Self::AskpassExitStatus(e) => write!(f, "{e}"),
Self::InvalidUtf8(_) => write!(f, "Password contains invalid UTF-8"),
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}"),
}
Expand Down
Loading

0 comments on commit 54c1841

Please sign in to comment.