Skip to main content

toride_ssh_key/
install.rs

1//! Install and uninstall SSH public keys on remote hosts.
2//!
3//! The primary entry points are:
4//! - [`install_key_to_remote`] -- installs a public key via `ssh-copy-id` or
5//!   manual SSH fallback.
6//! - [`uninstall_key_from_remote`] -- removes a matching key line from the
7//!   remote `~/.ssh/authorized_keys` file.
8
9use std::path::Path;
10
11use toride_ssh_core::{CliRunner, Error, Result};
12
13/// Result of a key installation attempt.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum InstallOutcome {
16    /// `ssh-copy-id` was available and succeeded.
17    SshCopyId,
18    /// `ssh-copy-id` was not available; manual install via `ssh` succeeded.
19    Manual,
20}
21
22/// Result of a key removal attempt.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum UninstallOutcome {
25    /// The key was found and removed from the remote `authorized_keys`.
26    Removed,
27    /// The key was not present in the remote `authorized_keys`.
28    NotFound,
29}
30
31/// Install a public key on a remote host.
32///
33/// This function:
34/// 1. Detects whether `ssh-copy-id` is available on the local system.
35/// 2. If available, uses `ssh-copy-id -i <pubkey> <dest>` to install the key.
36/// 3. If not available, falls back to manual mode: SSH into the remote and run
37///    `mkdir -p ~/.ssh && echo '<key>' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`.
38///
39/// # Arguments
40///
41/// * `key_path` - Path to the **private** key file. The corresponding `.pub`
42///   file is derived automatically (e.g., `id_ed25519` -> `id_ed25519.pub`).
43///   If the `.pub` file does not exist, the private key path is used directly
44///   (some `ssh-copy-id` implementations accept this).
45/// * `dest` - The remote destination in `[user@]host` format (same as what
46///   you would pass to `ssh` or `ssh-copy-id`).
47///
48/// # Errors
49///
50/// Returns an error if:
51/// - The key path does not exist.
52/// - Neither `ssh-copy-id` nor `ssh` is available.
53/// - The remote command fails (authentication denied, network unreachable, etc.).
54pub 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
96/// Install via manual SSH command when `ssh-copy-id` is unavailable.
97///
98/// Reads the public key content locally, then runs:
99/// ```sh
100/// ssh <dest> "mkdir -p ~/.ssh && echo '<key>' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
101/// ```
102async 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    // Escape single quotes in the key content for safe shell embedding.
117    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
128/// Remove a public key from a remote host's `authorized_keys`.
129///
130/// This function:
131/// 1. Reads the local public key content.
132/// 2. `SSHes` into the remote host and uses `grep -v` to strip the matching key
133///    line from `~/.ssh/authorized_keys`.
134///
135/// # Arguments
136///
137/// * `key_path` - Path to the **private** key file. The corresponding `.pub`
138///   file is derived automatically (e.g., `id_ed25519` -> `id_ed25519.pub`).
139///   If the `.pub` file does not exist, the private key path is used directly.
140/// * `dest` - The remote destination in `[user@]host` format (same as what
141///   you would pass to `ssh`).
142///
143/// # Errors
144///
145/// Returns an error if:
146/// - The key path does not exist.
147/// - `ssh` is not available in `PATH`.
148/// - The remote command fails (authentication denied, network unreachable, etc.).
149pub 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
172/// Remove via manual SSH command using `grep -v`.
173///
174/// Reads the public key content locally, then runs:
175/// ```sh
176/// ssh <dest> "grep -vF '<key_content>' ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.tmp && mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
177/// ```
178///
179/// The remote `grep -vF` removes any line that exactly contains the full key
180/// content (excluding any trailing comment).  This is safe to run even when
181/// the key is not present in the file -- `grep -vF` will simply reproduce the
182/// original content unchanged, and we detect the no-op by comparing the
183/// original and filtered output.
184async 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    // Extract just the key type + base64 data (skip the comment) so that
199    // `grep -vF` matches the key line regardless of comment differences.
200    // SSH public key format: <key-type> <base64-data> [comment]
201    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    // Escape single quotes in the key content for safe shell embedding.
214    let escaped_key = key_fingerprint.replace('\'', "'\\''");
215
216    // Build a remote command that atomically removes the matching key line:
217    // 1. grep -vF removes lines containing the key fingerprint.
218    // 2. Write to a temp file and atomically move into place.
219    // 3. Preserve permissions with chmod 600.
220    // 4. If grep produces no output (file becomes empty or key not found),
221    //    we still get an empty file; the mv still succeeds.
222    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    // We cannot reliably distinguish "key was present and removed" from
227    // "key was never there" based on grep exit code alone when piping into
228    // a temp file, so we use a two-step approach: first check if the key
229    // exists on the remote, then remove it.
230    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        // Verify the remote command format matches expectations.
273        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        // The escaped version should not have unescaped single quotes inside
289        // the echo argument. After escaping, it's becomes it'\''s.
290        assert!(!cmd.contains("it's"));
291        assert!(cmd.contains("it'\\''s"));
292    }
293
294    // -----------------------------------------------------------------------
295    // Uninstall tests
296    // -----------------------------------------------------------------------
297
298    #[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        // ssh is not registered as existing.
334        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        // The check command returns NOTFOUND.
358        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        // First call: check command returns FOUND.
380        runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
381        // Second call: removal command succeeds.
382        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        // Check command returns FOUND.
404        runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
405        // Removal command fails.
406        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        // The grep -vF command should use only key type + base64, not the comment.
422        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        // Use a key where the base64 portion itself contains a single quote
435        // (synthetic test to verify escaping logic on the fingerprint).
436        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(); // empty/malformed pubkey
458
459        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}