ref:main
//! End-to-end tests for `anvil ssh-key add`'s automatic mode.
//!
//! Each test runs the real binary against a wiremock server with `ANVIL_SSH_DIR`
//! pointed at a throwaway directory, so nothing here reads or writes the
//! developer's real `~/.ssh` and nothing reaches a real Anvil account.
//!
//! All of these run with `--json`, which also makes them non-interactive: the
//! command must never block on a prompt when stdout is a data channel.
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};
/// Real Ed25519 and RSA public keys (their private halves were discarded).
/// Real ones matter: the command computes an OpenSSH SHA256 fingerprint from
/// the blob, so a fake base64 string would not exercise the same path.
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
}
/// Run the real binary with `--json`, with `~/.ssh` redirected to `ssh_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)
// Isolate the config file too, so a developer's real credentials and
// default repo can never influence the result.
.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),
)
})
}
/// A server that reports `existing` keys and accepts a POST of a new one.
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
}
/// The body of the single POST the run made, or None if it made none.
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() {
// The headline behaviour: a user who already has a key types four words
// and is done.
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() {
// A machine with an old id_rsa alongside a new id_ed25519 is the common
// case; silently registering the RSA key would be a downgrade.
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() {
// So the entry in `ssh-key list` is recognisable without the user having
// been made to invent a label.
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() {
// `--auto` invites being run twice (in a setup script, say). The second
// run must be a cheap no-op, not a pile of identical keys on the account.
let dir = tmpdir("dupe");
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
// Fingerprint of ED25519 as OpenSSH computes it — the value the server
// would already be holding after a first upload.
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() {
// The worst possible outcome for this command is shipping the private key
// to the server. `--key-file ~/.ssh/id_ed25519` (no .pub) is an easy typo.
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() {
// Minting a credential is not something to do silently inside a script.
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() {
// Generating a second key here would be wrong, and ssh-keygen would refuse
// to overwrite the private key anyway — so the user would just be stuck.
let Some(_) = which_ssh_keygen() else {
eprintln!("skipping: ssh-keygen not installed");
return;
};
let dir = tmpdir("orphan");
let private = dir.join("id_ed25519");
// Make a real key pair, then delete the public half.
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() {
// ~/.ssh is full of files that look key-shaped. Uploading a certificate
// grants no access, and would look like it had worked.
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"]);
// No real key present -> it must reach the "nothing to use" path, not
// upload the certificate.
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() {
// A passphrase-protected key with nobody to type the passphrase used to
// fall through to generation, which targeted that same path and died with
// "already exists — refusing to overwrite it" — a message about entirely
// the wrong problem.
let dir = tmpdir("locked");
// Not a real key, so `ssh-keygen -y` fails exactly as it would on a
// passphrase-protected one with no terminal.
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() {
// Discovering nothing here would send the user down the "generate a key"
// path when they already have a perfectly good one.
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() {
// The wedge one branch over from the passphrase case: a `.pub` that exists
// but doesn't parse made discovery empty *and* hid the private key from
// the orphan scan, so the run fell through to generation, targeted the
// private key that was already there, and died on "already exists".
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);
// Truncate the public half to something unparseable.
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() {
// The target directory is whatever the user typed at the prompt. Answering
// `~/anvil_key` must not chmod their home directory to 0700.
#[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() {
// `source` is the only way a script can tell "used the key you had" from
// "generated one for you", so it must not be present on just one path.
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() {
// `--yes` says "don't prompt". It previously only had that effect when
// stdin was not a terminal — so a bootstrap script run from an interactive
// shell blocked on the "Which key should Anvil use?" select.
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(|_| ())
}