Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* support rewording non-HEAD commits when `commit.gpgsign` is enabled (gpg format only) [[@guerinoni](https://github.com/guerinoni)] ([#2959](https://github.com/gitui-org/gitui/pull/2959))

### Fixes
* fall back to conventional OpenSSH private keys when no usable SSH-agent credential is available ([#495](https://github.com/gitui-org/gitui/issues/495))
* crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895))
* when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748))
* index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953))
Expand Down
48 changes: 46 additions & 2 deletions asyncgit/src/sync/cred.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ use super::{
};
use crate::error::{Error, Result};
use git2::CredentialHelper;
use std::path::{Path, PathBuf};

const DEFAULT_SSH_PRIVATE_KEY_NAMES: [&str; 3] =
["id_ed25519", "id_ecdsa", "id_rsa"];

/// basic Authentication Credentials
#[derive(Debug, Clone, Default, PartialEq, Eq)]
Expand All @@ -35,6 +39,22 @@ impl BasicAuthCredential {
}
}

/// Existing private keys from the conventional OpenSSH locations.
pub(crate) fn default_ssh_private_keys(home: &Path) -> Vec<PathBuf> {
let ssh_dir = home.join(".ssh");
DEFAULT_SSH_PRIVATE_KEY_NAMES
.into_iter()
.map(|name| ssh_dir.join(name))
.filter(|path| path.is_file())
.collect()
}

/// Existing private keys from the current user's home directory.
pub(crate) fn default_ssh_private_keys_from_home() -> Vec<PathBuf> {
dirs::home_dir()
.map_or_else(Vec::new, |home| default_ssh_private_keys(&home))
}

