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(crate) fn ensure_ssh_dir(ssh_dir: &Path) -> io::Result<()> {
fs::create_dir_all(ssh_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(ssh_dir) {
let mut perms = metadata.permissions();
if perms.mode() & 0o777 != 0o700 {
perms.set_mode(0o700);
let _ = fs::set_permissions(ssh_dir, perms);
}
}
}
Ok(())
}
pub fn update_ssh_config_in_dir(accounts: &[Account], ssh_dir: &Path) -> io::Result<()> {
ensure_ssh_dir(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 {
let before = existing[..start].trim_end();
let mut merged = String::new();
if !before.is_empty() {
merged.push_str(before);
merged.push_str("\n\n");
}
merged.push_str(&managed_block);
merged
}
} 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> {
ensure_ssh_dir(ssh_dir)?;
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);
}
#[test]
fn test_update_ssh_config_self_healing() {
use super::{Account, update_ssh_config_in_dir};
use crate::models::Platform;
use std::fs;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("config");
fs::write(
&config_path,
"Host existing-host\n HostName example.com\n\n# >>> gitcore managed block >>>\nSome dangling config lines\n",
)
.unwrap();
let accounts = vec![Account {
name: "work".to_string(),
platform: Platform::Github,
key_path: "id_ed25519_work".to_string(),
host_alias: "github-work".to_string(),
username: "tester".to_string(),
email: "tester@example.com".to_string(),
gpg_key_id: None,
}];
update_ssh_config_in_dir(&accounts, dir.path()).unwrap();
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("Host existing-host"));
assert!(content.contains("# >>> gitcore managed block >>>"));
assert!(content.contains("# <<< gitcore managed block <<<"));
assert!(content.contains("Host github-work"));
let occurrences = content.matches("# >>> gitcore managed block >>>").count();
assert_eq!(occurrences, 1);
}
}