1use crate::error::{Result, SshError};
5use ssh_key::{Algorithm, LineEnding, PrivateKey};
6use std::path::{Path, PathBuf};
7
8pub fn xdg_config_dir() -> Option<PathBuf> {
12 std::env::var_os("XDG_CONFIG_HOME")
13 .map(PathBuf::from)
14 .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
15 .map(|p| p.join("coven"))
16}
17
18pub fn default_agent_key_path() -> Option<PathBuf> {
20 xdg_config_dir().map(|p| p.join("agent_key"))
21}
22
23pub fn default_client_key_path() -> Option<PathBuf> {
25 xdg_config_dir().map(|p| p.join("client_key"))
26}
27
28pub fn default_swarm_key_path() -> Option<PathBuf> {
30 xdg_config_dir().map(|p| p.join("coven-swarm").join("agent_key"))
31}
32
33pub fn load_key(key_path: &Path) -> Result<PrivateKey> {
38 let key_data = std::fs::read_to_string(key_path).map_err(|e| SshError::ReadKey {
39 path: key_path.to_path_buf(),
40 source: e,
41 })?;
42
43 PrivateKey::from_openssh(&key_data).map_err(|e| SshError::ParseKey {
44 path: key_path.to_path_buf(),
45 source: e,
46 })
47}
48
49pub fn generate_key(key_path: &Path) -> Result<PrivateKey> {
57 eprintln!("Generating new SSH key at {}...", key_path.display());
58
59 if let Some(parent) = key_path.parent() {
61 std::fs::create_dir_all(parent).map_err(|e| SshError::CreateDirectory {
62 path: parent.to_path_buf(),
63 source: e,
64 })?;
65 }
66
67 let private_key = PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519)
69 .map_err(SshError::GenerateKey)?;
70
71 let private_key_str = private_key
73 .to_openssh(LineEnding::LF)
74 .map_err(SshError::SerializeKey)?;
75
76 std::fs::write(key_path, private_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
77 path: key_path.to_path_buf(),
78 source: e,
79 })?;
80
81 #[cfg(unix)]
83 {
84 use std::os::unix::fs::PermissionsExt;
85 std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)).map_err(
86 |e| SshError::SetPermissions {
87 path: key_path.to_path_buf(),
88 source: e,
89 },
90 )?;
91 }
92
93 let pub_key_path = key_path.with_extension("pub");
95 let public_key = private_key.public_key();
96 let public_key_str = public_key.to_openssh().map_err(SshError::SerializeKey)?;
97
98 std::fs::write(&pub_key_path, public_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
99 path: pub_key_path.clone(),
100 source: e,
101 })?;
102
103 eprintln!("SSH key generated!");
104 eprintln!(" Private: {}", key_path.display());
105 eprintln!(" Public: {}", pub_key_path.display());
106
107 Ok(private_key)
108}
109
110pub fn load_or_generate_key(key_path: &Path) -> Result<PrivateKey> {
118 if key_path.exists() {
119 load_key(key_path)
120 } else {
121 generate_key(key_path)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use tempfile::TempDir;
129
130 #[test]
131 fn test_xdg_config_dir_returns_some() {
132 let dir = xdg_config_dir();
134 assert!(dir.is_some() || std::env::var_os("HOME").is_none());
135 }
136
137 #[test]
138 fn test_default_agent_key_path_ends_with_agent_key() {
139 if let Some(path) = default_agent_key_path() {
140 assert!(path.ends_with("agent_key"));
141 assert!(path.to_string_lossy().contains("coven"));
142 }
143 }
144
145 #[test]
146 fn test_default_swarm_key_path_ends_with_agent_key() {
147 if let Some(path) = default_swarm_key_path() {
148 assert!(path.ends_with("agent_key"));
149 assert!(path.to_string_lossy().contains("coven-swarm"));
150 }
151 }
152
153 #[test]
154 fn test_generate_and_load_key() {
155 let temp_dir = TempDir::new().expect("should create temp dir");
156 let key_path = temp_dir.path().join("test_key");
157
158 let generated = generate_key(&key_path).expect("should generate key");
160
161 assert!(key_path.exists(), "private key should exist");
163 assert!(
164 key_path.with_extension("pub").exists(),
165 "public key should exist"
166 );
167
168 let loaded = load_key(&key_path).expect("should load key");
170
171 assert_eq!(
173 generated.public_key().to_openssh().unwrap(),
174 loaded.public_key().to_openssh().unwrap(),
175 "loaded key should match generated key"
176 );
177 }
178
179 #[test]
180 fn test_load_or_generate_generates_when_missing() {
181 let temp_dir = TempDir::new().expect("should create temp dir");
182 let key_path = temp_dir.path().join("new_key");
183
184 assert!(!key_path.exists(), "key should not exist initially");
185
186 let key = load_or_generate_key(&key_path).expect("should generate key");
187 assert!(key_path.exists(), "key should exist after generation");
188 assert!(key.public_key().key_data().is_ed25519());
189 }
190
191 #[test]
192 fn test_load_or_generate_loads_when_exists() {
193 let temp_dir = TempDir::new().expect("should create temp dir");
194 let key_path = temp_dir.path().join("existing_key");
195
196 let original = generate_key(&key_path).expect("should generate key");
198
199 let loaded = load_or_generate_key(&key_path).expect("should load key");
201
202 assert_eq!(
203 original.public_key().to_openssh().unwrap(),
204 loaded.public_key().to_openssh().unwrap(),
205 "should load existing key, not generate new"
206 );
207 }
208
209 #[test]
210 fn test_generated_key_is_ed25519() {
211 let temp_dir = TempDir::new().expect("should create temp dir");
212 let key_path = temp_dir.path().join("ed25519_key");
213
214 let key = generate_key(&key_path).expect("should generate key");
215 assert!(key.public_key().key_data().is_ed25519());
216 }
217
218 #[cfg(unix)]
219 #[test]
220 fn test_private_key_has_restrictive_permissions() {
221 use std::os::unix::fs::PermissionsExt;
222
223 let temp_dir = TempDir::new().expect("should create temp dir");
224 let key_path = temp_dir.path().join("secure_key");
225
226 generate_key(&key_path).expect("should generate key");
227
228 let metadata = std::fs::metadata(&key_path).expect("should read metadata");
229 let mode = metadata.permissions().mode() & 0o777;
230 assert_eq!(mode, 0o600, "private key should have 0600 permissions");
231 }
232
233 #[test]
234 fn test_load_key_file_not_found() {
235 let temp_dir = TempDir::new().expect("should create temp dir");
236 let nonexistent_path = temp_dir.path().join("nonexistent_key");
237
238 let result = load_key(&nonexistent_path);
239 assert!(result.is_err());
240
241 let err = result.unwrap_err();
242 assert!(matches!(err, crate::error::SshError::ReadKey { .. }));
243 }
244
245 #[test]
246 fn test_load_key_invalid_format() {
247 let temp_dir = TempDir::new().expect("should create temp dir");
248 let invalid_key_path = temp_dir.path().join("invalid_key");
249
250 std::fs::write(&invalid_key_path, "not a valid ssh key").expect("should write file");
252
253 let result = load_key(&invalid_key_path);
254 assert!(result.is_err());
255
256 let err = result.unwrap_err();
257 assert!(matches!(err, crate::error::SshError::ParseKey { .. }));
258 }
259
260 #[test]
261 fn test_xdg_config_home_override() {
262 let original = std::env::var_os("XDG_CONFIG_HOME");
264
265 std::env::set_var("XDG_CONFIG_HOME", "/custom/config");
267
268 let dir = xdg_config_dir();
269 assert!(dir.is_some());
270 assert_eq!(dir.unwrap(), PathBuf::from("/custom/config/coven"));
271
272 match original {
274 Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
275 None => std::env::remove_var("XDG_CONFIG_HOME"),
276 }
277 }
278}