Skip to content
1 change: 1 addition & 0 deletions src/auth/create/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod ssh;
299 changes: 299 additions & 0 deletions src/auth/create/ssh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
use dialoguer::{Confirm, Input, Select};
use std::fs;
use std::path::Path;
use std::process::Command;

pub fn setup_ssh_auth() {
println!("🔐 SSH Authentication Setup");
println!("Setting up SSH authentication for Git operations...\n");

let home_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
let ssh_dir = Path::new(&home_dir).join(".ssh");

// Create .ssh directory if it doesn't exist
if !ssh_dir.exists() {
println!("📁 Creating .ssh directory...");
if let Err(e) = fs::create_dir_all(&ssh_dir) {
eprintln!("❌ Failed to create .ssh directory: {}", e);
return;
}
println!("✅ .ssh directory created successfully");
}

Check warning on line 23 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L6-L23

Added lines #L6 - L23 were not covered by tests

// Check for existing SSH keys
let key_types = [
("id_ed25519", "Ed25519 (recommended)"),
("id_rsa", "RSA"),
("id_ecdsa", "ECDSA"),
];

let mut existing_keys = Vec::new();
for (key_name, key_type) in &key_types {
let private_key = ssh_dir.join(key_name);
let public_key = ssh_dir.join(format!("{}.pub", key_name));

if private_key.exists() && public_key.exists() {

Check warning on line 37 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L26-L37

Added lines #L26 - L37 were not covered by tests
// Read the public key to extract identity
let identity = match fs::read_to_string(&public_key) {
Ok(content) => {
// Extract the comment/email part (last part after the key data)
let parts: Vec<&str> = content.split_whitespace().collect();
if parts.len() >= 3 {
parts[2..].join(" ")

Check warning on line 44 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L39-L44

Added lines #L39 - L44 were not covered by tests
} else {
"No identity found".to_string()

Check warning on line 46 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L46

Added line #L46 was not covered by tests
}
}
Err(_) => "Could not read key".to_string(),

Check warning on line 49 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L49

Added line #L49 was not covered by tests
};

existing_keys.push((*key_name, *key_type, public_key, identity));
}

Check warning on line 53 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L52-L53

Added lines #L52 - L53 were not covered by tests
}

if !existing_keys.is_empty() {
println!("🔍 Found existing SSH keys:");
for (i, (key_name, key_type, _, identity)) in existing_keys.iter().enumerate() {
println!(" {}. {} ({}) - {}", i + 1, key_name, key_type, identity);
}

Check warning on line 60 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L56-L60

Added lines #L56 - L60 were not covered by tests

let options = vec!["Use existing key", "Generate new key", "Exit"];
match Select::new()
.with_prompt("Choose an option")
.default(0)
.items(&options)
.interact()

Check warning on line 67 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L62-L67

Added lines #L62 - L67 were not covered by tests
{
Ok(0) => {
if existing_keys.len() == 1 {
display_public_key_and_guide(&existing_keys[0].2);
} else {
select_existing_key(&existing_keys);
}
return;

Check warning on line 75 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L70-L75

Added lines #L70 - L75 were not covered by tests
}
Ok(1) => {
// Continue to generate new key
}

Check warning on line 79 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L77-L79

Added lines #L77 - L79 were not covered by tests
Ok(2) | Err(_) => {
println!("Setup cancelled.");
return;

Check warning on line 82 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L81-L82

Added lines #L81 - L82 were not covered by tests
}
_ => unreachable!(),

Check warning on line 84 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L84

Added line #L84 was not covered by tests
}
}

Check warning on line 86 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L86

Added line #L86 was not covered by tests

// Generate new SSH key
generate_new_ssh_key(&ssh_dir);
}

Check warning on line 90 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L89-L90

Added lines #L89 - L90 were not covered by tests

