gitcore 1.4.0

A secure, zero-friction Git identity manager for developers who juggle multiple accounts.
Documentation
use crate::command_runner::{CommandRunner, SystemCommandRunner};
use crate::git::run_command_with;
use crate::models::Account;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Output};

pub fn get_ssh_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("~"))
        .join(".ssh")
}

pub fn update_ssh_config_in_dir(accounts: &[Account], ssh_dir: &Path) -> io::Result<()> {
    fs::create_dir_all(ssh_dir)?;
    let config_path = ssh_dir.join("config");
    const START_MARKER: &str = "# >>> gitcore managed block >>>";
    const END_MARKER: &str = "# <<< gitcore managed block <<<";

    let mut managed_block = String::new();
    managed_block.push_str("# Generated by gitcore - DO NOT EDIT MANUALLY\n");
    managed_block.push_str(START_MARKER);
    managed_block.push('\n');

    for acc in accounts {
        let key_full_path = ssh_dir.join(&acc.key_path);
        let key_path_str = key_full_path.to_string_lossy().replace('\\', "/");
        managed_block.push_str(&format!("Host {}\n", acc.host_alias));
        managed_block.push_str(&format!("  HostName {}\n", acc.platform.host()));
        managed_block.push_str("  User git\n");
        managed_block.push_str(&format!("  IdentityFile {}\n", key_path_str));
        managed_block.push_str("  AddKeysToAgent yes\n");
        managed_block.push_str("  IdentitiesOnly yes\n\n");
    }

    managed_block.push_str(END_MARKER);
    managed_block.push('\n');

    let existing = fs::read_to_string(&config_path).unwrap_or_default();
    let new_content = if let Some(start) = existing.find(START_MARKER) {
        if let Some(end_rel) = existing[start..].find(END_MARKER) {
            let end = start + end_rel + END_MARKER.len();
            let before = existing[..start].trim_end();
            let after = existing[end..].trim_start();

            let mut merged = String::new();
            if !before.is_empty() {
                merged.push_str(before);
                merged.push_str("\n\n");
            }
            merged.push_str(&managed_block);
            if !after.is_empty() {
                merged.push('\n');
                merged.push_str(after);
                if !merged.ends_with('\n') {
                    merged.push('\n');
                }
            }
            merged
        } else {
            format!("{}\n{}", existing.trim_end(), managed_block)
        }
    } else if existing.trim().is_empty() {
        managed_block.clone()
    } else {
        format!("{}\n\n{}", existing.trim_end(), managed_block)
    };

    let tmp_path = config_path.with_extension("tmp");
    fs::write(&tmp_path, new_content)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?;
    }

    fs::rename(&tmp_path, &config_path)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600))?;
    }

    Ok(())
}

pub fn generate_ssh_key(key_path: &str, email: &str, passphrase: &str) -> io::Result<String> {
    generate_ssh_key_in_dir(&get_ssh_dir(), key_path, email, passphrase)
}

pub fn generate_ssh_key_in_dir(
    ssh_dir: &Path,
    key_path: &str,
    email: &str,
    passphrase: &str,
) -> io::Result<String> {
    generate_ssh_key_in_dir_with(&SystemCommandRunner, ssh_dir, key_path, email, passphrase)
}

pub(crate) fn generate_ssh_key_in_dir_with(
    runner: &dyn CommandRunner,
    ssh_dir: &Path,
    key_path: &str,
    email: &str,
    passphrase: &str,
) -> io::Result<String> {
    let key_full = ssh_dir.join(key_path);

    if !key_full.exists() {
        run_command_with(
            runner,
            "ssh-keygen",
            &[
                "-t",
                "ed25519",
                "-f",
                key_full.to_str().unwrap(),
                "-N",
                passphrase,
                "-C",
                email,
            ],
        )?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&key_full, fs::Permissions::from_mode(0o600))?;
            let pub_key_path = ssh_dir.join(format!("{}.pub", key_path));
            fs::set_permissions(&pub_key_path, fs::Permissions::from_mode(0o644))?;
        }
    }

    let pub_key_path = ssh_dir.join(format!("{}.pub", key_path));
    let pub_key = fs::read_to_string(pub_key_path)?;
    Ok(pub_key)
}

