use std::path::Path;
use toride_ssh_core::{CliRunner, Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
SshCopyId,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UninstallOutcome {
Removed,
NotFound,
}
pub async fn install_key_to_remote(
key_path: &Path,
dest: &str,
runner: &dyn CliRunner,
) -> Result<InstallOutcome> {
if !key_path.exists() {
return Err(Error::KeyNotFound(key_path.display().to_string()));
}
let pub_path = key_path.with_extension("pub");
let pubkey_path = if pub_path.exists() {
pub_path
} else {
key_path.to_path_buf()
};
if runner.tool_exists("ssh-copy-id") {
let pubkey_str = pubkey_path.to_str().ok_or_else(|| {
Error::CommandFailed(format!(
"public key path is not valid UTF-8: {}",
pubkey_path.display()
))
})?;
runner
.run(
"ssh-copy-id",
vec!["-i".to_owned(), pubkey_str.to_owned(), dest.to_owned()],
)
.await?;
return Ok(InstallOutcome::SshCopyId);
}
if !runner.tool_exists("ssh") {
return Err(Error::ToolNotFound(
"neither ssh-copy-id nor ssh found in PATH".to_owned(),
));
}
install_via_manual_ssh(&pubkey_path, dest, runner).await?;
Ok(InstallOutcome::Manual)
}
async fn install_via_manual_ssh(
pubkey_path: &Path,
dest: &str,
runner: &dyn CliRunner,
) -> Result<()> {
let pubkey_content = tokio::task::spawn_blocking({
let path = pubkey_path.to_path_buf();
move || std::fs::read_to_string(&path).map_err(Error::Io)
})
.await
.map_err(|e| Error::TaskFailed(e.to_string()))??;
let pubkey_content = pubkey_content.trim();
let escaped_key = pubkey_content.replace('\'', "'\\''");
let remote_cmd = format!(
"mkdir -p ~/.ssh && echo '{escaped_key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
);
runner.run("ssh", vec![dest.to_owned(), remote_cmd]).await?;
Ok(())
}
pub async fn uninstall_key_from_remote(
key_path: &Path,
dest: &str,
runner: &dyn CliRunner,
) -> Result<UninstallOutcome> {
if !key_path.exists() {
return Err(Error::KeyNotFound(key_path.display().to_string()));
}
let pub_path = key_path.with_extension("pub");
let pubkey_path = if pub_path.exists() {
pub_path
} else {
key_path.to_path_buf()
};
if !runner.tool_exists("ssh") {
return Err(Error::ToolNotFound("ssh not found in PATH".to_owned()));
}
uninstall_via_manual_ssh(&pubkey_path, dest, runner).await
}
async fn uninstall_via_manual_ssh(
pubkey_path: &Path,
dest: &str,
runner: &dyn CliRunner,
) -> Result<UninstallOutcome> {
let pubkey_content = tokio::task::spawn_blocking({
let path = pubkey_path.to_path_buf();
move || std::fs::read_to_string(&path).map_err(Error::Io)
})
.await
.map_err(|e| Error::TaskFailed(e.to_string()))??;
let pubkey_content = pubkey_content.trim();
let key_fingerprint = pubkey_content
.split_whitespace()
.take(2)
.collect::<Vec<&str>>()
.join(" ");
if key_fingerprint.is_empty() {
return Err(Error::CommandFailed(
"public key file appears to be empty or malformed".to_owned(),
));
}
let escaped_key = key_fingerprint.replace('\'', "'\\''");
let remote_cmd = format!(
"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"
);
let check_cmd = format!(
"grep -qF '{escaped_key}' ~/.ssh/authorized_keys 2>/dev/null && echo FOUND || echo NOTFOUND"
);
let check_output = runner.run("ssh", vec![dest.to_owned(), check_cmd]).await?;
if check_output.trim() == "NOTFOUND" {
return Ok(UninstallOutcome::NotFound);
}
runner.run("ssh", vec![dest.to_owned(), remote_cmd]).await?;
Ok(UninstallOutcome::Removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_outcome_variants_are_distinct() {
assert_ne!(InstallOutcome::SshCopyId, InstallOutcome::Manual);
}
#[test]
fn install_key_to_remote_rejects_missing_key() {
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
let result = rt.block_on(install_key_to_remote(
Path::new("/nonexistent/key"),
"user@host",
&runner,
));
assert!(result.is_err());
match result.unwrap_err() {
Error::KeyNotFound(_) => {}
other => panic!("expected KeyNotFound, got: {other:?}"),
}
}
#[test]
fn manual_ssh_command_format() {
let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host";
let escaped = key.replace('\'', "'\\''");
let cmd = format!(
"mkdir -p ~/.ssh && echo '{escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
);
assert!(cmd.starts_with("mkdir -p ~/.ssh && echo '"));
assert!(cmd.ends_with("chmod 600 ~/.ssh/authorized_keys"));
assert!(cmd.contains(">> ~/.ssh/authorized_keys"));
}
#[test]
fn manual_ssh_command_escapes_single_quotes() {
let key = "ssh-ed25519 AAAA it's a key user@host";
let escaped = key.replace('\'', "'\\''");
let cmd = format!("echo '{escaped}'");
assert!(!cmd.contains("it's"));
assert!(cmd.contains("it'\\''s"));
}
#[test]
fn uninstall_outcome_variants_are_distinct() {
assert_ne!(UninstallOutcome::Removed, UninstallOutcome::NotFound);
}
#[test]
fn uninstall_key_from_remote_rejects_missing_key() {
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
let result = rt.block_on(uninstall_key_from_remote(
Path::new("/nonexistent/key"),
"user@host",
&runner,
));
assert!(result.is_err());
match result.unwrap_err() {
Error::KeyNotFound(_) => {}
other => panic!("expected KeyNotFound, got: {other:?}"),
}
}
#[test]
fn uninstall_key_from_remote_rejects_missing_ssh() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pub_path = dir.path().join("id_ed25519.pub");
std::fs::write(&key_path, "private key").unwrap();
std::fs::write(
&pub_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
)
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
assert!(result.is_err());
match result.unwrap_err() {
Error::ToolNotFound(msg) => assert!(msg.contains("ssh")),
other => panic!("expected ToolNotFound, got: {other:?}"),
}
}
#[test]
fn uninstall_key_from_remote_returns_not_found() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pub_path = dir.path().join("id_ed25519.pub");
std::fs::write(&key_path, "private key").unwrap();
std::fs::write(
&pub_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
)
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
runner.set_tool_exists("ssh", true);
runner.push_run_response("ssh", Ok("NOTFOUND\n".to_owned()));
let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
assert_eq!(result.unwrap(), UninstallOutcome::NotFound);
}
#[test]
fn uninstall_key_from_remote_returns_removed() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pub_path = dir.path().join("id_ed25519.pub");
std::fs::write(&key_path, "private key").unwrap();
std::fs::write(
&pub_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
)
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
runner.set_tool_exists("ssh", true);
runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
runner.push_run_response("ssh", Ok(String::new()));
let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
assert_eq!(result.unwrap(), UninstallOutcome::Removed);
}
#[test]
fn uninstall_key_from_remote_propagates_ssh_error() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pub_path = dir.path().join("id_ed25519.pub");
std::fs::write(&key_path, "private key").unwrap();
std::fs::write(
&pub_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host\n",
)
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
runner.set_tool_exists("ssh", true);
runner.push_run_response("ssh", Ok("FOUND\n".to_owned()));
runner.push_run_response(
"ssh",
Err(Error::CommandFailed("connection refused".to_owned())),
);
let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
assert!(result.is_err());
match result.unwrap_err() {
Error::CommandFailed(msg) => assert!(msg.contains("connection refused")),
other => panic!("expected CommandFailed, got: {other:?}"),
}
}
#[test]
fn uninstall_command_uses_key_fingerprint_not_full_line() {
let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host";
let fingerprint: String = key
.split_whitespace()
.take(2)
.collect::<Vec<&str>>()
.join(" ");
assert_eq!(fingerprint, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI");
assert!(!fingerprint.contains("user@host"));
}
#[test]
fn uninstall_command_escapes_single_quotes_in_key() {
let key = "ssh-ed25519 AAA'A it's a key";
let fingerprint: String = key
.split_whitespace()
.take(2)
.collect::<Vec<&str>>()
.join(" ");
assert!(
fingerprint.contains('\''),
"fingerprint should contain a quote"
);
let escaped = fingerprint.replace('\'', "'\\''");
let cmd = format!("grep -vF '{escaped}' ~/.ssh/authorized_keys");
assert!(cmd.contains("AAA'\\''A"));
}
#[test]
fn uninstall_rejects_empty_pubkey() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("id_ed25519");
let pub_path = dir.path().join("id_ed25519.pub");
std::fs::write(&key_path, "private key").unwrap();
std::fs::write(&pub_path, "\n").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let runner = toride_ssh_core::MockCliRunner::new();
runner.set_tool_exists("ssh", true);
let result = rt.block_on(uninstall_key_from_remote(&key_path, "user@host", &runner));
assert!(result.is_err());
match result.unwrap_err() {
Error::CommandFailed(msg) => assert!(msg.contains("empty or malformed")),
other => panic!("expected CommandFailed, got: {other:?}"),
}
}
}