fn select_existing_key(existing_keys: &[(&str, &str, std::path::PathBuf, String)]) {
let key_options: Vec<String> = existing_keys
.iter()
.map(|(key_name, key_type, _, identity)| {
format!("{} ({}) - {}", key_name, key_type, identity)
})
.collect();

match Select::new()
.with_prompt("Select which key to use")
.default(0)
.items(&key_options)
.interact()

Check warning on line 104 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L92-L104

Added lines #L92 - L104 were not covered by tests
{
Ok(choice) => {
display_public_key_and_guide(&existing_keys[choice].2);
}
Err(_) => {
println!("Selection cancelled.");
}

Check warning on line 111 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L106-L111

Added lines #L106 - L111 were not covered by tests
}
}

Check warning on line 113 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L113

Added line #L113 was not covered by tests

fn generate_new_ssh_key(ssh_dir: &Path) {
println!("\n🔑 Generating new SSH key...");

Check warning on line 116 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L115-L116

Added lines #L115 - L116 were not covered by tests

// Get user email
let email = match Input::<String>::new()
.with_prompt("Enter your email address")
.validate_with(|input: &String| -> Result<(), &str> {
if input.trim().is_empty() {
Err("Email cannot be empty")
} else if !input.contains('@') {
Err("Please enter a valid email address")

Check warning on line 125 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L119-L125

Added lines #L119 - L125 were not covered by tests
} else {
Ok(())

Check warning on line 127 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L127

Added line #L127 was not covered by tests
}
})
.interact()

Check warning on line 130 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L129-L130

Added lines #L129 - L130 were not covered by tests
{
Ok(email) => email.trim().to_string(),

Check warning on line 132 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L132

Added line #L132 was not covered by tests
Err(_) => {
println!("Email input cancelled. Exiting.");
return;

Check warning on line 135 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L134-L135

Added lines #L134 - L135 were not covered by tests
}
};

// Choose key type
let key_types = vec![
"Ed25519 (recommended, modern and secure)",
"RSA 4096 (widely compatible)",
];

Check warning on line 143 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L140-L143

Added lines #L140 - L143 were not covered by tests

let key_choice = match Select::new()
.with_prompt("Choose SSH key type")
.default(0)
.items(&key_types)
.interact()

Check warning on line 149 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L145-L149

Added lines #L145 - L149 were not covered by tests
{
Ok(choice) => choice,

Check warning on line 151 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L151

Added line #L151 was not covered by tests
Err(_) => {
println!("Key type selection cancelled. Exiting.");
return;

Check warning on line 154 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L153-L154

Added lines #L153 - L154 were not covered by tests
}
};

let (key_type, key_name, ssh_keygen_args) = match key_choice {
1 => ("RSA", "id_rsa", vec!["-t", "rsa", "-b", "4096"]),
_ => ("Ed25519", "id_ed25519", vec!["-t", "ed25519"]),

Check warning on line 160 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L158-L160

Added lines #L158 - L160 were not covered by tests
};

println!("\n🔧 Generating {} key...", key_type);
let key_path = ssh_dir.join(key_name);

// Build ssh-keygen command
let mut cmd = Command::new("ssh-keygen");
cmd.args(&ssh_keygen_args)
.arg("-C")
.arg(&email)
.arg("-f")
.arg(&key_path)
.arg("-N")
.arg(""); // Empty passphrase for simplicity

match cmd.status() {
Ok(status) if status.success() => {
println!("✅ SSH key generated successfully!");
// Add to ssh-agent
add_key_to_agent(&key_path);
// Display public key and guide
let public_key_path = ssh_dir.join(format!("{}.pub", key_name));
display_public_key_and_guide(&public_key_path);
}
Ok(status) => {
eprintln!("❌ ssh-keygen failed with status: {}", status);
}
Err(e) => {
eprintln!("❌ Failed to run ssh-keygen: {}", e);
eprintln!("Make sure OpenSSH is installed on your system.");
}

Check warning on line 191 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L163-L191

Added lines #L163 - L191 were not covered by tests
}
}

Check warning on line 193 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L193

Added line #L193 was not covered by tests