pub fn delete_account_keys(key_path: &str) -> io::Result<Vec<PathBuf>> {
    delete_account_keys_in_dir(&get_ssh_dir(), key_path)
}

pub fn delete_account_keys_in_dir(ssh_dir: &Path, key_path: &str) -> io::Result<Vec<PathBuf>> {
    let paths = [
        ssh_dir.join(key_path),
        ssh_dir.join(format!("{}.pub", key_path)),
    ];

    let mut deleted = Vec::new();
    for path in paths {
        if path.exists() {
            fs::remove_file(&path)?;
            deleted.push(path);
        }
    }

    Ok(deleted)
}

pub(crate) fn test_ssh_connection_with(
    runner: &dyn CommandRunner,
    host_alias: &str,
) -> io::Result<Output> {
    runner.run(
        "ssh",
        &[
            "-o",
            "BatchMode=yes",
            "-o",
            "ConnectTimeout=10",
            "-o",
            "StrictHostKeyChecking=accept-new",
            "-T",
            host_alias,
        ],
    )
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SshConnectionState {
    Authenticated,
    ConnectedWithoutShell,
    Failed,
}

#[derive(Debug, Clone)]
pub struct SshConnectionProbe {
    pub status: ExitStatus,
    pub stderr: String,
    pub state: SshConnectionState,
}

pub(crate) fn probe_ssh_connection_with(
    runner: &dyn CommandRunner,
    host_alias: &str,
) -> io::Result<SshConnectionProbe> {
    let output = test_ssh_connection_with(runner, host_alias)?;
    Ok(parse_ssh_connection_probe(output))
}

fn parse_ssh_connection_probe(output: Output) -> SshConnectionProbe {
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    let state = classify_ssh_connection(&output.status, &stderr);

    SshConnectionProbe {
        status: output.status,
        stderr,
        state,
    }
}

fn classify_ssh_connection(status: &ExitStatus, stderr: &str) -> SshConnectionState {
    let stderr = stderr.trim();
    let authenticated = stderr.contains("successfully authenticated")
        || stderr.starts_with("Hi ")
        || stderr.starts_with("Welcome to GitLab")
        || stderr.contains("Shell access is not supported")
        || stderr.contains("does not provide shell access");

    if authenticated {
        SshConnectionState::Authenticated
    } else if status.success() {
        SshConnectionState::ConnectedWithoutShell
    } else {
        SshConnectionState::Failed
    }
}

pub fn check_host_key_in_dir(ssh_dir: &Path, platform_host: &str) -> HostKeyStatus {
    let known_hosts_path = ssh_dir.join("known_hosts");
    if !known_hosts_path.exists() {
        return HostKeyStatus::Unknown;
    }

    match fs::read_to_string(&known_hosts_path) {
        Ok(content) => {
            if content.contains(platform_host) {
                HostKeyStatus::Known
            } else {
                HostKeyStatus::New
            }
        }
        Err(_) => HostKeyStatus::Unknown,
    }
}

#[derive(Debug, Clone, Copy)]
pub enum HostKeyStatus {
    Known,
    New,
    Unknown,
}

#[cfg(test)]
mod tests {
    use super::{SshConnectionState, classify_ssh_connection};
    #[cfg(unix)]
    use std::os::unix::process::ExitStatusExt;
    #[cfg(windows)]
    use std::os::windows::process::ExitStatusExt;
    use std::process::ExitStatus;

    #[test]
    fn classifies_github_auth_message_as_authenticated() {
        let status = ExitStatus::from_raw(255);
        let state = classify_ssh_connection(
            &status,
            "Hi octocat! You've successfully authenticated, but GitHub does not provide shell access.",
        );
        assert_eq!(state, SshConnectionState::Authenticated);
    }

    #[test]
    fn classifies_success_exit_without_auth_banner() {
        let status = ExitStatus::from_raw(0);
        let state = classify_ssh_connection(&status, "");
        assert_eq!(state, SshConnectionState::ConnectedWithoutShell);
    }

    #[test]
    fn classifies_permission_denied_as_failed() {
        let status = ExitStatus::from_raw(255);
        let state = classify_ssh_connection(&status, "Permission denied (publickey).");
        assert_eq!(state, SshConnectionState::Failed);
    }
}