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 src/auth/create/mod.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pub mod ssh;
299 changes: 299 additions & 0 deletions src/auth/create/ssh.rs
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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