/// know if username and password are needed for this url
pub fn need_username_password(repo_path: &RepoPath) -> Result<bool> {
let repo = repo(repo_path)?;
Expand Down Expand Up @@ -196,14 +216,16 @@ pub fn extract_cred_from_url(url: &str) -> BasicAuthCredential {
mod tests {
use crate::sync::{
cred::{
extract_cred_from_url, extract_username_password,
need_username_password, BasicAuthCredential,
default_ssh_private_keys, extract_cred_from_url,
extract_username_password, need_username_password,
BasicAuthCredential,
},
remotes::DEFAULT_REMOTE_NAME,
tests::repo_init,
RepoPath,
};
use serial_test::serial;
use std::fs::File;

#[test]
fn test_credential_complete() {
Expand All @@ -229,6 +251,28 @@ mod tests {
assert!(!BasicAuthCredential::new(None, None).is_complete());
}

#[test]
fn test_default_ssh_private_keys_returns_existing_keys_in_preference_order(
) {
let home = tempfile::tempdir().unwrap();
let ssh_dir = home.path().join(".ssh");
std::fs::create_dir(&ssh_dir).unwrap();
File::create(ssh_dir.join("id_rsa")).unwrap();
File::create(ssh_dir.join("id_ecdsa")).unwrap();
File::create(ssh_dir.join("id_ed25519")).unwrap();
File::create(ssh_dir.join("id_ed25519.pub")).unwrap();
File::create(ssh_dir.join("custom_key")).unwrap();

assert_eq!(
default_ssh_private_keys(home.path()),
vec![
ssh_dir.join("id_ed25519"),
ssh_dir.join("id_ecdsa"),
ssh_dir.join("id_rsa"),
]
);
}

#[test]
fn test_extract_username_from_url() {
assert_eq!(
Expand Down
258 changes: 229 additions & 29 deletions asyncgit/src/sync/remotes/callbacks.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
use super::push::ProgressNotification;
use crate::{error::Result, sync::cred::BasicAuthCredential};
use crate::{
error::Result,
sync::cred::{
default_ssh_private_keys_from_home, BasicAuthCredential,
},
};
use crossbeam_channel::Sender;
use git2::{Cred, Error as GitError, RemoteCallbacks};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
use std::{
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
},
};

#[derive(Default)]
struct CredentialAttempts {
ssh_agent: AtomicBool,
ssh_private_key_index: AtomicUsize,
username: AtomicBool,
user_pass_plaintext: AtomicBool,
default: AtomicBool,
}

enum SshCredentialSource<'a> {
Agent,
PrivateKey(&'a Path),
}

fn existing_ssh_public_key(private_key: &Path) -> Option<PathBuf> {
let public_key = private_key.with_extension("pub");
public_key.is_file().then_some(public_key)
}

///
#[derive(Default, Clone)]
pub struct CallbackStats {
Expand All @@ -19,27 +46,61 @@ pub struct Callbacks {
sender: Option<Sender<ProgressNotification>>,
basic_credential: Option<BasicAuthCredential>,
stats: Arc<Mutex<CallbackStats>>,
first_call_to_credentials: Arc<AtomicBool>,
credential_attempts: Arc<CredentialAttempts>,
ssh_private_keys: Arc<Vec<PathBuf>>,
}

impl Callbacks {
///
pub fn new(
sender: Option<Sender<ProgressNotification>>,
basic_credential: Option<BasicAuthCredential>,
) -> Self {
Self::new_with_ssh_private_keys(
sender,
basic_credential,
default_ssh_private_keys_from_home(),
)
}

fn new_with_ssh_private_keys(
sender: Option<Sender<ProgressNotification>>,
basic_credential: Option<BasicAuthCredential>,
ssh_private_keys: Vec<PathBuf>,
) -> Self {
let stats = Arc::new(Mutex::new(CallbackStats::default()));

Self {
sender,
basic_credential,
stats,
first_call_to_credentials: Arc::new(AtomicBool::new(
true,
)),
credential_attempts: Arc::new(
CredentialAttempts::default(),
),
ssh_private_keys: Arc::new(ssh_private_keys),
}
}

fn next_ssh_credential_source(
&self,
) -> Option<SshCredentialSource<'_>> {
if !self
.credential_attempts
.ssh_agent
.swap(true, Ordering::Relaxed)
{
return Some(SshCredentialSource::Agent);
}

let index = self
.credential_attempts
.ssh_private_key_index
.fetch_add(1, Ordering::Relaxed);
self.ssh_private_keys.get(index).map(|path| {
SshCredentialSource::PrivateKey(path.as_path())
})
}

///
pub fn get_stats(&self) -> Result<CallbackStats> {
let stats = self.stats.lock()?;
Expand Down Expand Up @@ -186,36 +247,175 @@ impl Callbacks {
"creds: '{url}' {username_from_url:?} ({allowed_types:?})",
);

// This boolean is used to avoid multiple calls to credentials callback.
if self.first_call_to_credentials.load(Ordering::Relaxed) {
self.first_call_to_credentials
.store(false, Ordering::Relaxed);
} else {
return Err(GitError::from_str("Bad credentials."));
}

match &self.basic_credential {
_ if allowed_types.is_ssh_key() => username_from_url
.map_or_else(
|| {
Err(GitError::from_str(
" Couldn't extract username from url.",
))
},
Cred::ssh_key_from_agent,
),
_ if allowed_types.is_ssh_key() => {
let username = username_from_url
.or_else(|| {
self.basic_credential.as_ref().and_then(
|credential| {
credential.username.as_deref()
},
)
})
.unwrap_or("git");

match self.next_ssh_credential_source() {
Some(SshCredentialSource::Agent) => {
Cred::ssh_key_from_agent(username)
}
Some(SshCredentialSource::PrivateKey(path)) => {
let public_key =
existing_ssh_public_key(path);
Cred::ssh_key(
username,
public_key.as_deref(),
path,
None,
)
}
None => Err(GitError::from_str(
"No SSH credentials left to try.",
)),
}
}
Some(BasicAuthCredential {
username: Some(user),
password: Some(pwd),
}) if allowed_types.is_user_pass_plaintext() => {
}) if allowed_types.is_user_pass_plaintext()
&& !self
.credential_attempts
.user_pass_plaintext
.swap(true, Ordering::Relaxed) =>
{
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")),
}) if allowed_types.is_username()
&& !self
.credential_attempts
.username
.swap(true, Ordering::Relaxed) =>
{
Cred::username(user)
}
_ if allowed_types.is_username()
&& !self
.credential_attempts
.username
.swap(true, Ordering::Relaxed) =>
{
Cred::username("git")
}
_ if allowed_types.is_default()
&& !self
.credential_attempts
.default
.swap(true, Ordering::Relaxed) =>
{
Cred::default()
}
_ => Err(GitError::from_str(
"No supported credentials left to try.",
)),
}
}
}

#[cfg(test)]
mod tests {
use super::{
existing_ssh_public_key, Callbacks, SshCredentialSource,
};
use crate::sync::cred::BasicAuthCredential;
use git2::CredentialType;
use std::{error::Error, fs, path::PathBuf};
use tempfile::tempdir;

#[test]
fn ssh_private_key_uses_matching_public_key_when_available(
) -> Result<(), Box<dyn Error>> {
let directory = tempdir()?;
let private_key = directory.path().join("id_ed25519");
let public_key = private_key.with_extension("pub");
fs::write(&private_key, b"private")?;
fs::write(&public_key, b"public")?;

assert_eq!(
existing_ssh_public_key(&private_key),
Some(public_key)
);
Ok(())
}

#[test]
fn ssh_private_key_allows_missing_public_key(
) -> Result<(), Box<dyn Error>> {
let directory = tempdir()?;
let private_key = directory.path().join("id_ed25519");
fs::write(&private_key, b"private")?;

assert_eq!(existing_ssh_public_key(&private_key), None);
Ok(())
}

#[test]
fn ssh_credentials_try_agent_then_each_private_key_once() {
let first = PathBuf::from("first_key");
let second = PathBuf::from("second_key");
let callbacks = Callbacks::new_with_ssh_private_keys(
None,
None,
vec![first.clone(), second.clone()],
);

assert!(matches!(
callbacks.next_ssh_credential_source(),
Some(SshCredentialSource::Agent)
));
assert!(matches!(
callbacks.next_ssh_credential_source(),
Some(SshCredentialSource::PrivateKey(path)) if path == first
));
assert!(matches!(
callbacks.next_ssh_credential_source(),
Some(SshCredentialSource::PrivateKey(path)) if path == second
));
assert!(callbacks.next_ssh_credential_source().is_none());
}

#[test]
fn username_and_password_are_separate_credential_attempts() {
let callbacks = Callbacks::new_with_ssh_private_keys(
None,
Some(BasicAuthCredential::new(
Some("user".to_owned()),
Some("password".to_owned()),
)),
Vec::new(),
);

assert!(callbacks
.credentials(
"https://example.com",
None,
CredentialType::USERNAME,
)
.is_ok());
assert!(callbacks
.credentials(
"https://example.com",
Some("user"),
CredentialType::USER_PASS_PLAINTEXT,
)
.is_ok());
assert!(callbacks
.credentials(
"https://example.com",
Some("user"),
CredentialType::USER_PASS_PLAINTEXT,
)
.is_err());
}
}