diff --git a/Cargo.lock b/Cargo.lock index f2dfee766d..dbdd3526fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,8 +67,10 @@ dependencies = [ "log", "rayon-core", "scopetime", + "serial_test", "tempfile", "thiserror", + "url", ] [[package]] @@ -1087,6 +1089,28 @@ dependencies = [ "syn", ] +[[package]] +name = "serial_test" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b15f74add9a9d4a3eb2bf739c9a427d266d3895b53d992c3a7c234fec2ff1f1" +dependencies = [ + "lazy_static", + "parking_lot 0.10.2", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65f59259be9fc1bf677d06cc1456e97756004a1a5a577480f71430bd7c17ba33" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "signal-hook" version = "0.1.16" diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 5c3a2fe8cd..3f2c40d453 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -18,7 +18,9 @@ rayon-core = "1.8" crossbeam-channel = "0.5" log = "0.4" thiserror = "1.0" +url = "2.1.1" [dev-dependencies] tempfile = "3.1" -invalidstring = { path = "../invalidstring", version = "0.1" } \ No newline at end of file +invalidstring = { path = "../invalidstring", version = "0.1" } +serial_test = "0.5.0" \ No newline at end of file diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index b8c44d3e19..1b7c8adc75 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -9,6 +9,9 @@ pub enum Error { #[error("git: no head found")] NoHead, + #[error("git: remote url not found")] + UnknownRemote, + #[error("io error:{0}")] Io(#[from] std::io::Error), diff --git a/asyncgit/src/push.rs b/asyncgit/src/push.rs index 2533dcc2fe..de0f8b0a48 100644 --- a/asyncgit/src/push.rs +++ b/asyncgit/src/push.rs @@ -1,3 +1,4 @@ +use crate::sync::cred::BasicAuthCredential; use crate::{ error::{Error, Result}, sync, AsyncNotification, CWD, @@ -88,6 +89,8 @@ pub struct PushRequest { pub remote: String, /// pub branch: String, + /// + pub basic_credential: Option, } #[derive(Default, Clone, Debug)] @@ -161,6 +164,7 @@ impl AsyncPush { CWD, params.remote.as_str(), params.branch.as_str(), + params.basic_credential, progress_sender.clone(), ); diff --git a/asyncgit/src/sync/cred.rs b/asyncgit/src/sync/cred.rs new file mode 100644 index 0000000000..fbd2304451 --- /dev/null +++ b/asyncgit/src/sync/cred.rs @@ -0,0 +1,257 @@ +//! credentials git helper + +use git2::{Config, CredentialHelper}; + +use crate::error::{Error, Result}; +use crate::CWD; + +/// basic Authentication Credentials +#[derive(Debug, Clone, Default, PartialEq)] +pub struct BasicAuthCredential { + /// + pub username: Option, + /// + pub password: Option, +} + +impl BasicAuthCredential { + /// + pub fn is_complete(&self) -> bool { + self.username.is_some() && self.password.is_some() + } + /// + pub fn new( + username: Option, + password: Option, + ) -> Self { + BasicAuthCredential { username, password } + } +} + +/// know if username and password are needed for this url +pub fn need_username_password(remote: &str) -> Result { + let repo = crate::sync::utils::repo(CWD)?; + let url = repo + .find_remote(remote)? + .url() + .ok_or(Error::UnknownRemote)? + .to_owned(); + let is_http = url.starts_with("http"); + Ok(is_http) +} + +/// extract username and password +pub fn extract_username_password( + remote: &str, +) -> Result { + let repo = crate::sync::utils::repo(CWD)?; + let url = repo + .find_remote(remote)? + .url() + .ok_or(Error::UnknownRemote)? + .to_owned(); + let mut helper = CredentialHelper::new(&url); + + if let Ok(config) = Config::open_default() { + helper.config(&config); + } + Ok(match helper.execute() { + Some((username, password)) => { + BasicAuthCredential::new(Some(username), Some(password)) + } + None => extract_cred_from_url(&url), + }) +} + +/// extract credentials from url +pub fn extract_cred_from_url(url: &str) -> BasicAuthCredential { + if let Ok(url) = url::Url::parse(url) { + BasicAuthCredential::new( + if url.username() == "" { + None + } else { + Some(url.username().to_owned()) + }, + url.password().map(|pwd| pwd.to_owned()), + ) + } else { + BasicAuthCredential::new(None, None) + } +} + +#[cfg(test)] +mod tests { + use crate::sync::cred::{ + extract_cred_from_url, extract_username_password, + need_username_password, BasicAuthCredential, + }; + use crate::sync::tests::repo_init; + use crate::sync::DEFAULT_REMOTE_NAME; + use serial_test::serial; + use std::env; + + #[test] + fn test_credential_complete() { + assert_eq!( + BasicAuthCredential::new( + Some("username".to_owned()), + Some("password".to_owned()) + ) + .is_complete(), + true + ); + } + + #[test] + fn test_credential_not_complete() { + assert_eq!( + BasicAuthCredential::new( + None, + Some("password".to_owned()) + ) + .is_complete(), + false + ); + assert_eq!( + BasicAuthCredential::new( + Some("username".to_owned()), + None + ) + .is_complete(), + false + ); + assert_eq!( + BasicAuthCredential::new(None, None).is_complete(), + false + ); + } + + #[test] + fn test_extract_username_from_url() { + assert_eq!( + extract_cred_from_url("https://user@github.com"), + BasicAuthCredential::new(Some("user".to_owned()), None) + ); + } + + #[test] + fn test_extract_username_password_from_url() { + assert_eq!( + extract_cred_from_url("https://user:pwd@github.com"), + BasicAuthCredential::new( + Some("user".to_owned()), + Some("pwd".to_owned()) + ) + ); + } + + #[test] + fn test_extract_nothing_from_url() { + assert_eq!( + extract_cred_from_url("https://github.com"), + BasicAuthCredential::new(None, None) + ); + } + + #[test] + #[serial] + fn test_need_username_password_if_https() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + repo.remote(DEFAULT_REMOTE_NAME, "http://user@github.com") + .unwrap(); + + assert_eq!( + need_username_password(DEFAULT_REMOTE_NAME).unwrap(), + true + ); + } + + #[test] + #[serial] + fn test_dont_need_username_password_if_ssh() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + repo.remote(DEFAULT_REMOTE_NAME, "git@github.com:user/repo") + .unwrap(); + + assert_eq!( + need_username_password(DEFAULT_REMOTE_NAME).unwrap(), + false + ); + } + + #[test] + #[serial] + #[should_panic] + fn test_error_if_no_remote_when_trying_to_retrieve_if_need_username_password( + ) { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + + need_username_password(DEFAULT_REMOTE_NAME).unwrap(); + } + + #[test] + #[serial] + fn test_extract_username_password_from_repo() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + repo.remote( + DEFAULT_REMOTE_NAME, + "http://user:pass@github.com", + ) + .unwrap(); + + assert_eq!( + extract_username_password(DEFAULT_REMOTE_NAME).unwrap(), + BasicAuthCredential::new( + Some("user".to_owned()), + Some("pass".to_owned()) + ) + ); + } + + #[test] + #[serial] + fn test_extract_username_from_repo() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + repo.remote(DEFAULT_REMOTE_NAME, "http://user@github.com") + .unwrap(); + + assert_eq!( + extract_username_password(DEFAULT_REMOTE_NAME).unwrap(), + BasicAuthCredential::new(Some("user".to_owned()), None) + ); + } + + #[test] + #[serial] + #[should_panic] + fn test_error_if_no_remote_when_trying_to_extract_username_password( + ) { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + env::set_current_dir(repo_path).unwrap(); + + extract_username_password(DEFAULT_REMOTE_NAME).unwrap(); + } +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 22ab4cf98b..0ace7b0d49 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -5,6 +5,7 @@ mod commit; mod commit_details; mod commit_files; mod commits_info; +pub mod cred; pub mod diff; mod hooks; mod hunks; @@ -35,6 +36,7 @@ pub use ignore::add_to_ignore; pub use logwalker::LogWalker; pub use remotes::{ fetch_origin, get_remotes, push, ProgressNotification, + DEFAULT_REMOTE_NAME, }; pub use reset::{reset_stage, reset_workdir}; pub use stash::{get_stashes, stash_apply, stash_drop, stash_save}; diff --git a/asyncgit/src/sync/remotes.rs b/asyncgit/src/sync/remotes.rs index c4e2d2042e..ff93cce37b 100644 --- a/asyncgit/src/sync/remotes.rs +++ b/asyncgit/src/sync/remotes.rs @@ -1,13 +1,16 @@ //! use super::CommitId; -use crate::{error::Result, sync::utils}; +use crate::{ + error::Result, sync::cred::BasicAuthCredential, sync::utils, +}; use crossbeam_channel::Sender; use git2::{ Cred, Error as GitError, FetchOptions, PackBuilderStage, PushOptions, RemoteCallbacks, }; use scopetime::scope_time; + /// #[derive(Debug, Clone)] pub enum ProgressNotification { @@ -49,6 +52,9 @@ pub enum ProgressNotification { Done, } +/// +pub const DEFAULT_REMOTE_NAME: &str = "origin"; + /// pub fn get_remotes(repo_path: &str) -> Result> { scope_time!("get_remotes"); @@ -66,10 +72,10 @@ pub fn fetch_origin(repo_path: &str, branch: &str) -> Result { scope_time!("fetch_origin"); let repo = utils::repo(repo_path)?; - let mut remote = repo.find_remote("origin")?; + let mut remote = repo.find_remote(DEFAULT_REMOTE_NAME)?; let mut options = FetchOptions::new(); - options.remote_callbacks(match remote_callbacks(None) { + options.remote_callbacks(match remote_callbacks(None, None) { Ok(callback) => callback, Err(e) => return Err(e), }); @@ -84,6 +90,7 @@ pub fn push( repo_path: &str, remote: &str, branch: &str, + basic_credential: Option, progress_sender: Sender, ) -> Result<()> { scope_time!("push_origin"); @@ -94,7 +101,10 @@ pub fn push( let mut options = PushOptions::new(); options.remote_callbacks( - match remote_callbacks(Some(progress_sender)) { + match remote_callbacks( + Some(progress_sender), + basic_credential, + ) { Ok(callbacks) => callbacks, Err(e) => return Err(e), }, @@ -108,6 +118,7 @@ pub fn push( fn remote_callbacks<'a>( sender: Option>, + basic_credential: Option, ) -> Result> { let mut callbacks = RemoteCallbacks::new(); let sender_clone = sender.clone(); @@ -165,21 +176,57 @@ fn remote_callbacks<'a>( }) }); }); - callbacks.credentials(|url, username_from_url, allowed_types| { - log::debug!( - "creds: '{}' {:?} ({:?})", - url, - username_from_url, - allowed_types - ); - match username_from_url { - Some(username) => Cred::ssh_key_from_agent(username), - None => Err(GitError::from_str( - " Couldn't extract username from url.", - )), - } - }); + let mut first_call_to_credentials = true; + // This boolean is used to avoid multiple call to credentials callback. + // If credentials are bad, we don't ask the user to re-fill his creds. We push an error and he will be able to restart his action (for example a push) and retype his creds. + // This behavior is explained in a issue on git2-rs project : https://github.com/rust-lang/git2-rs/issues/347 + // An implementation reference is done in cargo : https://github.com/rust-lang/cargo/blob/9fb208dddb12a3081230a5fd8f470e01df8faa25/src/cargo/sources/git/utils.rs#L588 + // There is also a guide about libgit2 authentication : https://libgit2.org/docs/guides/authentication/ + callbacks.credentials( + move |url, username_from_url, allowed_types| { + log::debug!( + "creds: '{}' {:?} ({:?})", + url, + username_from_url, + allowed_types + ); + if first_call_to_credentials { + first_call_to_credentials = false; + } else { + return Err(GitError::from_str("Bad credentials.")); + } + + match &basic_credential { + _ if allowed_types.is_ssh_key() => { + match username_from_url { + Some(username) => { + Cred::ssh_key_from_agent(username) + } + None => Err(GitError::from_str( + " Couldn't extract username from url.", + )), + } + } + Some(BasicAuthCredential { + username: Some(user), + password: Some(pwd), + }) if allowed_types.is_user_pass_plaintext() => { + Cred::userpass_plaintext(&user, &pwd) + } + Some(BasicAuthCredential { + username: Some(user), + password: _, + }) if allowed_types.is_username() => { + Cred::username(user) + } + _ if allowed_types.is_default() => Cred::default(), + _ => Err(GitError::from_str( + "Couldn't find credentials", + )), + } + }, + ); Ok(callbacks) } @@ -204,7 +251,7 @@ mod tests { let remotes = get_remotes(repo_path).unwrap(); - assert_eq!(remotes, vec![String::from("origin")]); + assert_eq!(remotes, vec![String::from(DEFAULT_REMOTE_NAME)]); fetch_origin(repo_path, "master").unwrap(); } diff --git a/src/components/cred.rs b/src/components/cred.rs new file mode 100644 index 0000000000..916f50ba33 --- /dev/null +++ b/src/components/cred.rs @@ -0,0 +1,162 @@ +use anyhow::Result; +use crossterm::event::Event; +use tui::{backend::Backend, layout::Rect, Frame}; + +use asyncgit::sync::cred::BasicAuthCredential; + +use crate::components::TextInputComponent; +use crate::{ + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, + }, + keys::SharedKeyConfig, + strings, + ui::style::SharedTheme, +}; + +/// +pub struct CredComponent { + visible: bool, + key_config: SharedKeyConfig, + input_username: TextInputComponent, + input_password: TextInputComponent, + cred: BasicAuthCredential, +} + +impl CredComponent { + /// + pub fn new( + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + Self { + visible: false, + input_username: TextInputComponent::new( + theme.clone(), + key_config.clone(), + &strings::username_popup_title(&key_config), + &strings::username_popup_msg(&key_config), + ), + input_password: TextInputComponent::new( + theme, + key_config.clone(), + &strings::password_popup_title(&key_config), + &strings::password_popup_msg(&key_config), + ), + key_config, + cred: BasicAuthCredential::new(None, None), + } + } + + pub fn set_cred(&mut self, cred: BasicAuthCredential) { + self.cred = cred; + } + + pub const fn get_cred(&self) -> &BasicAuthCredential { + &self.cred + } +} + +impl DrawableComponent for CredComponent { + fn draw( + &self, + f: &mut Frame, + rect: Rect, + ) -> Result<()> { + if self.visible { + self.input_username.draw(f, rect)?; + self.input_password.draw(f, rect)?; + } + Ok(()) + } +} + +impl Component for CredComponent { + fn commands( + &self, + out: &mut Vec, + _force_all: bool, + ) -> CommandBlocking { + if self.is_visible() { + out.clear(); + } + + out.push(CommandInfo::new( + strings::commands::validate_msg(&self.key_config), + true, + self.visible, + )); + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + self.visible, + )); + + visibility_blocking(self) + } + + fn event(&mut self, ev: Event) -> Result { + if self.visible { + if let Event::Key(e) = ev { + if e == self.key_config.exit_popup { + self.hide(); + } + if self.input_username.event(ev)? + || self.input_password.event(ev)? + { + return Ok(true); + } else if e == self.key_config.enter { + if self.input_username.is_visible() { + self.cred = BasicAuthCredential::new( + Some( + self.input_username + .get_text() + .to_owned(), + ), + None, + ); + self.input_username.hide(); + self.input_password.show()?; + } else if self.input_password.is_visible() { + self.cred = BasicAuthCredential::new( + self.cred.username.clone(), + Some( + self.input_password + .get_text() + .to_owned(), + ), + ); + self.input_password.hide(); + self.input_password.clear(); + return Ok(false); + } else { + self.hide(); + } + } + } + return Ok(true); + } + Ok(false) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.cred = BasicAuthCredential::new(None, None); + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + if self.cred.username.is_none() { + self.input_username.show() + } else if self.cred.password.is_none() { + self.input_password.show() + } else { + Ok(()) + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 3c6695d713..1fbd41f5c8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -4,6 +4,7 @@ mod commit; mod commit_details; mod commitlist; mod create_branch; +mod cred; mod diff; mod externaleditor; mod filetree; diff --git a/src/components/push.rs b/src/components/push.rs index a33a28ff39..d1431c2a34 100644 --- a/src/components/push.rs +++ b/src/components/push.rs @@ -1,7 +1,7 @@ use crate::{ components::{ - visibility_blocking, CommandBlocking, CommandInfo, Component, - DrawableComponent, + cred::CredComponent, visibility_blocking, CommandBlocking, + CommandInfo, Component, DrawableComponent, }, keys::SharedKeyConfig, queue::{InternalEvent, Queue}, @@ -10,6 +10,11 @@ use crate::{ }; use anyhow::Result; use asyncgit::{ + sync::cred::{ + extract_username_password, need_username_password, + BasicAuthCredential, + }, + sync::DEFAULT_REMOTE_NAME, AsyncNotification, AsyncPush, PushProgress, PushProgressState, PushRequest, }; @@ -30,9 +35,11 @@ pub struct PushComponent { git_push: AsyncPush, progress: Option, pending: bool, + branch: String, queue: Queue, theme: SharedTheme, key_config: SharedKeyConfig, + input_cred: CredComponent, } impl PushComponent { @@ -47,8 +54,13 @@ impl PushComponent { queue: queue.clone(), pending: false, visible: false, + branch: "".to_string(), git_push: AsyncPush::new(sender), progress: None, + input_cred: CredComponent::new( + theme.clone(), + key_config.clone(), + ), theme, key_config, } @@ -56,14 +68,36 @@ impl PushComponent { /// pub fn push(&mut self, branch: String) -> Result<()> { + self.branch = branch; + self.show()?; + if need_username_password(DEFAULT_REMOTE_NAME)? { + let cred = extract_username_password(DEFAULT_REMOTE_NAME) + .unwrap_or_else(|_| { + BasicAuthCredential::new(None, None) + }); + if cred.is_complete() { + self.push_to_remote(Some(cred)) + } else { + self.input_cred.set_cred(cred); + self.input_cred.show() + } + } else { + self.push_to_remote(None) + } + } + + fn push_to_remote( + &mut self, + cred: Option, + ) -> Result<()> { self.pending = true; self.progress = None; self.git_push.request(PushRequest { //TODO: find tracking branch name - remote: String::from("origin"), - branch, + remote: String::from(DEFAULT_REMOTE_NAME), + branch: self.branch.clone(), + basic_credential: cred, })?; - self.show()?; Ok(()) } @@ -95,7 +129,6 @@ impl PushComponent { )), ); } - self.hide(); } @@ -134,7 +167,7 @@ impl DrawableComponent for PushComponent { fn draw( &self, f: &mut Frame, - _rect: Rect, + rect: Rect, ) -> Result<()> { if self.visible { let (state, progress) = self.get_progress(); @@ -163,6 +196,7 @@ impl DrawableComponent for PushComponent { .percent(u16::from(progress)), area, ); + self.input_cred.draw(f, rect)?; } Ok(()) @@ -173,27 +207,44 @@ impl Component for PushComponent { fn commands( &self, out: &mut Vec, - _force_all: bool, + force_all: bool, ) -> CommandBlocking { if self.is_visible() { out.clear(); } - out.push(CommandInfo::new( - strings::commands::close_msg(&self.key_config), - !self.pending, - self.visible, - )); - - visibility_blocking(self) + if self.input_cred.is_visible() { + self.input_cred.commands(out, force_all) + } else { + out.push(CommandInfo::new( + strings::commands::close_msg(&self.key_config), + !self.pending, + self.visible, + )); + visibility_blocking(self) + } } fn event(&mut self, ev: Event) -> Result { if self.visible { if let Event::Key(e) = ev { - if e == self.key_config.enter { + if e == self.key_config.exit_popup { self.hide(); } + if self.input_cred.event(ev)? { + return Ok(true); + } else if e == self.key_config.enter { + if self.input_cred.is_visible() + && self.input_cred.get_cred().is_complete() + { + self.push_to_remote(Some( + self.input_cred.get_cred().clone(), + ))?; + self.input_cred.hide(); + } else { + self.hide(); + } + } } return Ok(true); } diff --git a/src/strings.rs b/src/strings.rs index 8b98c5d551..4c9ea63696 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -139,6 +139,18 @@ pub fn create_branch_popup_msg( ) -> String { "type branch name".to_string() } +pub fn username_popup_title(_key_config: &SharedKeyConfig) -> String { + "Username".to_string() +} +pub fn username_popup_msg(_key_config: &SharedKeyConfig) -> String { + "type username".to_string() +} +pub fn password_popup_title(_key_config: &SharedKeyConfig) -> String { + "Password".to_string() +} +pub fn password_popup_msg(_key_config: &SharedKeyConfig) -> String { + "type password".to_string() +} pub fn rename_branch_popup_title( _key_config: &SharedKeyConfig, @@ -334,6 +346,14 @@ pub mod commands { ) .hide_help() } + pub fn validate_msg(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!("Validate [{}]", get_hint(key_config.enter),), + "validate msg", + CMD_GROUP_GENERAL, + ) + .hide_help() + } pub fn select_staging( key_config: &SharedKeyConfig, ) -> CommandText {