use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
const ED25519: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx cole@laptop\n";
const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhARsa+2Rd7Fw rsa@oldbox\n";
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("anvil-sshauto-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn run(server_uri: &str, ssh_dir: &Path, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_SSH_DIR", ssh_dir)
.env("ANVIL_CONFIG", ssh_dir.join("anvil-config.json"))
.output()
.expect("failed to run anvil binary")
}
fn stdout_json(out: &Output) -> Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
async fn ssh_key_server(existing: Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ssh_keys": existing})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "k9", "name": "uploaded", "fingerprint": "SHA256:server-side"
})))
.mount(&server)
.await;
server
}
async fn posted_body(server: &MockServer) -> Option<Value> {
let reqs: Vec<Request> = server.received_requests().await.unwrap();
reqs.into_iter()
.find(|r| r.method == wiremock::http::Method::POST)
.map(|r| serde_json::from_slice(&r.body).expect("POST body was not JSON"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn add_with_no_arguments_finds_and_uploads_the_existing_key() {
let dir = tmpdir("bare");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server).await.expect("a key must be uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true), "got: {v}");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_uploaded_key_is_the_modern_one_when_several_exist() {
let dir = tmpdir("prefer");
std::fs::write(dir.join("id_rsa.pub"), RSA).unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add", "--auto"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server).await.expect("a key must be uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"should have picked the Ed25519 key, uploaded: {body}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_key_name_defaults_to_its_comment() {
let dir = tmpdir("name");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(out.status.success());
let body = posted_body(&server).await.unwrap();
assert_eq!(body["name"], json!("cole@laptop"), "got: {body}");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_explicit_name_still_wins() {
let dir = tmpdir("explicitname");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(
&server.uri(),
&dir,
&["ssh-key", "add", "--name", "work-laptop"],
);
assert!(out.status.success());
let body = posted_body(&server).await.unwrap();
assert_eq!(body["name"], json!("work-laptop"), "got: {body}");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn re_running_add_does_not_upload_a_duplicate() {
let dir = tmpdir("dupe");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let fp = anvil::sshkeys::parse_public_key(ED25519)
.unwrap()
.fingerprint()
.unwrap();
let server = ssh_key_server(json!([{"id": "k1", "name": "laptop", "fingerprint": fp}])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
posted_body(&server).await.is_none(),
"an already-registered key must not be POSTed again"
);
let v = stdout_json(&out);
assert_eq!(v["already_registered"], json!(true), "got: {v}");
assert_eq!(v["ssh_key"]["id"], json!("k1"), "the key itself: {v}");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_private_key_passed_as_key_file_is_refused_before_any_upload() {
let dir = tmpdir("private");
let private = dir.join("id_ed25519");
std::fs::write(
&private,
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n",
)
.unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(
&server.uri(),
&dir,
&[
"ssh-key",
"add",
"--name",
"oops",
"--key-file",
private.to_str().unwrap(),
],
);
assert!(!out.status.success(), "must fail");
assert!(
posted_body(&server).await.is_none(),
"a private key must never be sent to the server"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "got: {v}");
assert!(
v["error"].as_str().unwrap().contains(".pub"),
"the error should point at the .pub file: {v}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn with_no_key_and_nobody_to_ask_it_explains_rather_than_generating() {
let dir = tmpdir("noprompt");
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(!out.status.success(), "must fail");
let v = stdout_json(&out);
let err = v["error"].as_str().unwrap();
assert!(err.contains("--yes"), "must name the escape hatch: {err}");
assert!(
!dir.join("id_ed25519").exists(),
"no key may be created without consent"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn yes_generates_a_key_and_registers_it() {
if which_ssh_keygen().is_none() {
eprintln!("skipping: ssh-keygen not installed");
return;
}
let dir = tmpdir("generate");
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add", "--yes"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(dir.join("id_ed25519").is_file(), "private key was created");
assert!(
dir.join("id_ed25519.pub").is_file(),
"public key was created"
);
let body = posted_body(&server).await.expect("the new key is uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_public_key_deleted_by_accident_is_recovered_not_replaced() {
let Some(_) = which_ssh_keygen() else {
eprintln!("skipping: ssh-keygen not installed");
return;
};
let dir = tmpdir("orphan");
let private = dir.join("id_ed25519");
let ok = std::process::Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-C", "orphan@test", "-f"])
.arg(&private)
.output()
.expect("ssh-keygen")
.status
.success();
assert!(ok, "ssh-keygen should have created the key");
std::fs::remove_file(dir.join("id_ed25519.pub")).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server)
.await
.expect("the recovered key is uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn certificates_and_known_hosts_are_not_mistaken_for_account_keys() {
let dir = tmpdir("noise");
std::fs::write(dir.join("id_ed25519-cert.pub"), ED25519).unwrap();
std::fs::write(dir.join("known_hosts"), "github.com ssh-rsa AAAAB3\n").unwrap();
std::fs::write(dir.join("config"), "Host *\n User git\n").unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
!out.status.success(),
"stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
posted_body(&server).await.is_none(),
"nothing may be uploaded"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_unreadable_private_key_reports_that_not_a_bogus_overwrite_error() {
let dir = tmpdir("locked");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----\nnot really a key\n-----END OPENSSH PRIVATE KEY-----\n",
)
.unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(!out.status.success(), "must fail");
let v = stdout_json(&out);
let err = v["error"].as_str().unwrap();
assert!(
!err.contains("refusing to overwrite"),
"must not blame an overwrite it never attempted: {err}"
);
assert!(
err.contains("public half"),
"should say what actually went wrong: {err}"
);
assert!(
err.contains("ssh-keygen -y"),
"should offer the recovery command: {err}"
);
assert!(posted_body(&server).await.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_key_written_with_extra_spacing_is_still_found() {
let dir = tmpdir("spacing");
let spaced = ED25519.trim_end().replace(' ', " ");
std::fs::write(dir.join("id_ed25519.pub"), format!("{spaced}\n")).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server)
.await
.expect("the key must be uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_corrupt_public_key_beside_a_good_private_one_is_recovered() {
if which_ssh_keygen().is_none() {
eprintln!("skipping: ssh-keygen not installed");
return;
}
let dir = tmpdir("corruptpub");
let private = dir.join("id_ed25519");
let ok = std::process::Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-C", "corrupt@test", "-f"])
.arg(&private)
.output()
.expect("ssh-keygen")
.status
.success();
assert!(ok);
std::fs::write(dir.join("id_ed25519.pub"), "ssh-ed25519 \n").unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(
out.status.success(),
"should recover from the private key; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server).await.expect("a key must be uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let v = stdout_json(&out);
assert_eq!(v["source"], json!("derived"), "got: {v}");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn generating_a_key_does_not_re_permission_an_existing_directory() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if which_ssh_keygen().is_none() {
eprintln!("skipping: ssh-keygen not installed");
return;
}
let dir = tmpdir("perms");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let before = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add", "--yes"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
after, before,
"an existing directory's mode must be left alone (was {before:o}, now {after:o})"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn both_outcomes_report_where_the_key_came_from() {
let dir = tmpdir("source");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["source"], json!("discovered"), "got: {v}");
assert_eq!(v["already_registered"], json!(false), "got: {v}");
assert!(
v["fingerprint"].as_str().is_some_and(|f| !f.is_empty()),
"a fingerprint must be reachable without knowing the server's nesting: {v}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn yes_takes_the_best_key_rather_than_asking_which() {
let dir = tmpdir("yespick");
std::fs::write(dir.join("id_rsa.pub"), RSA).unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
let server = ssh_key_server(json!([])).await;
let out = run(&server.uri(), &dir, &["ssh-key", "add", "--yes"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let body = posted_body(&server).await.expect("a key must be uploaded");
assert!(
body["public_key"]
.as_str()
.unwrap()
.starts_with("ssh-ed25519 "),
"uploaded: {body}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn which_ssh_keygen() -> Option<()> {
std::process::Command::new("ssh-keygen")
.arg("-?")
.output()
.ok()
.map(|_| ())
}