git: prompt for credentials when needed

This commit is contained in:
Benjamin Saunders 2022-11-06 10:15:44 -08:00
parent fd0a065801
commit d69eb808df
6 changed files with 149 additions and 13 deletions

View File

@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* It is now possible to specity configuration options on the command line
with he new `--config-toml` global option.
* (#469) `jj git` subcommands will prompt for credentials when
required for HTTPS remotes rather than failing.
### Fixed bugs
* `jj edit root` now fails gracefully.

11
Cargo.lock generated
View File

@ -730,6 +730,7 @@ dependencies = [
"predicates",
"rand",
"regex",
"rpassword",
"serde",
"tempfile",
"test-case",
@ -1340,6 +1341,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316"
[[package]]
name = "rpassword"
version = "7.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c9f5d2a0c3e2ea729ab3706d22217177770654c3ef5056b68b69d07332d3f5"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "ryu"
version = "1.0.11"

View File

@ -51,6 +51,7 @@ pest = "2.4.0"
pest_derive = "2.4"
rand = "0.8.5"
regex = "1.6.0"
rpassword = "7.1.0"
serde = { version = "1.0", features = ["derive"] }
tempfile = "3.3.0"
textwrap = "0.16.0"

View File

@ -483,6 +483,8 @@ fn push_refs(
pub struct RemoteCallbacks<'a> {
pub progress: Option<&'a mut dyn FnMut(&Progress)>,
pub get_ssh_key: Option<&'a mut dyn FnMut(&str) -> Option<PathBuf>>,
pub get_password: Option<&'a mut dyn FnMut(&str, &str) -> Option<String>>,
pub get_username_password: Option<&'a mut dyn FnMut(&str) -> Option<(String, String)>>,
}
impl<'a> RemoteCallbacks<'a> {
@ -504,17 +506,31 @@ impl<'a> RemoteCallbacks<'a> {
}
// TODO: We should expose the callbacks to the caller instead -- the library
// crate shouldn't read environment variables.
callbacks.credentials(move |_url, username_from_url, allowed_types| {
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
if std::env::var("SSH_AUTH_SOCK").is_ok() || std::env::var("SSH_AGENT_PID").is_ok()
{
return git2::Cred::ssh_key_from_agent(username_from_url.unwrap());
callbacks.credentials(move |url, username_from_url, allowed_types| {
if let Some(username) = username_from_url {
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
if std::env::var("SSH_AUTH_SOCK").is_ok()
|| std::env::var("SSH_AGENT_PID").is_ok()
{
return git2::Cred::ssh_key_from_agent(username);
}
if let Some(ref mut cb) = self.get_ssh_key {
if let Some(path) = cb(username) {
return git2::Cred::ssh_key(username, None, &path, None);
}
}
}
if let (&mut Some(ref mut cb), Some(username)) =
(&mut self.get_ssh_key, username_from_url)
{
if let Some(path) = cb(username) {
return git2::Cred::ssh_key(username, None, &path, None);
if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Some(ref mut cb) = self.get_password {
if let Some(pw) = cb(url, username) {
return git2::Cred::userpass_plaintext(username, &pw);
}
}
}
} else if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Some(ref mut cb) = self.get_username_password {
if let Some((username, pw)) = cb(url) {
return git2::Cred::userpass_plaintext(&username, &pw);
}
}
}

View File

@ -18,7 +18,8 @@ use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::{fs, io};
@ -4095,12 +4096,15 @@ fn do_git_clone(
Ok((workspace_command, maybe_default_branch))
}
#[allow(clippy::explicit_auto_deref)] // https://github.com/rust-lang/rust-clippy/issues/9763
fn with_remote_callbacks<T>(ui: &mut Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T {
let mut ui = Mutex::new(ui);
let mut callback = None;
if ui.use_progress_indicator() {
if ui.get_mut().unwrap().use_progress_indicator() {
let mut progress = Progress::new(Instant::now());
let ui = &ui;
callback = Some(move |x: &git::Progress| {
progress.update(Instant::now(), x, ui);
progress.update(Instant::now(), x, *ui.lock().unwrap());
});
}
let mut callbacks = git::RemoteCallbacks::default();
@ -4109,9 +4113,86 @@ fn with_remote_callbacks<T>(ui: &mut Ui, f: impl FnOnce(git::RemoteCallbacks<'_>
.map(|x| x as &mut dyn FnMut(&git::Progress));
let mut get_ssh_key = get_ssh_key; // Coerce to unit fn type
callbacks.get_ssh_key = Some(&mut get_ssh_key);
let mut get_pw = |url: &str, _username: &str| {
pinentry_get_pw(url).or_else(|| terminal_get_pw(*ui.lock().unwrap(), url))
};
callbacks.get_password = Some(&mut get_pw);
let mut get_user_pw = |url: &str| {
let ui = &mut *ui.lock().unwrap();
Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?))
};
callbacks.get_username_password = Some(&mut get_user_pw);
f(callbacks)
}
fn terminal_get_username(ui: &mut Ui, url: &str) -> Option<String> {
ui.prompt(&format!("Username for {}", url)).ok()
}
fn terminal_get_pw(ui: &mut Ui, url: &str) -> Option<String> {
ui.prompt_password(&format!("Passphrase for {}: ", url))
.ok()
}
fn pinentry_get_pw(url: &str) -> Option<String> {
let mut pinentry = Command::new("pinentry")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.ok()?;
#[rustfmt::skip]
pinentry
.stdin
.take()
.unwrap()
.write_all(
format!(
"SETTITLE jj passphrase\n\
SETDESC Enter passphrase for {url}\n\
SETPROMPT Passphrase:\n\
GETPIN\n"
)
.as_bytes(),
)
.ok()?;
let mut out = String::new();
pinentry
.stdout
.take()
.unwrap()
.read_to_string(&mut out)
.ok()?;
_ = pinentry.wait();
for line in out.split('\n') {
if !line.starts_with("D ") {
continue;
}
let (_, encoded) = line.split_at(2);
return decode_assuan_data(encoded);
}
None
}
// https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses
fn decode_assuan_data(encoded: &str) -> Option<String> {
let encoded = encoded.as_bytes();
let mut decoded = Vec::with_capacity(encoded.len());
let mut i = 0;
while i < encoded.len() {
if encoded[i] != b'%' {
decoded.push(encoded[i]);
i += 1;
continue;
}
i += 1;
let byte =
u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?;
decoded.push(byte);
i += 2;
}
String::from_utf8(decoded).ok()
}
fn get_ssh_key(_username: &str) -> Option<PathBuf> {
let home_dir = std::env::var("HOME").ok()?;
let key_path = std::path::Path::new(&home_dir).join(".ssh").join("id_rsa");

View File

@ -207,6 +207,30 @@ impl Ui {
}
}
pub fn prompt(&mut self, prompt: &str) -> io::Result<String> {
if !atty::is(Stream::Stdout) {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Cannot prompt for input since the output is not connected to a terminal",
));
}
write!(self, "{}: ", prompt)?;
self.flush()?;
let mut buf = String::new();
io::stdin().read_line(&mut buf)?;
Ok(buf)
}
pub fn prompt_password(&mut self, prompt: &str) -> io::Result<String> {
if !atty::is(Stream::Stdout) {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Cannot prompt for input since the output is not connected to a terminal",
));
}
rpassword::prompt_password(&format!("{}: ", prompt))
}
pub fn size(&self) -> Option<(u16, u16)> {
crossterm::terminal::size().ok()
}