From d69eb808dfcc4b80f216acfec4414aa654aa0bb7 Mon Sep 17 00:00:00 2001 From: Benjamin Saunders Date: Sun, 6 Nov 2022 10:15:44 -0800 Subject: [PATCH] git: prompt for credentials when needed --- CHANGELOG.md | 3 ++ Cargo.lock | 11 +++++++ Cargo.toml | 1 + lib/src/git.rs | 36 ++++++++++++++------ src/commands.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 24 ++++++++++++++ 6 files changed, 149 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec053ea77..750c68f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 36f68e1e4..0b0ca33a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d6d8176b7..179792213 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/lib/src/git.rs b/lib/src/git.rs index 46d446724..aa2347014 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -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>, + pub get_password: Option<&'a mut dyn FnMut(&str, &str) -> Option>, + 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); } } } diff --git a/src/commands.rs b/src/commands.rs index 3491da621..1409195e7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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(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(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 { + ui.prompt(&format!("Username for {}", url)).ok() +} + +fn terminal_get_pw(ui: &mut Ui, url: &str) -> Option { + ui.prompt_password(&format!("Passphrase for {}: ", url)) + .ok() +} + +fn pinentry_get_pw(url: &str) -> Option { + 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 { + 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 { let home_dir = std::env::var("HOME").ok()?; let key_path = std::path::Path::new(&home_dir).join(".ssh").join("id_rsa"); diff --git a/src/ui.rs b/src/ui.rs index 7319eb016..0562a3562 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -207,6 +207,30 @@ impl Ui { } } + pub fn prompt(&mut self, prompt: &str) -> io::Result { + 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 { + 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() }