use crate::error::{Result, SshError};
use ssh_key::{Algorithm, LineEnding, PrivateKey};
use std::path::{Path, PathBuf};
pub fn xdg_config_dir() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.map(|p| p.join("coven"))
}
pub fn default_agent_key_path() -> Option<PathBuf> {
xdg_config_dir().map(|p| p.join("agent_key"))
}
pub fn default_client_key_path() -> Option<PathBuf> {
xdg_config_dir().map(|p| p.join("client_key"))
}
pub fn default_swarm_key_path() -> Option<PathBuf> {
xdg_config_dir().map(|p| p.join("coven-swarm").join("agent_key"))
}
pub fn load_key(key_path: &Path) -> Result<PrivateKey> {
let key_data = std::fs::read_to_string(key_path).map_err(|e| SshError::ReadKey {
path: key_path.to_path_buf(),
source: e,
})?;
PrivateKey::from_openssh(&key_data).map_err(|e| SshError::ParseKey {
path: key_path.to_path_buf(),
source: e,
})
}
pub fn generate_key(key_path: &Path) -> Result<PrivateKey> {
eprintln!("Generating new SSH key at {}...", key_path.display());
if let Some(parent) = key_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SshError::CreateDirectory {
path: parent.to_path_buf(),
source: e,
})?;
}
let private_key = PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519)
.map_err(SshError::GenerateKey)?;
let private_key_str = private_key
.to_openssh(LineEnding::LF)
.map_err(SshError::SerializeKey)?;
std::fs::write(key_path, private_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
path: key_path.to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)).map_err(
|e| SshError::SetPermissions {
path: key_path.to_path_buf(),
source: e,
},
)?;
}
let pub_key_path = key_path.with_extension("pub");
let public_key = private_key.public_key();
let public_key_str = public_key.to_openssh().map_err(SshError::SerializeKey)?;
std::fs::write(&pub_key_path, public_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
path: pub_key_path.clone(),
source: e,
})?;
eprintln!("SSH key generated!");
eprintln!(" Private: {}", key_path.display());
eprintln!(" Public: {}", pub_key_path.display());
Ok(private_key)
}
pub fn load_or_generate_key(key_path: &Path) -> Result<PrivateKey> {
if key_path.exists() {
load_key(key_path)
} else {
generate_key(key_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_xdg_config_dir_returns_some() {
let dir = xdg_config_dir();
assert!(dir.is_some() || std::env::var_os("HOME").is_none());
}
#[test]
fn test_default_agent_key_path_ends_with_agent_key() {
if let Some(path) = default_agent_key_path() {
assert!(path.ends_with("agent_key"));
assert!(path.to_string_lossy().contains("coven"));
}
}
#[test]
fn test_default_swarm_key_path_ends_with_agent_key() {
if let Some(path) = default_swarm_key_path() {
assert!(path.ends_with("agent_key"));
assert!(path.to_string_lossy().contains("coven-swarm"));
}
}
#[test]
fn test_generate_and_load_key() {
let temp_dir = TempDir::new().expect("should create temp dir");
let key_path = temp_dir.path().join("test_key");
let generated = generate_key(&key_path).expect("should generate key");
assert!(key_path.exists(), "private key should exist");
assert!(
key_path.with_extension("pub").exists(),
"public key should exist"
);
let loaded = load_key(&key_path).expect("should load key");
assert_eq!(
generated.public_key().to_openssh().unwrap(),
loaded.public_key().to_openssh().unwrap(),
"loaded key should match generated key"
);
}
#[test]
fn test_load_or_generate_generates_when_missing() {
let temp_dir = TempDir::new().expect("should create temp dir");
let key_path = temp_dir.path().join("new_key");
assert!(!key_path.exists(), "key should not exist initially");
let key = load_or_generate_key(&key_path).expect("should generate key");
assert!(key_path.exists(), "key should exist after generation");
assert!(key.public_key().key_data().is_ed25519());
}
#[test]
fn test_load_or_generate_loads_when_exists() {
let temp_dir = TempDir::new().expect("should create temp dir");
let key_path = temp_dir.path().join("existing_key");
let original = generate_key(&key_path).expect("should generate key");
let loaded = load_or_generate_key(&key_path).expect("should load key");
assert_eq!(
original.public_key().to_openssh().unwrap(),
loaded.public_key().to_openssh().unwrap(),
"should load existing key, not generate new"
);
}
#[test]
fn test_generated_key_is_ed25519() {
let temp_dir = TempDir::new().expect("should create temp dir");
let key_path = temp_dir.path().join("ed25519_key");
let key = generate_key(&key_path).expect("should generate key");
assert!(key.public_key().key_data().is_ed25519());
}
#[cfg(unix)]
#[test]
fn test_private_key_has_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().expect("should create temp dir");
let key_path = temp_dir.path().join("secure_key");
generate_key(&key_path).expect("should generate key");
let metadata = std::fs::metadata(&key_path).expect("should read metadata");
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "private key should have 0600 permissions");
}
#[test]
fn test_load_key_file_not_found() {
let temp_dir = TempDir::new().expect("should create temp dir");
let nonexistent_path = temp_dir.path().join("nonexistent_key");
let result = load_key(&nonexistent_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, crate::error::SshError::ReadKey { .. }));
}
#[test]
fn test_load_key_invalid_format() {
let temp_dir = TempDir::new().expect("should create temp dir");
let invalid_key_path = temp_dir.path().join("invalid_key");
std::fs::write(&invalid_key_path, "not a valid ssh key").expect("should write file");
let result = load_key(&invalid_key_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, crate::error::SshError::ParseKey { .. }));
}
#[test]
fn test_xdg_config_home_override() {
let original = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", "/custom/config");
let dir = xdg_config_dir();
assert!(dir.is_some());
assert_eq!(dir.unwrap(), PathBuf::from("/custom/config/coven"));
match original {
Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}