feat: support https push (#353)

This commit is contained in:
Arnaud 2020-10-25 10:50:20 +01:00 committed by GitHub
parent acccbfa08a
commit 9439114e5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 609 additions and 36 deletions

24
Cargo.lock generated
View file

@ -67,8 +67,10 @@ dependencies = [
"log",
"rayon-core",
"scopetime",
"serial_test",
"tempfile",
"thiserror",
"url",
]
[[package]]
@ -1070,6 +1072,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"

View file

@ -18,7 +18,9 @@ rayon-core = "1.9"
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" }
invalidstring = { path = "../invalidstring", version = "0.1" }
serial_test = "0.5.0"

View file

@ -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),

View file

@ -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<BasicAuthCredential>,
}
#[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(),
);

257
asyncgit/src/sync/cred.rs Normal file
View file

@ -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<String>,
///
pub password: Option<String>,
}
impl BasicAuthCredential {
///
pub fn is_complete(&self) -> bool {
self.username.is_some() && self.password.is_some()
}
///
pub fn new(
username: Option<String>,
password: Option<String>,
) -> Self {
BasicAuthCredential { username, password }
}
}
/// know if username and password are needed for this url
pub fn need_username_password(remote: &str) -> Result<bool> {
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<BasicAuthCredential> {
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();
}
}

View file

@ -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};

View file

@ -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<Vec<String>> {
scope_time!("get_remotes");
@ -66,10 +72,10 @@ pub fn fetch_origin(repo_path: &str, branch: &str) -> Result<usize> {
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<BasicAuthCredential>,
progress_sender: Sender<ProgressNotification>,
) -> 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<Sender<ProgressNotification>>,
basic_credential: Option<BasicAuthCredential>,
) -> Result<RemoteCallbacks<'a>> {
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();
}

162
src/components/cred.rs Normal file
View file

@ -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<B: Backend>(
&self,
f: &mut Frame<B>,
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<CommandInfo>,
_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<bool> {
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(())
}
}
}

View file

@ -4,6 +4,7 @@ mod commit;
mod commit_details;
mod commitlist;
mod create_branch;
mod cred;
mod diff;
mod externaleditor;
mod filetree;

View file

@ -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<PushProgress>,
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<BasicAuthCredential>,
) -> 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<B: Backend>(
&self,
f: &mut Frame<B>,
_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<CommandInfo>,
_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<bool> {
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);
}

View file

@ -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 {