diff --git a/CHANGELOG.md b/CHANGELOG.md index ec053ea773..578e485fb7 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. +* `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 36f68e1e44..0b0ca33a50 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 d6d8176b7b..1797922137 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 fd286e2253..138cf66884 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -482,6 +482,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> { @@ -503,19 +505,30 @@ impl<'a> RemoteCallbacks<'a> { } // TODO: We should expose the callbacks to the caller instead -- the library // crate shouldn't look in $HOME etc. - callbacks.credentials(move |_url, username_from_url, allowed_types| { + 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()); } - 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 let Some(username) = username_from_url { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + 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 Some(ref mut cb) = self.get_password { + if let Some(pw) = cb(url, username) { + return git2::Cred::userpass_plaintext(username, &pw); + } + } + } else if let Some(ref mut cb) = self.get_username_password { + if let Some((username, pw)) = cb(url) { + return git2::Cred::userpass_plaintext(&username, &pw); + } } git2::Cred::default() }); diff --git a/src/commands.rs b/src/commands.rs index 5a44a27747..e88ae605bd 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}; @@ -4058,9 +4059,10 @@ fn do_git_clone( } 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() { - let mut progress = Progress::new(Instant::now(), ui); + if ui.get_mut().unwrap().use_progress_indicator() { + let mut progress = Progress::new(Instant::now(), &ui); callback = Some(move |x: &git::Progress| { progress.update(Instant::now(), x); }); @@ -4071,9 +4073,85 @@ 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(&mut *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!("Password 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/progress.rs b/src/progress.rs index c1609f3373..43540d7f9f 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -1,3 +1,4 @@ +use std::sync::Mutex; use std::time::{Duration, Instant}; use crossterm::terminal::{Clear, ClearType}; @@ -6,14 +7,14 @@ use jujutsu_lib::git; use crate::ui::Ui; pub struct Progress<'a> { - ui: &'a mut Ui, + ui: &'a Mutex<&'a mut Ui>, next_print: Instant, rate: RateEstimate, buffer: String, } impl<'a> Progress<'a> { - pub fn new(now: Instant, ui: &'a mut Ui) -> Self { + pub fn new(now: Instant, ui: &'a Mutex<&'a mut Ui>) -> Self { Self { ui, next_print: now + INITIAL_DELAY, @@ -43,8 +44,8 @@ impl<'a> Progress<'a> { write!(self.buffer, " at {: >5.1} {}B/s ", scaled, prefix).unwrap(); } - let bar_width = self - .ui + let ui = &mut *self.ui.lock().unwrap(); + let bar_width = ui .size() .map(|(cols, _rows)| usize::from(cols)) .unwrap_or(0) @@ -53,14 +54,18 @@ impl<'a> Progress<'a> { draw_progress(progress.overall, &mut self.buffer, bar_width); self.buffer.push(']'); - _ = write!(self.ui, "{}", self.buffer); - _ = self.ui.flush(); + _ = write!(ui, "{}", self.buffer); + _ = ui.flush(); } } impl Drop for Progress<'_> { fn drop(&mut self) { - _ = write!(self.ui, "\r{}", Clear(ClearType::CurrentLine)); + _ = write!( + self.ui.lock().unwrap(), + "\r{}", + Clear(ClearType::CurrentLine) + ); } } diff --git a/src/ui.rs b/src/ui.rs index 7319eb0160..aa12e487e9 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, + "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, + "not connected to a terminal", + )); + } + rpassword::prompt_password(&format!("{}: ", prompt)) + } + pub fn size(&self) -> Option<(u16, u16)> { crossterm::terminal::size().ok() } @@ -215,3 +239,17 @@ impl Ui { enum UiOutputPair { Terminal { stdout: Stdout, stderr: Stderr }, } + +impl io::Write for Ui { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self.output_pair { + UiOutputPair::Terminal { ref mut stdout, .. } => stdout.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self.output_pair { + UiOutputPair::Terminal { ref mut stdout, .. } => stdout.flush(), + } + } +}