fn add_key_to_agent(key_path: &Path) {
println!("\n🔧 Adding key to ssh-agent...");

// Check if ssh-agent is running
if std::env::var("SSH_AUTH_SOCK").is_err() {
println!("SSH agent is not running. Starting ssh-agent...");
// Try to start ssh-agent
match Command::new("ssh-agent").arg("-s").output() {
Ok(output) if output.status.success() => {
let agent_output = String::from_utf8_lossy(&output.stdout);
println!("SSH agent started. You may need to run the following commands:");
println!("{}", agent_output.trim());
}
_ => {
println!("⚠️ Could not start ssh-agent automatically.");
println!("You may need to start it manually with: eval $(ssh-agent -s)");
}

Check warning on line 211 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L195-L211

Added lines #L195 - L211 were not covered by tests
}
}

Check warning on line 213 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L213

Added line #L213 was not covered by tests

// Add key to agent
match Command::new("ssh-add").arg(key_path).status() {
Ok(status) if status.success() => {
println!("✅ Key added to ssh-agent successfully!");
}
Ok(_) => {
println!("⚠️ Failed to add key to ssh-agent");
println!(
"💡 You can add it manually with: ssh-add {}",
key_path.display()
);
}
Err(_) => {
println!("⚠️ ssh-add not available");
println!(
"💡 You can add it manually later with: ssh-add {}",
key_path.display()
);
}

Check warning on line 233 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L216-L233

Added lines #L216 - L233 were not covered by tests
}
}

Check warning on line 235 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L235

Added line #L235 was not covered by tests

fn display_public_key_and_guide(public_key_path: &Path) {
println!("\n📋 Your SSH Public Key:");
println!("{}", "─".repeat(60));

match fs::read_to_string(public_key_path) {
Ok(public_key) => {
println!("{}", public_key.trim());
println!("{}", "─".repeat(60));

println!("\n🚀 Next Steps:");
println!("\n1. Copy the public key above (it's already selected for you)");
println!("\n2. Add it to your GitHub account:");
println!(" • Go to: https://github.com/settings/ssh/new");
println!(" • Or navigate to: GitHub → Settings → SSH and GPG keys → New SSH key");
println!("\n3. Fill in the form:");
println!(" • Title: Give it a descriptive name (e.g., 'My Laptop - bgit')");
println!(" • Key type: Authentication Key");
println!(" • Key: Paste the public key from above");
println!("\n4. Click 'Add SSH key' and enter your GitHub password if prompted");
println!("\n5. Test your connection:");
println!(" ssh -T git@github.com");
println!(
"\n🎉 You're all set! Your bgit tool can now authenticate with GitHub using SSH."
);

// Offer to open GitHub in browser
if Confirm::new()
.with_prompt("Would you like to open GitHub SSH settings in your default browser?")
.default(false)
.interact()
.unwrap_or(false)
{
open_github_ssh_settings();
}

Check warning on line 270 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L237-L270

Added lines #L237 - L270 were not covered by tests
}
Err(e) => {
eprintln!("❌ Failed to read public key file: {}", e);
}

Check warning on line 274 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L272-L274

Added lines #L272 - L274 were not covered by tests
}
}

Check warning on line 276 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L276

Added line #L276 was not covered by tests

fn open_github_ssh_settings() {
let url = "https://github.com/settings/ssh/new";

#[cfg(target_os = "windows")]
let cmd = Command::new("cmd").args(["/c", "start", url]).status();

#[cfg(target_os = "macos")]
let cmd = Command::new("open").arg(url).status();

#[cfg(target_os = "linux")]
let cmd = Command::new("xdg-open").arg(url).status();

Check warning on line 288 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L278-L288

Added lines #L278 - L288 were not covered by tests

match cmd {
Ok(status) if status.success() => {
println!("🌐 Opening GitHub SSH settings in your browser...");
}
_ => {
println!("⚠️ Could not open browser automatically.");
println!("🔗 Please visit: {}", url);
}

Check warning on line 297 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L290-L297

Added lines #L290 - L297 were not covered by tests
}
}

