1use std::path::Path;
10
11use toride_ssh_core::{CliRunner, Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum InstallOutcome {
16 SshCopyId,
18 Manual,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum UninstallOutcome {
25 Removed,
27 NotFound,
29}
30
31pub async fn install_key_to_remote(
55 key_path: &Path,
56 dest: &str,
57 runner: &dyn CliRunner,
58) -> Result<InstallOutcome> {
59 if !key_path.exists() {
60 return Err(Error::KeyNotFound(key_path.display().to_string()));
61 }
62
63 let pub_path = key_path.with_extension("pub");
64 let pubkey_path = if pub_path.exists() {
65 pub_path
66 } else {
67 key_path.to_path_buf()
68 };
69
70 if runner.tool_exists("ssh-copy-id") {
71 let pubkey_str = pubkey_path.to_str().ok_or_else(|| {
72 Error::CommandFailed(format!(
73 "public key path is not valid UTF-8: {}",
74 pubkey_path.display()
75 ))
76 })?;
77 runner
78 .run(
79 "ssh-copy-id",
80 vec!["-i".to_owned(), pubkey_str.to_owned(), dest.to_owned()],
81 )
82 .await?;
83 return Ok(InstallOutcome::SshCopyId);
84 }
85
86 if !runner.tool_exists("ssh") {
87 return Err(Error::ToolNotFound(
88 "neither ssh-copy-id nor ssh found in PATH".to_owned(),
89 ));
90 }
91
92 install_via_manual_ssh(&pubkey_path, dest, runner).await?;
93 Ok(InstallOutcome::Manual)
94}
95
96async fn install_via_manual_ssh(
103 pubkey_path: &Path,
104 dest: &str,
105 runner: &dyn CliRunner,
106) -> Result<()> {
107 let pubkey_content = tokio::task::spawn_blocking({
108 let path = pubkey_path.to_path_buf();
109 move || std::fs::read_to_string(&path).map_err(Error::Io)
110 })
111 .await
112 .map_err(|e| Error::TaskFailed(e.to_string()))??;
113
114 let pubkey_content = pubkey_content.trim();
115
116 let escaped_key = pubkey_content.replace('\'', "'\\''");
118
119 let remote_cmd = format!(
120 "mkdir -p ~/.ssh && echo '{escaped_key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
121 );
122
123 runner.run("ssh", vec![dest.to_owned(), remote_cmd]).await?;
124
125 Ok(())
126}
127
128pub async fn uninstall_key_from_remote(
150 key_path: &Path,
151 dest: &str,
152 runner: &dyn CliRunner,
153) -> Result<UninstallOutcome> {
154 if !key_path.exists() {
155 return Err(Error::KeyNotFound(key_path.display().to_string()));
156 }
157
158 let pub_path = key_path.with_extension("pub");
159 let pubkey_path = if pub_path.exists() {
160 pub_path
161 } else {
162 key_path.to_path_buf()
163 };
164
165 if !runner.tool_exists("ssh") {
166 return Err(Error::ToolNotFound("ssh not found in PATH".to_owned()));
167 }
168
169 uninstall_via_manual_ssh(&pubkey_path, dest, runner).await
170}
171
172async fn uninstall_via_manual_ssh(
185 pubkey_path: &Path,
186 dest: &str,
187 runner: &dyn CliRunner,
188) -> Result<UninstallOutcome> {
189 let pubkey_content = tokio::task::spawn_blocking({
190 let path = pubkey_path.to_path_buf();
191 move || std::fs::read_to_string(&path).map_err(Error::Io)
192 })
193 .await
194 .map_err(|e| Error::TaskFailed(e.to_string()))??;
195
196 let pubkey_content = pubkey_content.trim();
197
198 let key_fingerprint = pubkey_content
202 .split_whitespace()
203 .take(2)
204 .collect::<Vec<&str>>()
205 .join(" ");
206
207 if key_fingerprint.is_empty() {
208 return Err(Error::CommandFailed(
209 "public key file appears to be empty or malformed".to_owned(),
210 ));
211 }
212
213 let escaped_key = key_fingerprint.replace('\'', "'\\''");
215
216 let remote_cmd = format!(
223 "grep -vF '{escaped_key}' ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.tmp 2>/dev/null; mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys 2>/dev/null; chmod 600 ~/.ssh/authorized_keys 2>/dev/null"
224 );
225
226 let check_cmd = format!(
231 "grep -qF '{escaped_key}' ~/.ssh/authorized_keys 2>/dev/null && echo FOUND || echo NOTFOUND"
232 );
233
234 let check_output = runner.run("ssh", vec![dest.to_owned(), check_cmd]).await?;
235
236 if check_output.trim() == "NOTFOUND" {
237 return Ok(UninstallOutcome::NotFound);
238 }
239
240 runner.run("ssh", vec![dest.to_owned(), remote_cmd]).await?;
241
242 Ok(UninstallOutcome::Removed)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn install_outcome_variants_are_distinct() {
251 assert_ne!(InstallOutcome::SshCopyId, InstallOutcome::Manual);
252 }
253
254 #[test]
255 fn install_key_to_remote_rejects_missing_key() {
256 let rt = tokio::runtime::Runtime::new().unwrap();
257 let runner = toride_ssh_core::MockCliRunner::new();
258 let result = rt.block_on(install_key_to_remote(
259 Path::new("/nonexistent/key"),
260 "user@host",
261 &runner,
262 ));
263 assert!(result.is_err());
264 match result.unwrap_err() {
265 Error::KeyNotFound(_) => {}
266 other => panic!("expected KeyNotFound, got: {other:?}"),
267 }
268 }
269
270 #[test]
271 fn manual_ssh_command_format() {
272 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host";
274 let escaped = key.replace('\'', "'\\''");
275 let cmd = format!(
276 "mkdir -p ~/.ssh && echo '{escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
277 );
278 assert!(cmd.starts_with("mkdir -p ~/.ssh && echo '"));
279 assert!(cmd.ends_with("chmod 600 ~/.ssh/authorized_keys"));
280 assert!(cmd.contains(">> ~/.ssh/authorized_keys"));
281 }
282
283 #[test]
284 fn manual_ssh_command_escapes_single_quotes() {
285 let key = "ssh-ed25519 AAAA it's a key user@host";
286 let escaped = key.replace('\'', "'\\''");
287 let cmd = format!("echo '{escaped}'");
288 assert!(!cmd.contains("it's"));
291 assert!(cmd.contains("it'\\''s"));
292 }
293
294 #[test]
299 fn uninstall_outcome_variants_are_distinct() {
300 assert_ne!(UninstallOutcome::Removed, UninstallOutcome::NotFound);
301 }
302
303 #[test]
304 fn uninstall_key_from_remote_rejects_missing_key() {
305 let rt = tokio::runtime::Runtime::new().unwrap();
306 let runner = toride_ssh_core::MockCliRunner::new();
307 let result = rt.block_on(uninstall_key_from_remote(
308 Path::new("/nonexistent/key"),
309 "user@host",
310 &runner,
311 ));
312 assert!(result.is_err());
313 match result.unwrap_err() {
314 Error::KeyNotFound(_) => {}
315 other => panic!("expected KeyNotFound, got: {other:?}"),
316 }
317 }
318
319 #[test]
320 fn uninstall_key_from_remote_rejects_missing_ssh() {
321 let dir = tempfile::tempdir().unwrap();
322 let key_path = dir.path().join("id_ed25519");
323 let pub_path = dir.path().join("id_ed25519.pub");
324 std::fs::write(&key_path, "private key").unwrap();
325 std::fs::write(
326 &pub_path,
327 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
328 )
329 .unwrap();
330
331 let rt = tokio::runtime::Runtime::new().unwrap();
332 let runner = toride_ssh_core::MockCliRunner::new();
333 let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
335 assert!(result.is_err());
336 match result.unwrap_err() {
337 Error::ToolNotFound(msg) => assert!(msg.contains("ssh")),
338 other => panic!("expected ToolNotFound, got: {other:?}"),
339 }
340 }
341
342 #[test]
343 fn uninstall_key_from_remote_returns_not_found() {
344 let dir = tempfile::tempdir().unwrap();
345 let key_path = dir.path().join("id_ed25519");
346 let pub_path = dir.path().join("id_ed25519.pub");
347 std::fs::write(&key_path, "private key").unwrap();
348 std::fs::write(
349 &pub_path,
350 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
351 )
352 .unwrap();
353
354 let rt = tokio::runtime::Runtime::new().unwrap();
355 let runner = toride_ssh_core::MockCliRunner::new();
356 runner.set_tool_exists("ssh", true);
357 runner.push_run_response("ssh", Ok("NOTFOUND\n".to_owned()));
359
360 let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
361 assert_eq!(result.unwrap(), UninstallOutcome::NotFound);
362 }
363
364 #[test]
365 fn uninstall_key_from_remote_returns_removed() {
366 let dir = tempfile::tempdir().unwrap();
367 let key_path = dir.path().join("id_ed25519");
368 let pub_path = dir.path().join("id_ed25519.pub");
369 std::fs::write(&key_path, "private key").unwrap();
370 std::fs::write(
371 &pub_path,
372 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
373 )
374 .unwrap();
375
376 let rt = tokio::runtime::Runtime::new().unwrap();
377 let runner = toride_ssh_core::MockCliRunner::new();
378 runner.set_tool_exists("ssh", true);
379 runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
381 runner.push_run_response("ssh", Ok(String::new()));
383
384 let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
385 assert_eq!(result.unwrap(), UninstallOutcome::Removed);
386 }
387
388 #[test]
389 fn uninstall_key_from_remote_propagates_ssh_error() {
390 let dir = tempfile::tempdir().unwrap();
391 let key_path = dir.path().join("id_ed25519");
392 let pub_path = dir.path().join("id_ed25519.pub");
393 std::fs::write(&key_path, "private key").unwrap();
394 std::fs::write(
395 &pub_path,
396 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
397 )
398 .unwrap();
399
400 let rt = tokio::runtime::Runtime::new().unwrap();
401 let runner = toride_ssh_core::MockCliRunner::new();
402 runner.set_tool_exists("ssh", true);
403 runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
405 runner.push_run_response(
407 "ssh",
408 Err(Error::CommandFailed("connection refused".to_owned())),
409 );
410
411 let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
412 assert!(result.is_err());
413 match result.unwrap_err() {
414 Error::CommandFailed(msg) => assert!(msg.contains("connection refused")),
415 other => panic!("expected CommandFailed, got: {other:?}"),
416 }
417 }
418
419 #[test]
420 fn uninstall_command_uses_key_fingerprint_not_full_line() {
421 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host";
423 let fingerprint: String = key
424 .split_whitespace()
425 .take(2)
426 .collect::<Vec<&str>>()
427 .join(" ");
428 assert_eq!(fingerprint, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI");
429 assert!(!fingerprint.contains("user@host"));
430 }
431
432 #[test]
433 fn uninstall_command_escapes_single_quotes_in_key() {
434 let key = "ssh-ed25519 AAA'A it's a key";
437 let fingerprint: String = key
438 .split_whitespace()
439 .take(2)
440 .collect::<Vec<&str>>()
441 .join(" ");
442 assert!(
443 fingerprint.contains('\''),
444 "fingerprint should contain a quote"
445 );
446 let escaped = fingerprint.replace('\'', "'\\''");
447 let cmd = format!("grep -vF '{escaped}' ~/.ssh/authorized_keys");
448 assert!(cmd.contains("AAA'\\''A"));
449 }
450
451 #[test]
452 fn uninstall_rejects_empty_pubkey() {
453 let dir = tempfile::tempdir().unwrap();
454 let key_path = dir.path().join("id_ed25519");
455 let pub_path = dir.path().join("id_ed25519.pub");
456 std::fs::write(&key_path, "private key").unwrap();
457 std::fs::write(&pub_path, "\n").unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
460 let runner = toride_ssh_core::MockCliRunner::new();
461 runner.set_tool_exists("ssh", true);
462
463 let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
464 assert!(result.is_err());
465 match result.unwrap_err() {
466 Error::CommandFailed(msg) => assert!(msg.contains("empty or malformed")),
467 other => panic!("expected CommandFailed, got: {other:?}"),
468 }
469 }
470}