Check warning on line 299 in src/auth/create/ssh.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/create/ssh.rs#L299

Added line #L299 was not covered by tests
33 changes: 33 additions & 0 deletions src/auth/git_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use git2::{CertificateCheckStatus, CredentialType, RemoteCallbacks};
use log::debug;
use std::sync::{Arc, Mutex};

use crate::auth::{git_http::try_userpass_authentication, git_ssh::ssh_authenticate_git};

pub fn setup_auth_callbacks() -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();

// Track attempt count across callback invocations
let attempt_count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));

callbacks.credentials(move |url, username_from_url, allowed_types| {
let mut count = attempt_count.lock().unwrap();
*count += 1;
let current_attempt = *count;
drop(count);

if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) {
try_userpass_authentication(username_from_url)

Check warning on line 20 in src/auth/git_auth.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_auth.rs#L7-L20

Added lines #L7 - L20 were not covered by tests
} else {
ssh_authenticate_git(url, username_from_url, allowed_types, current_attempt)

Check warning on line 22 in src/auth/git_auth.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_auth.rs#L22

Added line #L22 was not covered by tests
}
});

// Set up certificate check callback for HTTPS
callbacks.certificate_check(|_cert, _host| {
debug!("Skipping certificate verification (INSECURE)");
Ok(CertificateCheckStatus::CertificateOk)
});

callbacks
}

Check warning on line 33 in src/auth/git_auth.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_auth.rs#L24-L33

Added lines #L24 - L33 were not covered by tests
55 changes: 55 additions & 0 deletions src/auth/git_http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
use dialoguer::{Input, Password, theme::ColorfulTheme};
use git2::{Cred, Error, ErrorClass, ErrorCode};
use log::debug;

pub fn try_userpass_authentication(username_from_url: Option<&str>) -> Result<Cred, Error> {
debug!("USER_PASS_PLAINTEXT authentication is allowed, prompting for credentials");

Check warning on line 6 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L5-L6

Added lines #L5 - L6 were not covered by tests

// Prompt for username if not provided in URL
let username = if let Some(user) = username_from_url {
user.to_string()

Check warning on line 10 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L9-L10

Added lines #L9 - L10 were not covered by tests
} else {
Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Enter your username")
.interact()
.map_err(|e| {
Error::new(
ErrorCode::Auth,
ErrorClass::Net,
format!("Failed to read username: {}", e),
)
})?

Check warning on line 21 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L12-L21

Added lines #L12 - L21 were not covered by tests
};

let token = Password::with_theme(&ColorfulTheme::default())
.with_prompt("Enter your personal access token")
.interact()
.map_err(|e| {
Error::new(
ErrorCode::Auth,
ErrorClass::Net,
format!("Failed to read token: {}", e),
)
})?;

Check warning on line 33 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L24-L33

Added lines #L24 - L33 were not covered by tests

if !username.is_empty() && !token.is_empty() {
debug!("Creating credentials with username and token");
match Cred::userpass_plaintext(&username, &token) {
Ok(cred) => {
debug!("Username/token authentication succeeded");
Ok(cred)

Check warning on line 40 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L35-L40

Added lines #L35 - L40 were not covered by tests
}
Err(e) => {
debug!("Username/token authentication failed: {}", e);
Err(e)

Check warning on line 44 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L42-L44

Added lines #L42 - L44 were not covered by tests
}
}
} else {
debug!("Username or token is empty, skipping userpass authentication");
Err(Error::new(
ErrorCode::Auth,
ErrorClass::Net,
"Username or token cannot be empty",
))

Check warning on line 53 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L48-L53

Added lines #L48 - L53 were not covered by tests
}
}

Check warning on line 55 in src/auth/git_http.rs

View check run for this annotation

Codecov / codecov/patch

src/auth/git_http.rs#L55

Added line #L55 was not covered by tests
Loading