ref:526a4489daa9147204157602455b6376d4c78c37

Clone with the login token, and add SSH keys automatically (#59)

`anvil auth login` did not get you a working checkout. Two gaps: **`repo clone` only ever spoke SSH.** It built `git@host:org/repo.git` unconditionally, so a freshly logged-in user was rejected by git with "Permission denied (publickey)" — a message naming neither Anvil nor the fix. The server accepts the login token for git-over-HTTPS (Basic auth, password = PAT, username ignored), so that credential existed the whole time and was never offered. Clone now defaults to HTTPS with the login token. `--ssh` opts back in. The token is **not** written into the clone: - not in the remote URL (that persists a secret into `.git/config` and survives `auth rotate`), - not in `-c http.extraHeader` (that exposes it in argv to every process on the machine). Instead git is pointed at the `anvil` binary as a credential helper, scoped to the configured server so the token is never offered to another host. The helper is written into the new clone too, so `git pull`/`git push` keep working without a prompt. New hidden subcommand: `anvil auth git-credential`. **`ssh-key add` required `--name` and `--key-file`.** Now bare `anvil ssh-key add` (or `--auto`): - finds the best key in `~/.ssh` — Ed25519 > ECDSA > RSA > DSA, stable ordering, skipping certificates and `known_hosts`; - names it from the key's own comment, else `user@host`; - skips the upload when the fingerprint is already registered, so it is safe to re-run from a setup script; - recovers a deleted public half via `ssh-keygen -y` instead of generating a second key beside the good one; - with no key and a terminal, walks through creating one (type, path, comment; `ssh-keygen` owns the passphrase prompt so it never touches our argv or memory); - with no terminal, refuses unless `--yes` — minting a credential silently inside a script is not a default. Public keys are parsed and fingerprinted in-process (SHA256, OpenSSH display form). That parser is also what refuses to upload a private key passed by mistake. ## Verification Against the live server: - fresh clone of `fangorn/faraday` authenticates with the login token alone, no SSH key involved; - `.git/config` holds a plain remote URL and the scoped helper — no token; - subsequent `git fetch` succeeds with `GIT_TERMINAL_PROMPT=0`, proving the helper answers the 401 unattended; - the helper emits zero bytes for a `github.com` challenge; - live discovery on a machine holding both key types picks `~/.ssh/id_ed25519.pub`. 699 tests pass (up from 684); clippy clean under `-D warnings`; `cargo fmt --check` clean. New: 10 end-to-end tests for the auto path against a mock server with a redirected `~/.ssh`, plus unit tests for key parsing/fingerprinting/discovery and the credential-helper protocol. Backwards compatible: the old `ssh-key add --name X --key-file Y` form still works, and the clone JSON still carries `ssh_url`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SHA: 526a4489daa9147204157602455b6376d4c78c37
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-08-06 03:55
Parents: 09ed84e
11 files changed +2462 -28
Type
Cargo.lock +1 −0
@@ -74,6 +74,7 @@
name = "anvil-cli"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"clap",
"colored",
Cargo.toml +1 −0
@@ -32,6 +32,7 @@
futures = "0.3"
glob = "0.3"
sha2 = "0.10"
base64 = "0.22"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
src/commands/auth.rs +24 −0
@@ -57,6 +57,17 @@
},
/// Log out and remove stored credentials
Logout,
/// Git credential helper (invoked by git, not by hand)
///
/// Answers git's credential protocol on stdin/stdout with the stored login
/// token, so `git` can authenticate to Anvil over HTTPS without the token
/// ever being written into a repository's config. `anvil repo clone` wires
/// this into the clones it creates.
#[command(name = "git-credential", hide = true)]
GitCredential {
/// One of git's operations: get, store, or erase.
operation: String,
},
}
pub async fn run(args: AuthArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
@@ -69,7 +80,20 @@
AuthCommand::Status => status().await,
AuthCommand::Rotate { yes } => rotate(yes).await,
AuthCommand::Logout => logout().await,
AuthCommand::GitCredential { operation } => git_credential(&operation),
}
}
/// Serve git's credential protocol.
///
/// stdout belongs to git here, so this writes the credential itself and returns
/// an empty response — none of the `output::` helpers may print, and under
/// `--json` (which git never passes) the empty envelope is still well-formed.
fn git_credential(operation: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
crate::gitcreds::serve(operation, stdin.lock(), &mut stdout)?;
Ok(output::Response::done())
}
/// Try to open `url` in the user's default browser. Returns true on success.
src/commands/repo.rs +277 −15
@@ -41,12 +41,15 @@
#[arg(long, default_value = "private")]
visibility: String,
},
/// Clone a repository via SSH
/// Clone a repository, authenticating with your `anvil auth login` token
Clone {
/// Repository (org/repo)
repo: String,
/// Local directory name
dir: Option<String>,
/// Clone over SSH instead, using the keys in your ssh-agent/~/.ssh
#[arg(long)]
ssh: bool,
},
/// Set the default repository for commands
SetDefault {
@@ -85,7 +88,7 @@
description,
visibility,
} => create(&name, org.as_deref(), &description, &visibility).await,
RepoCommand::Clone { repo, dir } => clone(&repo, dir.as_deref()).await,
RepoCommand::Clone { repo, dir, ssh } => clone(&repo, dir.as_deref(), ssh).await,
RepoCommand::SetDefault { repo } => set_default(&repo).await,
}
}
@@ -238,43 +241,200 @@
Ok(output::Response::ok("repo", resp))
}
/// Clone a repository.
///
/// HTTPS with the login token is the default, because that credential always
/// exists after `anvil auth login` — an SSH-only clone made a working login
/// insufficient and failed with git's own "Permission denied (publickey)",
/// which says nothing about Anvil. SSH is still one flag away for anyone who
/// prefers it.
///
/// The token is never written into the clone: git gets it through
/// [`crate::gitcreds`], and what lands in `.git/config` is a plain remote URL
/// plus the command to ask this binary again later — so `git pull`/`git push`
/// in the clone keep working, and `anvil auth rotate` doesn't strand it.
async fn clone(
repo: &str,
dir: Option<&str>,
ssh: bool,
) -> Result<output::Response, Box<dyn std::error::Error>> {
// Reject a malformed `org/repo` here rather than letting git fail on a URL
// built from it.
let (org, name) = config::parse_org_repo(repo)?;
let config = Config::load()?;
// Normalize away a redundant `:443`/`:80` so the URL git clones, the config
// key scoping the helper, and the host git reports back to the helper all
// use one spelling — otherwise the helper declines its own clone.
let server = crate::gitcreds::normalize_server_url(config.server_url());
let host = url::Url::parse(&server)
.ok()
.and_then(|u| u.host_str().map(String::from))
.unwrap_or_else(|| "localhost".to_string());
let ssh_url = format!("git@{host}:{org}/{name}.git");
let https_url = format!("{server}/{org}/{name}.git");
// `parse_org_repo` splits on the first slash only, so `name` can still hold
// slashes; clone into the last segment rather than a nested path.
let server = config.server_url();
let default_target = name.rsplit('/').next().unwrap_or(&name).to_string();
let target = dir.unwrap_or(&default_target).to_string();
let has_token = config.token.as_deref().is_some_and(|t| !t.is_empty());
let clone_url = if ssh { &ssh_url } else { &https_url };
// Parse server URL to get hostname for SSH
let url = url::Url::parse(server)?;
let host = url.host_str().unwrap_or("localhost");
let ssh_url = format!("git@{host}:{repo}.git");
let target = dir.unwrap_or_else(|| repo.rsplit('/').next().unwrap_or(repo));
output::info(&format!("Cloning {clone_url} into {target}/"));
let mut cmd = std::process::Command::new("git");
output::info(&format!("Cloning {ssh_url} into {target}/"));
// Over HTTPS with a token, register ourselves as git's credential helper
// for this server only. `git -c` (before the subcommand) is transient — it
// is NOT persisted into the new repo, unlike `git clone -c` — so the same
// config is written into the clone explicitly after it succeeds.
let helper = (!ssh && has_token)
.then(|| crate::gitcreds::helper_config_key(&server))
.flatten()
.map(|key| (key, crate::gitcreds::helper_command()));
if let Some((ref key, ref command)) = helper {
// The empty value first clears any helper the user's global config
// already binds to this host, so the answer is deterministically ours.
cmd.arg("-c").arg(format!("{key}="));
cmd.arg("-c").arg(format!("{key}={command}"));
}
// Never let git run its own `Username for 'https://…':` prompt.
//
// Non-interactively it would hang forever instead of reporting the failure.
// Interactively it is worse in a subtler way: it is exactly the raw-git
// credential experience this command exists to replace, and it makes the
// "you are not logged in" diagnosis below unreachable — the user would be
// typing credentials into git while the CLI, which has a login flow right
// there, said nothing. Anonymous cloning of a public repo is unaffected:
// git only prompts once the server has demanded auth.
//
// SSH is left alone — `ssh` handles its own key passphrase prompt, which is
// legitimate and not git's credential prompt.
if !ssh {
cmd.env("GIT_TERMINAL_PROMPT", "0");
}
// git's own progress goes to stderr, which is safe under --json.
let status = cmd.args(["clone", clone_url, &target]).status()?;
let status = std::process::Command::new("git")
.args(["clone", &ssh_url, target])
.status()?;
if !status.success() {
return Err(clone_failure_message(status, ssh, has_token, &server).into());
}
// Persist the helper so fetch/pull/push in the clone authenticate too —
// but only when the token lives in the config file, which is what the
// helper will read from later. A token that came from `ANVIL_TOKEN` is
// gone by the next shell, so persisting would leave a helper that always
// declines *and* a reset entry that suppresses whatever credential manager
// the user has configured globally: strictly worse than writing nothing.
// The clone itself still authenticated, via the transient `-c` flags.
let token_is_durable = Config::load_from_disk()
.ok()
.and_then(|c| c.token)
.is_some_and(|t| !t.is_empty());
if let Some((key, command)) = helper {
if !token_is_durable {
output::warn(
"Cloned using ANVIL_TOKEN from the environment. Future fetches in this \
clone will need it set too — run `anvil auth login` to store a token \
on disk instead.",
);
} else if let Err(e) = persist_credential_helper(&target, &key, &command) {
// Not fatal: the clone succeeded and the user can still work. Say
// so rather than making it look like the clone failed.
output::warn(&format!(
"Cloned, but could not configure git credentials for future \
fetches: {e}"
));
return Err(format!("git clone exited with status {status}").into());
}
}
output::success(&format!("Cloned {repo} into {target}/"));
output::success(&format!("Cloned {org}/{name} into {target}/"));
Ok(output::Response::ok(
"repo",
serde_json::json!({
"repo": repo,
"repo": format!("{org}/{name}"),
"clone_url": clone_url,
"protocol": if ssh { "ssh" } else { "https" },
// Kept for callers that read `ssh_url` from the pre-HTTPS output.
"ssh_url": ssh_url,
"https_url": https_url,
"dir": target,
}),
))
}
/// Write the credential helper into the fresh clone's own config.
///
/// The two writes mirror the two `-c` flags used during the clone — reset the
/// helper list for this host, then add ours — and must land together. A lone
/// reset entry is not "unconfigured": to git it means *no helper at all for
/// this host*, which would actively disable whatever keychain or manager the
/// user has in their global config. So if the second write fails, the first is
/// rolled back and the clone is left exactly as git made it.
fn persist_credential_helper(
dir: &str,
key: &str,
command: &str,
) -> Result<(), Box<dyn std::error::Error>> {
git_config(&["-C", dir, "config", "--local", key, ""])?;
if let Err(e) = git_config(&["-C", dir, "config", "--local", "--add", key, command]) {
// Best-effort: if even the rollback fails there is nothing further we
// can do, and the caller's warning is already on its way.
let _ = git_config(&["-C", dir, "config", "--local", "--unset-all", key]);
return Err(e);
}
Ok(())
}
/// Run `git config …`, turning a non-zero exit into its stderr.
fn git_config(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let out = std::process::Command::new("git").args(args).output()?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr)
.trim()
.to_string()
.into());
}
Ok(())
}
/// Turn a failed `git clone` into a message that names the likely cause.
///
/// git reports "Permission denied (publickey)" or "Authentication failed",
/// neither of which tells an Anvil user what to do next. This does.
fn clone_failure_message(
status: std::process::ExitStatus,
ssh: bool,
has_token: bool,
server: &str,
) -> String {
let base = format!("git clone exited with status {status}");
if ssh {
return format!(
"{base}\n\nThis was an SSH clone. If the key was rejected, register one with \
`anvil ssh-key add --auto`, or drop --ssh to clone over HTTPS with your login token."
);
}
if !has_token {
return format!(
"{base}\n\nYou are not logged in, so the clone was anonymous and only public \
repositories are readable. Run `anvil auth login` and try again."
);
}
format!(
"{base}\n\nYour login token was offered to {server}. If it was rejected, the token may \
have expired — run `anvil auth login` again. If the repository exists but you cannot \
read it, ask an org admin for access."
)
}
async fn set_default(repo: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
// Validate format
config::resolve_repo(Some(repo))?;
@@ -289,4 +449,106 @@
"default_repo",
serde_json::Value::String(repo.to_string()),
))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct RepoCli {
#[command(subcommand)]
command: RepoCommand,
}
fn parse(args: &[&str]) -> RepoCommand {
RepoCli::try_parse_from(args).expect("parse").command
}
#[test]
fn clone_defaults_to_https_and_opts_in_to_ssh() {
match parse(&["clone", "acme/widget"]) {
RepoCommand::Clone { ssh, dir, .. } => {
assert!(!ssh, "HTTPS with the login token is the default");
assert!(dir.is_none());
}
_ => panic!("expected Clone"),
}
match parse(&["clone", "acme/widget", "--ssh"]) {
RepoCommand::Clone { ssh, .. } => assert!(ssh),
_ => panic!("expected Clone"),
}
}
#[test]
fn the_default_target_directory_is_the_last_path_segment() {
// `parse_org_repo` splits on the first slash only, so a mistyped
// `a/b/c` leaves `name` as `b/c`. Using that verbatim cloned into a
// nested ./b/c, where the pre-HTTPS code produced ./c.
assert_eq!(default_target_for("widget"), "widget");
assert_eq!(default_target_for("team/widget"), "widget");
}
/// Mirrors the target derivation in `clone`.
fn default_target_for(name: &str) -> String {
name.rsplit('/').next().unwrap_or(name).to_string()
}
#[test]
fn clone_still_takes_an_optional_target_directory() {
match parse(&["clone", "acme/widget", "somewhere"]) {
RepoCommand::Clone { dir, .. } => assert_eq!(dir.as_deref(), Some("somewhere")),
_ => panic!("expected Clone"),
}
}
/// An `ExitStatus` we can hand to `clone_failure_message`. Its `Display` is
/// all that matters here, so any failing command will do.
fn failed_status() -> std::process::ExitStatus {
std::process::Command::new("git")
.args(["rev-parse", "--definitely-not-a-flag"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("git should be installed")
}
// These messages are the whole point of the change: git's own
// "Permission denied (publickey)" is what sent the user looking for SSH
// keys they never needed.
#[test]
fn a_failed_anonymous_clone_says_to_log_in() {
let msg = clone_failure_message(failed_status(), false, false, "https://anvil.test");
assert!(msg.contains("anvil auth login"), "got: {msg}");
assert!(
msg.contains("public"),
"should explain the anonymous case: {msg}"
);
}
#[test]
fn a_failed_authenticated_clone_blames_the_token_or_access_not_ssh() {
let msg = clone_failure_message(failed_status(), false, true, "https://anvil.test");
assert!(
msg.contains("https://anvil.test"),
"must name the server: {msg}"
);
assert!(
!msg.contains("ssh-key"),
"must not send an HTTPS user to fix SSH keys: {msg}"
);
}
#[test]
fn a_failed_ssh_clone_offers_both_ways_out() {
let msg = clone_failure_message(failed_status(), true, true, "https://anvil.test");
assert!(msg.contains("anvil ssh-key add --auto"), "got: {msg}");
assert!(
msg.contains("--ssh"),
"should mention dropping the flag: {msg}"
);
}
}
src/commands/ssh_key.rs +440 −12
@@ -1,7 +1,9 @@
use crate::client::Client;
use crate::output;
use crate::sshkeys::{self, FoundKey, KeyType, PublicKey};
use clap::{Args, Subcommand};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct SshKeyArgs {
@@ -14,13 +16,24 @@
/// List your SSH keys
List,
/// Add an SSH key
///
/// With no arguments (or `--auto`) this finds the best public key in
/// `~/.ssh` and uploads it, offering to create one if you have none.
Add {
/// Key name/label
/// Key name/label (default: the key's comment, else user@hostname)
#[arg(long)]
name: Option<String>,
name: String,
/// Path to public key file (e.g., ~/.ssh/id_ed25519.pub)
#[arg(long)]
key_file: Option<String>,
/// Find (or create) a key in ~/.ssh without being told which file.
/// Implied when --key-file is omitted.
#[arg(long, conflicts_with = "key_file")]
auto: bool,
/// Don't prompt: pick the best existing key, and generate a
key_file: String,
/// passphrase-less Ed25519 key if there is none.
#[arg(long, short = 'y')]
yes: bool,
},
/// Remove an SSH key
Remove {
@@ -41,7 +54,12 @@
pub async fn run(args: SshKeyArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
match args.command {
SshKeyCommand::List => list().await,
SshKeyCommand::Add { name, key_file } => add(&name, &key_file).await,
SshKeyCommand::Add {
name,
key_file,
auto,
yes,
} => add(name.as_deref(), key_file.as_deref(), auto, yes).await,
SshKeyCommand::Remove { id } => remove(&id).await,
}
}
@@ -81,28 +99,438 @@
Ok(output::Response::items(items))
}
/// Whether we may ask the user questions: a terminal on stdin, and not in
/// `--json` mode (where stdout is a data channel and the caller is a script).
fn can_prompt() -> bool {
!output::is_json() && std::io::IsTerminal::is_terminal(&std::io::stdin())
}
/// Where the key to upload came from — kept for the JSON payload so a script
/// can tell "used the key you already had" from "made you a new one".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source {
/// An explicit `--key-file`.
Explicit,
/// Discovered in `~/.ssh`.
Discovered,
/// Reconstructed from a private key whose `.pub` had gone missing.
Derived,
/// Created by us, just now.
Generated,
}
impl Source {
fn as_str(&self) -> &'static str {
match self {
Source::Explicit => "explicit",
Source::Discovered => "discovered",
Source::Derived => "derived",
Source::Generated => "generated",
}
}
/// Label for the path shown next to the fingerprint.
async fn add(name: &str, key_file: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
let public_key = std::fs::read_to_string(key_file)
.map_err(|e| format!("Failed to read key file '{}': {}", key_file, e))?;
///
/// On the derived path that path is the *private* key — the public half is
/// what we computed from it. Printing "Key: ~/.ssh/id_ed25519" there reads
/// as "we uploaded your private key", which is precisely the fear this
/// command should not be feeding.
fn path_label(&self) -> &'static str {
match self {
Source::Derived => "Derived from",
_ => "Key",
}
}
}
async fn add(
name: Option<&str>,
key_file: Option<&str>,
_auto: bool,
yes: bool,
) -> Result<output::Response, Box<dyn std::error::Error>> {
// `--auto` is accepted for explicitness but changes nothing: naming a file
// selects it, and not naming one means "work it out", which is the whole
// point. Requiring the flag would just be a second way to say the same
// thing — and a way to get an error instead of a working key.
let (key, origin, source) = match key_file {
Some(path) => {
let expanded = expand_tilde(path);
let contents = std::fs::read_to_string(&expanded)
.map_err(|e| format!("Failed to read key file '{path}': {e}"))?;
let key = sshkeys::parse_public_key(&contents).ok_or_else(|| {
format!(
"'{path}' is not an OpenSSH public key. Point --key-file at the \
.pub file (e.g. ~/.ssh/id_ed25519.pub), not the private key."
)
})?;
(key, Some(expanded), Source::Explicit)
}
None => resolve_key_automatically(yes)?,
};
let client = Client::from_config()?;
let fingerprint = key.fingerprint();
// Uploading a key the account already has is a no-op server-side at best
// and a confusing duplicate at worst, so check first. A failure to list is
// not fatal — the POST is still the source of truth.
if let Some(ref fp) = fingerprint {
if let Some(existing) = find_existing(&client, fp).await {
let label = existing
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("(unnamed)");
output::success(&format!("This key is already on your account as '{label}'"));
output::detail("Fingerprint", fp);
if let Some(ref p) = origin {
output::detail(source.path_label(), &p.display().to_string());
}
// Same envelope as the upload path below: `ssh_key` is the key
// object, and the fields a caller needs to tell the two outcomes
// apart sit beside it. Diverging here would mean a script that
// reads `.ssh_key.fingerprint` worked only on one of them.
return Ok(output::Response::read(serde_json::json!({
"ok": true,
"ssh_key": existing,
"already_registered": true,
"fingerprint": fp,
"source": source.as_str(),
})));
}
}
// Naming precedence: what the user asked for, else the key's own comment
// (usually already `user@host`), else user@host. The point is that the key
// is identifiable in `ssh-key list` without anyone inventing a label.
let name = name
.map(str::to_string)
.or_else(|| key.comment.clone())
.unwrap_or_else(sshkeys::default_label);
let resp: serde_json::Value = client
.post(
"/user/ssh-keys",
&serde_json::json!({"name": name, "public_key": public_key.trim()}),
&serde_json::json!({"name": name, "public_key": key.to_line()}),
)
.await?;
let fingerprint = resp
let server_fingerprint = resp
.get("fingerprint")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
.map(str::to_string)
.or_else(|| {
resp.pointer("/ssh_key/fingerprint")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.or(fingerprint)
.unwrap_or_else(|| "unknown".to_string());
output::success(&format!("Added SSH key '{name}'"));
output::detail("Fingerprint", &server_fingerprint);
if let Some(ref p) = origin {
output::detail(source.path_label(), &p.display().to_string());
}
// `ssh_key` still echoes the server body verbatim (unchanged from before
// this command grew an automatic mode). `source` and `already_registered`
// are ours, and are the only way a script can tell a key it already had
// from one we just generated for it — so they must be present on both
// outcomes, not just the duplicate one. `fingerprint` is hoisted because
// its depth inside `ssh_key` depends on how the server wraps its payload.
Ok(output::Response::read(serde_json::json!({
"ok": true,
"ssh_key": resp,
"already_registered": false,
"fingerprint": server_fingerprint,
"source": source.as_str(),
})))
output::detail("Fingerprint", fingerprint);
Ok(output::Response::ok("ssh_key", resp))
}
/// Find the key to upload without being told where it is: use what's in
/// `~/.ssh`, recover a public half that's been deleted, or create one.
fn resolve_key_automatically(
yes: bool,
) -> Result<(PublicKey, Option<PathBuf>, Source), Box<dyn std::error::Error>> {
let dir = sshkeys::ssh_dir();
let found = sshkeys::discover(&dir);
if !found.is_empty() {
let chosen = choose_key(&found, yes)?;
output::info(&format!("Using {}", chosen.path.display()));
return Ok((
chosen.key.clone(),
Some(chosen.path.clone()),
Source::Discovered,
));
}
// No `.pub` file, but maybe a private key whose public half was deleted.
// Generating a *second* key here would be the wrong answer — and would
// fail anyway, since ssh-keygen won't overwrite the private key.
let orphans = sshkeys::private_keys_without_usable_public(&dir);
let mut failures: Vec<String> = Vec::new();
for private in &orphans {
match sshkeys::derive_public_key(private) {
Ok(key) => {
output::info(&format!(
"Recovered the public key for {}",
private.display()
));
return Ok((key, Some(private.clone()), Source::Derived));
}
// Usually a passphrase-protected key with nobody to type the
// passphrase in.
Err(e) => failures.push(e),
}
}
// Every private key we found refused to give up its public half. Falling
// through to generation would target one of those same paths and die on
// `generate`'s own "already exists" guard — a message about the wrong
// problem entirely. Report what actually went wrong instead.
if !failures.is_empty() {
return Err(format!(
"Found a private key in {} but could not read its public half:\n {}\n\n\
If the key has a passphrase, run this in a terminal so ssh-keygen can ask \
for it, or restore the .pub file with:\n ssh-keygen -y -f {} > {}.pub",
dir.display(),
failures.join("\n "),
orphans[0].display(),
orphans[0].display(),
)
.into());
}
let (key, path) = generate_interactively(&dir, yes)?;
Ok((key, Some(path), Source::Generated))
}
/// Pick among several discovered keys. With one key, with `--yes`, or with
/// nobody to ask, take the best-ranked one — `discover` has already sorted them.
///
/// `yes` has to be honoured even when a terminal is present: a bootstrap script
/// run from an interactive shell is the exact case the flag exists for, and it
/// would otherwise block here on a machine that happens to have two keys.
fn choose_key(found: &[FoundKey], yes: bool) -> Result<&FoundKey, Box<dyn std::error::Error>> {
if found.len() == 1 || yes || !can_prompt() {
return Ok(&found[0]);
}
let labels: Vec<String> = found
.iter()
.map(|f| {
let name = f
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(key)");
match f.key.comment {
Some(ref c) => format!("{name} ({}, {c})", f.key.algo),
None => format!("{name} ({})", f.key.algo),
}
})
.collect();
let idx = dialoguer::Select::new()
.with_prompt("Which key should Anvil use?")
.items(&labels)
.default(0)
.interact()?;
Ok(&found[idx])
}
/// Create a key pair, asking the questions that have a real choice behind them
/// and defaulting the rest.
fn generate_interactively(
dir: &Path,
yes: bool,
) -> Result<(PublicKey, PathBuf), Box<dyn std::error::Error>> {
let comment = sshkeys::default_label();
// `--yes` means "ask me nothing", terminal or not — otherwise the flag does
// nothing at all in an interactive shell, which is where bootstrap scripts
// usually run.
if yes || !can_prompt() {
// Nobody to ask, and no permission to decide for them. Silently
// creating a credential is not something to do behind a script's back.
if !yes {
return Err(format!(
"No SSH key found in {}. Re-run in a terminal to be walked through \
creating one, or pass --yes to generate an Ed25519 key with no \
passphrase automatically.",
dir.display()
)
.into());
}
let path = dir.join(KeyType::Ed25519.default_filename());
output::warn(&format!(
"Generating {} with no passphrase (--yes).",
path.display()
));
let key = sshkeys::generate(&path, KeyType::Ed25519, &comment, Some(""))?;
return Ok((key, path));
}
output::line(&format!(
"\nNo SSH key found in {}. Let's create one.",
dir.display()
));
let types = [
"Ed25519 (recommended — small, fast, modern)",
"RSA 4096 (only if something old needs it)",
];
let key_type = match dialoguer::Select::new()
.with_prompt("Key type")
.items(&types)
.default(0)
.interact()?
{
1 => KeyType::Rsa4096,
_ => KeyType::Ed25519,
};
let default_path = dir.join(key_type.default_filename());
let path: String = dialoguer::Input::new()
.with_prompt("File")
.default(default_path.display().to_string())
.interact_text()?;
let path = expand_tilde(&path);
let comment: String = dialoguer::Input::new()
.with_prompt("Comment (how you'll recognise this key)")
.default(comment)
.interact_text()?;
// Passing `None` leaves the passphrase to ssh-keygen's own prompt, so it is
// never in our argv or memory — and its "leave empty for no passphrase"
// behaviour is what people already expect.
output::line("\nssh-keygen will ask for a passphrase — press Enter twice for none.\n");
let key = sshkeys::generate(&path, key_type, &comment, None)?;
output::success(&format!("Created {}", path.display()));
Ok((key, path))
}
/// Look for `fingerprint` among the account's existing keys.
///
/// Returns `None` on any listing failure: not being able to check is a reason
/// to go ahead and POST (the server decides), not a reason to abort.
async fn find_existing(client: &Client, fingerprint: &str) -> Option<serde_json::Value> {
let resp: serde_json::Value = client.get("/user/ssh-keys").await.ok()?;
resp.get("ssh_keys")?
.as_array()?
.iter()
.find(|k| k.get("fingerprint").and_then(|v| v.as_str()) == Some(fingerprint))
.cloned()
}
/// Expand a leading `~/` against the home directory.
///
/// Users type `~/.ssh/id_ed25519.pub`, and the shell only expands it when it is
/// unquoted — so `--key-file "~/.ssh/id_ed25519.pub"` used to fail with a
/// confusing "no such file".
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(path)
}
async fn remove(id: &str) -> Result<output::Response, Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/user/ssh-keys/{id}")).await?;
output::success(&format!("Removed SSH key {id}"));
Ok(output::Response::deleted(id))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct SshKeyCli {
#[command(subcommand)]
command: SshKeyCommand,
}
fn parse(args: &[&str]) -> SshKeyCommand {
SshKeyCli::try_parse_from(args).expect("parse").command
}
#[test]
fn add_needs_no_arguments_at_all() {
// The point of the change: `anvil ssh-key add` must be a complete
// command, not a usage error.
match parse(&["add"]) {
SshKeyCommand::Add {
name,
key_file,
auto,
yes,
} => {
assert!(name.is_none());
assert!(key_file.is_none());
assert!(!auto);
assert!(!yes);
}
_ => panic!("expected Add"),
}
}
#[test]
fn add_still_accepts_the_old_explicit_form() {
// Existing scripts pass --name and --key-file; they must keep working.
match parse(&["add", "--name", "laptop", "--key-file", "/tmp/k.pub"]) {
SshKeyCommand::Add { name, key_file, .. } => {
assert_eq!(name.as_deref(), Some("laptop"));
assert_eq!(key_file.as_deref(), Some("/tmp/k.pub"));
}
_ => panic!("expected Add"),
}
}
#[test]
fn add_parses_auto_and_yes() {
match parse(&["add", "--auto", "--yes"]) {
SshKeyCommand::Add { auto, yes, .. } => {
assert!(auto);
assert!(yes);
}
_ => panic!("expected Add"),
}
}
#[test]
fn tilde_expands_to_the_home_directory() {
let home = dirs::home_dir().expect("a home directory");
assert_eq!(
expand_tilde("~/.ssh/id_ed25519.pub"),
home.join(".ssh/id_ed25519.pub")
);
assert_eq!(expand_tilde("~"), home);
}
#[test]
fn a_path_without_a_tilde_is_left_alone() {
assert_eq!(
expand_tilde("/etc/keys/k.pub"),
PathBuf::from("/etc/keys/k.pub")
);
assert_eq!(
expand_tilde("relative/k.pub"),
PathBuf::from("relative/k.pub")
);
// A tilde that isn't a home reference must not be mangled.
assert_eq!(expand_tilde("~weird"), PathBuf::from("~weird"));
}
}
src/config.rs +9 −1
@@ -190,7 +190,15 @@
Err(ConfigError::NotLoggedIn)
}
fn parse_org_repo(s: &str) -> Result<(String, String), ConfigError> {
/// Split an `org/repo` string on its **first** slash, requiring both sides to
/// be non-empty.
///
/// Note this is a split, not a validation of shape: `a/b/c` yields org `a` and
/// name `b/c`. That is deliberate — it is also how remote-URL paths are parsed
/// (see `parse_remote_url`) — but it means callers building a URL from the
/// result inherit whatever the user typed rather than getting an up-front
/// rejection.
pub fn parse_org_repo(s: &str) -> Result<(String, String), ConfigError> {
let parts: Vec<&str> = s.splitn(2, '/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(ConfigError::Io(std::io::Error::new(
src/gitcreds.rs +482 −0
@@ -1,0 +1,482 @@
//! Git credential-helper plumbing: how `anvil` hands its login token to `git`.
//!
//! Anvil serves git over HTTPS with Basic auth, where the *password* is the same
//! `anvil_…` token `anvil auth login` already stored and the username is
//! ignored. So the CLI has everything git needs — the problem is only getting it
//! there without writing the secret somewhere it doesn't belong.
//!
//! Two obvious approaches are rejected here:
//!
//! * **Token in the remote URL** (`https://x-token:TOKEN@host/org/repo.git`).
//! `git clone` persists the remote into `.git/config`, so the token ends up
//! in a world-readable file in every clone, survives `auth rotate`, and gets
//! copied into any bug report that includes a git config.
//! * **Token on git's command line** (`-c http.extraHeader=…`). Every process
//! on the machine can read another process's argv via `/proc`.
//!
//! Instead git is pointed at *this binary* as a credential helper. The token
//! stays in the 0600 config file, is read only when git actually gets a 401, and
//! the only thing written into a clone's config is the command to run.
use crate::config::Config;
use std::io::{BufRead, Write};
/// The Basic-auth username sent alongside the token.
///
/// The server ignores it entirely — it dispatches on the password — but git
/// requires *some* username before it will consider a credential complete, and
/// this is the literal the Anvil server's own CI runner uses when it builds
/// clone URLs, so it's the one that shows up in server-side logs.
pub const GIT_USERNAME: &str = "x-token";
/// A parsed git credential request: the `key=value` lines git writes to a
/// helper's stdin, terminated by a blank line or EOF.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialRequest {
pub protocol: Option<String>,
/// Git sends `host` including the port when it isn't the scheme default.
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
}
/// Parse git's credential description from `reader`.
///
/// Unknown keys are ignored rather than rejected: git has added fields over
/// time (`wwwauth[]`, `capability[]`, `oauth_refresh_token`) and a helper that
/// choked on an unfamiliar one would break on the next git release.
pub fn parse_request<R: BufRead>(reader: R) -> CredentialRequest {
let mut req = CredentialRequest::default();
for line in reader.lines().map_while(Result::ok) {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
break;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"protocol" => req.protocol = Some(value.to_string()),
"host" => req.host = Some(value.to_string()),
"path" => req.path = Some(value.to_string()),
"username" => req.username = Some(value.to_string()),
_ => {}
}
}
req
}
/// The `protocol` and `host` (with port, when non-default) that identify the
/// configured Anvil server in git's terms.
///
/// Returns `None` if the configured server URL has no host — a malformed
/// config, which must decline rather than match everything.
pub fn server_identity(server_url: &str) -> Option<(String, String)> {
let url = url::Url::parse(server_url).ok()?;
let host = url.host_str()?.to_string();
let scheme = url.scheme().to_string();
// `Url::port()` is None for the scheme's default port, which is exactly
// when git omits it too — so the two agree without special-casing 443/80.
let host = match url.port() {
Some(p) => format!("{host}:{p}"),
None => host,
};
Some((scheme, host))
}
/// Drop a port that is the scheme's default.
///
/// `url::Url` elides a default port always; git preserves whichever form the
/// URL was written in, so a remote of `https://host:443/…` reaches the helper
/// as `host=host:443`. Without this the two spellings of the same server would
/// not compare equal, the helper would decline, and — with terminal prompts
/// disabled — the clone would die on "could not read Username". The Anvil web
/// UI's own clone-URL widget always interpolates the port, so this is a form
/// users really are handed.
fn strip_default_port(scheme: &str, host: &str) -> String {
let default = match scheme {
"https" => ":443",
"http" => ":80",
_ => return host.to_string(),
};
host.strip_suffix(default).unwrap_or(host).to_string()
}
/// `server_url` with a redundant default port removed, so the URL handed to
/// git, the config key scoping the helper, and the host git reports back all
/// agree on one spelling.
pub fn normalize_server_url(server_url: &str) -> String {
let trimmed = server_url.trim_end_matches('/');
match url::Url::parse(trimmed) {
// `Url`'s serialization drops a default port for us.
Ok(u) => u.as_str().trim_end_matches('/').to_string(),
Err(_) => trimmed.to_string(),
}
}
/// Whether a credential request is for the configured Anvil server.
///
/// Deliberately strict about both scheme and host. A helper that answered for
/// any host would hand the Anvil token to github.com the first time git asked.
pub fn matches_server(req: &CredentialRequest, server_url: &str) -> bool {
let Some((scheme, host)) = server_identity(server_url) else {
return false;
};
// Git omits `protocol` only in odd configurations; treat a missing one as
// "unknown", not "matches".
if req.protocol.as_deref() != Some(scheme.as_str()) {
return false;
}
let want = strip_default_port(&scheme, &host);
req.host
.as_deref()
.map(|h| strip_default_port(&scheme, h))
.is_some_and(|h| h.eq_ignore_ascii_case(&want))
}
/// Render the `username=…\npassword=…` answer git expects.
pub fn format_response(username: &str, password: &str) -> String {
format!("username={username}\npassword={password}\n")
}
/// Serve one credential request: read git's query from `input`, write the
/// answer (if any) to `output`.
///
/// Only `get` produces output. `store` and `erase` are accepted and ignored on
/// purpose: the credential's home is the anvil config file, so there is nothing
/// to cache and nothing git should be able to delete. Both still exit
/// successfully — a helper that errors on `store` makes git print warnings
/// after an otherwise-fine fetch.
///
/// Declining (writing nothing) is also the response when the request is for
/// some other host, or when we hold no token: git then falls through to its
/// next helper or prompts, which is the correct behaviour in both cases.
pub fn serve<R: BufRead, W: Write>(
operation: &str,
input: R,
output: &mut W,
) -> Result<(), Box<dyn std::error::Error>> {
// A config we can't read or parse is "no credential", not a failure to
// report: git runs this on every fetch and push, and an error here would
// print `Error: config parse error: …` on each one while git fell back to
// prompting anyway. Declining leaves git to its next helper, and
// `anvil auth status` is where a broken config gets diagnosed.
let config = Config::load().unwrap_or_default();
serve_with(
operation,
input,
output,
config.server_url(),
config.token.as_deref(),
)
}
/// [`serve`], with the credential supplied rather than read from disk.
pub fn serve_with<R: BufRead, W: Write>(
operation: &str,
input: R,
output: &mut W,
server_url: &str,
token: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if operation != "get" {
return Ok(());
}
let req = parse_request(input);
if !matches_server(&req, server_url) {
return Ok(());
}
let Some(token) = token.filter(|t| !t.is_empty()) else {
return Ok(());
};
output.write_all(format_response(GIT_USERNAME, token).as_bytes())?;
output.flush()?;
Ok(())
}
/// The `credential.<url>.helper` value that invokes this binary.
///
/// The leading `!` tells git to run the string as a shell command, which is the
/// only form that reliably carries arguments and survives a path with spaces
/// (single-quoted below). Falls back to the bare name `anvil` when the
/// executable's own path can't be determined, so a `$PATH` install still works.
pub fn helper_command() -> String {
let exe = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "anvil".to_string());
format!("!{} auth git-credential", shell_quote(&exe))
}
/// Wrap `s` in single quotes for a POSIX shell, escaping any it contains.
///
/// Git runs `!`-prefixed helpers through `sh` (including Git for Windows, via
/// its bundled shell), where single quotes also stop backslashes from being
/// interpreted — so a Windows path survives unmangled.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// The git config key that scopes a helper to one server, e.g.
/// `credential.https://anvil.fangorn.io.helper`.
///
/// Scoping matters: an unscoped `credential.helper` would offer the Anvil token
/// for every host git talks to.
pub fn helper_config_key(server_url: &str) -> Option<String> {
let (scheme, host) = server_identity(server_url)?;
Some(format!("credential.{scheme}://{host}.helper"))
}
#[cfg(test)]
mod tests {
use super::*;
fn req(s: &str) -> CredentialRequest {
parse_request(std::io::Cursor::new(s.as_bytes()))
}
#[test]
fn parses_gits_key_value_block() {
let r = req("protocol=https\nhost=anvil.fangorn.io\npath=org/repo.git\n\n");
assert_eq!(r.protocol.as_deref(), Some("https"));
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
assert_eq!(r.path.as_deref(), Some("org/repo.git"));
}
#[test]
fn a_blank_line_ends_the_request() {
// Git keeps the pipe open after the blank line in some flows; reading
// past it would block or absorb the next request.
let r = req("protocol=https\nhost=anvil.fangorn.io\n\nprotocol=ssh\n");
assert_eq!(r.protocol.as_deref(), Some("https"));
}
#[test]
fn eof_without_a_blank_line_is_fine() {
let r = req("protocol=https\nhost=anvil.fangorn.io");
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
}
#[test]
fn unknown_keys_are_ignored_not_fatal() {
// Newer gits send wwwauth[]/capability[]; an older helper must cope.
let r = req(
"protocol=https\nhost=h\nwwwauth[]=Basic realm=\"Anvil Git\"\ncapability[]=authtype\n",
);
assert_eq!(r.host.as_deref(), Some("h"));
}
#[test]
fn a_value_containing_equals_is_kept_whole() {
let r = req("protocol=https\nhost=h\npath=a=b\n");
assert_eq!(r.path.as_deref(), Some("a=b"));
}
#[test]
fn crlf_input_is_handled() {
let r = req("protocol=https\r\nhost=anvil.fangorn.io\r\n\r\n");
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
}
// ── host matching ─────────────────────────────────────────────────
#[test]
fn matches_the_configured_server() {
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn never_answers_for_another_host() {
// The failure this prevents is handing the Anvil token to github.com.
let r = req("protocol=https\nhost=github.com\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn a_scheme_mismatch_does_not_match() {
// Answering an http:// challenge would send the token in cleartext.
let r = req("protocol=http\nhost=anvil.fangorn.io\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn host_comparison_is_case_insensitive() {
let r = req("protocol=https\nhost=Anvil.Fangorn.IO\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn a_non_default_port_must_agree() {
let r = req("protocol=http\nhost=localhost:4000\n");
assert!(matches_server(&r, "http://localhost:4000"));
assert!(!matches_server(&r, "http://localhost:4001"));
assert!(!matches_server(&r, "http://localhost"));
}
#[test]
fn a_default_port_in_the_config_still_matches_gits_bare_host() {
// Git drops :443 from `host`; url::Url drops it from `port()`. They
// must agree or every clone of a `https://host:443` config fails.
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(matches_server(&r, "https://anvil.fangorn.io:443"));
}
#[test]
fn an_explicit_default_port_matches_a_bare_configured_host() {
// Git preserves whichever spelling the URL used, so a remote of
// `https://host:443/…` reaches the helper as `host=host:443` (verified
// against real git). Declining there meant the helper refused its own
// clone, and with terminal prompts off the clone died on "could not
// read Username". The Anvil web UI hands out exactly this form.
let r = req("protocol=https\nhost=anvil.fangorn.io:443\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
assert!(matches_server(&r, "https://anvil.fangorn.io:443"));
let r80 = req("protocol=http\nhost=localhost:80\n");
assert!(matches_server(&r80, "http://localhost"));
}
#[test]
fn stripping_the_default_port_does_not_make_other_ports_match() {
let r = req("protocol=https\nhost=anvil.fangorn.io:8443\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
// :80 is not the default for https and must not be stripped.
let r80 = req("protocol=https\nhost=anvil.fangorn.io:80\n");
assert!(!matches_server(&r80, "https://anvil.fangorn.io"));
}
#[test]
fn normalizing_a_server_url_drops_only_a_redundant_port() {
assert_eq!(
normalize_server_url("https://anvil.fangorn.io:443"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("http://localhost:4000"),
"http://localhost:4000"
);
// Not a URL: left exactly as given rather than mangled.
assert_eq!(normalize_server_url("not a url"), "not a url");
}
#[test]
fn the_config_key_and_a_normalized_url_agree_on_one_spelling() {
// These three have to line up or the helper never gets consulted.
let server = normalize_server_url("https://anvil.fangorn.io:443");
assert_eq!(
helper_config_key(&server).as_deref(),
Some("credential.https://anvil.fangorn.io.helper")
);
assert!(matches_server(
&req("protocol=https\nhost=anvil.fangorn.io\n"),
&server
));
}
#[test]
fn a_request_missing_host_or_protocol_does_not_match() {
assert!(!matches_server(&req("protocol=https\n"), "https://a.io"));
assert!(!matches_server(&req("host=a.io\n"), "https://a.io"));
assert!(!matches_server(&req(""), "https://a.io"));
}
#[test]
fn an_unparseable_server_url_matches_nothing() {
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(!matches_server(&r, "not a url"));
}
// ── serve ─────────────────────────────────────────────────────────
#[test]
fn store_and_erase_write_nothing_and_succeed() {
// Git calls `store` after every successful authentication. Erroring
// there would print a warning on every fetch.
for op in ["store", "erase"] {
let mut out = Vec::new();
serve(
op,
std::io::Cursor::new(b"protocol=https\nhost=x\n".as_ref()),
&mut out,
)
.expect("op must succeed");
assert!(out.is_empty(), "{op} must not emit credentials");
}
}
/// `serve_with` over a request for `host`, returning what it wrote.
fn served(host: &str, token: Option<&str>) -> String {
let mut out = Vec::new();
serve_with(
"get",
std::io::Cursor::new(format!("protocol=https\nhost={host}\n\n").into_bytes()),
&mut out,
"https://anvil.fangorn.io",
token,
)
.expect("serve must not fail");
String::from_utf8(out).unwrap()
}
#[test]
fn holding_no_token_declines_instead_of_erroring() {
// This is also the path a corrupt or unreadable config takes: `serve`
// falls back to a default config rather than propagating an error, so
// git gets a clean decline on every fetch instead of a printed error.
assert_eq!(served("anvil.fangorn.io", None), "");
assert_eq!(served("anvil.fangorn.io", Some("")), "");
}
#[test]
fn a_held_token_is_offered_only_to_the_configured_host() {
assert_eq!(
served("anvil.fangorn.io", Some("anvil_secret")),
"username=x-token\npassword=anvil_secret\n"
);
assert_eq!(served("github.com", Some("anvil_secret")), "");
}
#[test]
fn response_has_the_shape_git_parses() {
let s = format_response("x-token", "anvil_secret");
assert_eq!(s, "username=x-token\npassword=anvil_secret\n");
}
// ── helper command ────────────────────────────────────────────────
#[test]
fn helper_command_is_a_shell_command_with_the_subcommand() {
let h = helper_command();
assert!(h.starts_with('!'), "git needs the ! prefix: {h}");
assert!(h.ends_with(" auth git-credential"), "got: {h}");
}
#[test]
fn a_path_with_spaces_or_quotes_stays_one_argument() {
assert_eq!(shell_quote("/opt/my tools/anvil"), "'/opt/my tools/anvil'");
assert_eq!(shell_quote("/o'dd/anvil"), r"'/o'\''dd/anvil'");
// Windows backslashes are literal inside single quotes.
assert_eq!(
shell_quote(r"C:\Users\me\anvil.exe"),
r"'C:\Users\me\anvil.exe'"
);
}
#[test]
fn config_key_scopes_the_helper_to_the_server() {
assert_eq!(
helper_config_key("https://anvil.fangorn.io").as_deref(),
Some("credential.https://anvil.fangorn.io.helper")
);
assert_eq!(
helper_config_key("http://localhost:4000").as_deref(),
Some("credential.http://localhost:4000.helper")
);
assert!(helper_config_key("not a url").is_none());
}
}
src/lib.rs +2 −0
@@ -8,9 +8,11 @@
pub mod client;
pub mod commands;
pub mod config;
pub mod gitcreds;
pub mod output;
pub mod platform;
pub mod runner;
pub mod sshkeys;
#[cfg(test)]
pub mod testutil;
src/sshkeys.rs +667 −0
@@ -1,0 +1,667 @@
//! Local SSH key discovery, parsing, fingerprinting and generation.
//!
//! Split out from `commands::ssh_key` so the parts that touch neither the
//! network nor a terminal can be unit-tested directly: everything here is
//! either pure (`parse_public_key`, `fingerprint`, `rank`) or takes the
//! directory to look in as an argument, so tests never depend on the real
//! `~/.ssh`.
use base64::Engine;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
/// Public-key algorithms we recognise, best first.
///
/// Order is the preference used to pick a key when a user has several: Ed25519
/// is the modern default, the `sk-` variants are hardware-backed and equally
/// good, ECDSA and RSA are legacy-but-fine, and DSA is effectively dead (and
/// refused by current OpenSSH) so it sorts last.
const ALGO_PREFERENCE: &[&str] = &[
"ssh-ed25519",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"ssh-rsa",
"ssh-dss",
];
/// A parsed OpenSSH public key: `<algo> <base64 blob> [comment]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicKey {
pub algo: String,
pub blob_b64: String,
pub comment: Option<String>,
}
impl PublicKey {
/// The SHA256 fingerprint in OpenSSH's display form (`SHA256:…`, base64
/// without padding) — the same string `ssh-keygen -lf` prints and the
/// server stores, so the two can be compared to spot an already-uploaded
/// key.
pub fn fingerprint(&self) -> Option<String> {
let raw = base64::engine::general_purpose::STANDARD
.decode(self.blob_b64.as_bytes())
.ok()?;
let digest = Sha256::digest(&raw);
let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest);
Some(format!("SHA256:{b64}"))
}
/// Re-render the key in the exact one-line form OpenSSH uses, which is what
/// the server wants in `public_key`.
pub fn to_line(&self) -> String {
match self.comment {
Some(ref c) if !c.is_empty() => format!("{} {} {}", self.algo, self.blob_b64, c),
_ => format!("{} {}", self.algo, self.blob_b64),
}
}
}
/// Parse the first usable public key out of a `.pub` file's contents.
///
/// Tolerant of the things that actually show up in these files: blank lines,
/// `#` comments, CRLF endings, and trailing whitespace. Returns `None` for
/// anything that isn't a recognised `<algo> <base64> [comment]` line — notably
/// a *private* key handed over by mistake, which must not be uploaded.
pub fn parse_public_key(contents: &str) -> Option<PublicKey> {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace *runs*, not single characters: a hand-edited or
// re-wrapped `.pub` file can have two spaces between the fields, and
// `splitn` would hand back an empty blob and make us discard a
// perfectly good key. The comment keeps its own internal spacing,
// which `split_whitespace` would destroy.
let Some((algo, rest)) = line.split_once(char::is_whitespace) else {
continue;
};
if !ALGO_PREFERENCE.contains(&algo) {
continue;
}
let rest = rest.trim_start();
let (blob, comment) = match rest.split_once(char::is_whitespace) {
Some((b, c)) => (b, c.trim()),
None => (rest, ""),
};
if blob.is_empty() {
continue;
}
// The blob must be real base64, or the server would reject it and the
// fingerprint we compare against would be meaningless.
if base64::engine::general_purpose::STANDARD
.decode(blob.as_bytes())
.is_err()
{
continue;
}
return Some(PublicKey {
algo: algo.to_string(),
blob_b64: blob.to_string(),
comment: (!comment.is_empty()).then(|| comment.to_string()),
});
}
None
}
/// The public-key path `ssh-keygen` writes next to the private key at `path`.
///
/// `ssh-keygen -f x.key` produces `x.key.pub` — it *appends*. `Path::with_
/// extension("pub")` *replaces*, so it would name `x.pub` for any filename
/// containing a dot, and we would go looking for a file that was never
/// written. That matters because the user is free to type a filename at the
/// generation prompt.
fn public_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".pub");
PathBuf::from(name)
}
/// Preference index for an algorithm; unknown algorithms sort last.
fn rank(algo: &str) -> usize {
ALGO_PREFERENCE
.iter()
.position(|a| *a == algo)
.unwrap_or(ALGO_PREFERENCE.len())
}
/// A public key found on disk, with the path it came from.
#[derive(Debug, Clone)]
pub struct FoundKey {
pub path: PathBuf,
pub key: PublicKey,
}
/// Every usable public key in `ssh_dir`, best first.
///
/// Sorted by algorithm preference and then by path, so the choice is stable
/// across runs rather than dependent on directory order — a user with both
/// `id_rsa.pub` and `id_ed25519.pub` gets the Ed25519 key every time.
///
/// Certificates (`*-cert.pub`) are skipped: they are not something the server
/// stores as an account key, and uploading one instead of the key it certifies
/// would silently not grant access.
pub fn discover(ssh_dir: &Path) -> Vec<FoundKey> {
let entries = match std::fs::read_dir(ssh_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut found: Vec<FoundKey> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| p.extension().is_some_and(|x| x == "pub"))
.filter(|p| {
!p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("-cert.pub"))
})
.filter_map(|path| {
let contents = std::fs::read_to_string(&path).ok()?;
let key = parse_public_key(&contents)?;
Some(FoundKey { path, key })
})
.collect();
found.sort_by(|a, b| {
rank(&a.key.algo)
.cmp(&rank(&b.key.algo))
.then_with(|| a.path.cmp(&b.path))
});
found
}
/// Private keys with no *usable* public half beside them, best first.
///
/// A `.pub` file is a convenience, not a requirement — it is derivable from the
/// private key. When one is missing or unreadable, recovering it beats
/// declaring "no key found" and generating a second key next to the perfectly
/// good one the user already has (which `ssh-keygen` would then refuse to
/// overwrite, leaving them stuck on a message about the wrong problem).
///
/// "Usable" is the operative word, and it is deliberately not "exists": a
/// truncated, corrupt, or unrecognised-algorithm `.pub` file is exactly as
/// useless to us as a missing one, and testing only for existence would send
/// that case down the same dead end.
pub fn private_keys_without_usable_public(ssh_dir: &Path) -> Vec<PathBuf> {
const CANDIDATES: &[&str] = &[
"id_ed25519",
"id_ed25519_sk",
"id_ecdsa",
"id_ecdsa_sk",
"id_rsa",
"id_dsa",
];
CANDIDATES
.iter()
.map(|n| ssh_dir.join(n))
.filter(|p| p.is_file())
.filter(|p| {
std::fs::read_to_string(public_path(p))
.ok()
.and_then(|c| parse_public_key(&c))
.is_none()
})
.collect()
}
/// Derive a public key from a private key via `ssh-keygen -y`.
///
/// Fails (rather than hangs) on a passphrase-protected key in a non-interactive
/// context: `ssh-keygen` reads the passphrase from the terminal, and with no
/// terminal it exits nonzero.
pub fn derive_public_key(private_key: &Path) -> Result<PublicKey, String> {
let out = std::process::Command::new("ssh-keygen")
.arg("-y")
.arg("-f")
.arg(private_key)
.output()
.map_err(|e| format!("could not run ssh-keygen: {e}"))?;
if !out.status.success() {
return Err(format!(
"ssh-keygen could not read {}: {}",
private_key.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let text = String::from_utf8_lossy(&out.stdout);
parse_public_key(&text).ok_or_else(|| {
format!(
"ssh-keygen produced no usable key for {}",
private_key.display()
)
})
}
/// What `generate` should create. Ed25519 with no size argument is the right
/// default; RSA is kept only for hosts that still need it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyType {
Ed25519,
Rsa4096,
}
impl KeyType {
/// The `ssh-keygen -t` name.
pub fn keygen_type(&self) -> &'static str {
match self {
KeyType::Ed25519 => "ed25519",
KeyType::Rsa4096 => "rsa",
}
}
/// Default filename OpenSSH itself would use, so the key lands where
/// `ssh` looks for it without any `IdentityFile` configuration.
pub fn default_filename(&self) -> &'static str {
match self {
KeyType::Ed25519 => "id_ed25519",
KeyType::Rsa4096 => "id_rsa",
}
}
}
/// Create a new key pair at `path` with `ssh-keygen`.
///
/// `passphrase` of `None` means "let `ssh-keygen` prompt on the terminal" — the
/// passphrase then never passes through our process, our argv, or our memory.
/// `Some("")` requests an unencrypted key, which is what a non-interactive run
/// has to do.
pub fn generate(
path: &Path,
key_type: KeyType,
comment: &str,
passphrase: Option<&str>,
) -> Result<PublicKey, String> {
if path.exists() {
return Err(format!(
"{} already exists — refusing to overwrite it",
path.display()
));
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
// Tighten permissions ONLY on a directory we just created. The parent
// is whatever the user typed at the generation prompt: answer
// `~/anvil_key` and it is their home directory, and chmod-ing that to
// 0700 would break anything relying on it being traversable (other
// accounts, a web server serving ~/public_html). Creating it 0700 is
// right; re-permissioning someone's existing directory is not ours to
// do.
let existed = parent.is_dir();
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
// ssh(1) ignores a world-readable ~/.ssh for some operations and
// OpenSSH refuses group-writable ones outright, so a directory we
// create for keys starts at 0700.
#[cfg(unix)]
if !existed {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = existed;
}
let mut cmd = std::process::Command::new("ssh-keygen");
cmd.arg("-t")
.arg(key_type.keygen_type())
.arg("-f")
.arg(path)
.arg("-C")
.arg(comment);
if key_type == KeyType::Rsa4096 {
cmd.arg("-b").arg("4096");
}
// `-N ""` is the documented way to ask for no passphrase, and is only
// reached when we could not prompt (the caller says so out loud). With no
// `-N` at all, ssh-keygen opens the terminal and asks for itself — so the
// passphrase never passes through our argv or memory.
if let Some(p) = passphrase {
cmd.arg("-N").arg(p);
}
let status = cmd
.status()
.map_err(|e| format!("could not run ssh-keygen: {e} — is OpenSSH installed?"))?;
if !status.success() {
return Err(format!("ssh-keygen exited with status {status}"));
}
let pub_path = public_path(path);
let contents = std::fs::read_to_string(&pub_path)
.map_err(|e| format!("could not read {}: {e}", pub_path.display()))?;
parse_public_key(&contents)
.ok_or_else(|| format!("{} is not a public key we understand", pub_path.display()))
}
/// `~/.ssh`, or `$ANVIL_SSH_DIR` when set (tests, and unusual homes).
pub fn ssh_dir() -> PathBuf {
if let Some(explicit) = std::env::var_os("ANVIL_SSH_DIR") {
if !explicit.is_empty() {
return PathBuf::from(explicit);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ssh")
}
/// `user@host`, the label OpenSSH puts in a key comment. Used as the default
/// for both the key comment and the name shown in `anvil ssh-key list`, so a
/// key is identifiable later without the user having to invent a name.
pub fn default_label() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|u| !u.is_empty());
let host = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.filter(|h| !h.is_empty());
match (user, host) {
(Some(u), Some(h)) => format!("{u}@{h}"),
(None, Some(h)) => h,
(Some(u), None) => u,
(None, None) => "anvil-cli".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
// A real Ed25519 public key (generated for tests; the private half was
// discarded). Using a genuine one matters: the fingerprint assertions below
// are checked against `ssh-keygen -lf`.
const ED25519: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx cole@laptop";
#[test]
fn parses_algo_blob_and_comment() {
let k = parse_public_key(ED25519).expect("parse");
assert_eq!(k.algo, "ssh-ed25519");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
}
#[test]
fn a_comment_may_contain_spaces() {
// OpenSSH treats everything after the blob as the comment, spaces and
// all. Splitting on every space would truncate it.
let k = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx my laptop key",
)
.unwrap();
assert_eq!(k.comment.as_deref(), Some("my laptop key"));
}
#[test]
fn a_key_without_a_comment_parses_and_round_trips() {
let line =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx";
let k = parse_public_key(line).unwrap();
assert!(k.comment.is_none());
assert_eq!(k.to_line(), line);
}
#[test]
fn crlf_and_surrounding_blank_lines_are_tolerated() {
let k = parse_public_key(&format!("\n\r\n{ED25519}\r\n\n")).expect("parse");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
// A stray \r must not ride along into the comment we upload.
assert!(!k.to_line().contains('\r'));
}
#[test]
fn a_private_key_is_never_parsed_as_a_public_one() {
// The whole point of the guard: uploading a private key would hand the
// server the user's credential.
let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n";
assert!(parse_public_key(private).is_none());
}
#[test]
fn extra_whitespace_between_fields_does_not_lose_the_key() {
// Splitting on single whitespace *characters* handed back an empty
// blob here and discarded the key — after which `--auto` would decide
// the user had no key at all and try to generate one.
let k = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx cole@laptop",
)
.expect("a double-spaced key is still a key");
assert_eq!(k.algo, "ssh-ed25519");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
assert_eq!(
k.fingerprint(),
parse_public_key(ED25519).unwrap().fingerprint()
);
}
#[test]
fn a_tab_separated_key_parses() {
let k = parse_public_key(
"ssh-ed25519\tAAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx\tcole@laptop",
)
.expect("tabs are whitespace too");
assert_eq!(k.comment.as_deref(), Some("cole@laptop"));
}
#[test]
fn garbage_and_non_base64_blobs_are_rejected() {
assert!(parse_public_key("").is_none());
assert!(parse_public_key("hello world").is_none());
assert!(parse_public_key("ssh-ed25519").is_none());
assert!(parse_public_key("ssh-ed25519 not!valid!base64!").is_none());
}
#[test]
fn fingerprint_matches_openssh_format() {
let k = parse_public_key(ED25519).unwrap();
let fp = k.fingerprint().expect("fingerprint");
assert!(fp.starts_with("SHA256:"), "got {fp}");
// Base64 of a 32-byte digest, unpadded: 43 chars after the prefix.
assert_eq!(fp.len(), "SHA256:".len() + 43, "got {fp}");
assert!(!fp.ends_with('='), "must be unpadded, got {fp}");
}
#[test]
fn fingerprint_ignores_the_comment() {
// The server fingerprints the blob. If ours folded the comment in, the
// "already uploaded" check would never match and we'd re-POST forever.
let a = parse_public_key(ED25519).unwrap();
let b = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQ8h1u1kZDvVfLJ8LqvC1pQvzXqYAJKKfPPUbVe6qsx someone@else",
)
.unwrap();
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn ed25519_outranks_rsa_which_outranks_dsa() {
assert!(rank("ssh-ed25519") < rank("ssh-rsa"));
assert!(rank("ssh-rsa") < rank("ssh-dss"));
assert!(rank("ssh-dss") < rank("something-invented"));
}
// ── discover ──────────────────────────────────────────────────────
//
// These write into a temp dir rather than the caller's ~/.ssh; `discover`
// takes the directory precisely so that's possible.
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"anvil-sshkeys-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA rsa@host";
#[test]
fn discover_prefers_ed25519_over_rsa() {
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 found = discover(&dir);
assert_eq!(found.len(), 2, "both keys should be found");
assert_eq!(found[0].key.algo, "ssh-ed25519", "Ed25519 must come first");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_skips_certificates_and_private_keys() {
let dir = tmpdir("skip");
std::fs::write(dir.join("id_ed25519-cert.pub"), ED25519).unwrap();
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("config"), "Host *\n").unwrap();
std::fs::write(dir.join("known_hosts"), "github.com ssh-rsa AAAA").unwrap();
let found = discover(&dir);
assert!(found.is_empty(), "found: {found:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_on_a_missing_directory_is_empty_not_an_error() {
// A machine with no ~/.ssh at all is exactly the case `--auto` exists
// to handle; it must reach the "generate one" path, not blow up.
let found = discover(Path::new("/definitely/not/a/real/ssh/dir"));
assert!(found.is_empty());
}
#[test]
fn discover_is_stable_when_two_keys_share_an_algorithm() {
let dir = tmpdir("stable");
std::fs::write(dir.join("b_key.pub"), ED25519).unwrap();
std::fs::write(dir.join("a_key.pub"), ED25519).unwrap();
let first = discover(&dir);
let second = discover(&dir);
assert_eq!(first.len(), 2);
assert_eq!(
first[0].path, second[0].path,
"the same key must win on every run"
);
assert!(first[0].path.ends_with("a_key.pub"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_with_no_public_half_is_reported() {
let dir = tmpdir("orphan");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
let orphans = private_keys_without_usable_public(&dir);
assert_eq!(orphans.len(), 1);
assert!(orphans[0].ends_with("id_ed25519"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_beside_an_unparseable_public_half_is_reported() {
// A `.pub` that exists but is corrupt is exactly as useless as one
// that is missing. Testing only for existence hid this key from the
// recovery path and sent the caller off to generate a duplicate.
let dir = tmpdir("corrupt");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), "ssh-ed25519 \n").unwrap();
let orphans = private_keys_without_usable_public(&dir);
assert_eq!(orphans.len(), 1, "got: {orphans:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_private_key_that_still_has_its_public_half_is_not_reported() {
let dir = tmpdir("paired");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
assert!(private_keys_without_usable_public(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generate_refuses_to_clobber_an_existing_key() {
// Overwriting a private key destroys access to every other host that
// trusts it. This must fail before ssh-keygen is even invoked.
let dir = tmpdir("clobber");
let path = dir.join("id_ed25519");
std::fs::write(&path, "existing").unwrap();
let err = generate(&path, KeyType::Ed25519, "c", Some("")).unwrap_err();
assert!(err.contains("already exists"), "got: {err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_public_path_is_appended_never_substituted() {
// ssh-keygen -f x.key writes x.key.pub. `with_extension("pub")` would
// say `x.pub`, so generation would create the pair and then fail
// reading a file that was never written — leaving the user wedged
// behind the "already exists" guard on the retry.
assert_eq!(
public_path(Path::new("/home/u/.ssh/anvil.key")),
PathBuf::from("/home/u/.ssh/anvil.key.pub")
);
assert_eq!(
public_path(Path::new("/home/u/.ssh/id_ed25519")),
PathBuf::from("/home/u/.ssh/id_ed25519.pub")
);
assert_eq!(
public_path(Path::new("/home/u/.ssh/my.work.key")),
PathBuf::from("/home/u/.ssh/my.work.key.pub")
);
}
#[test]
fn a_dotted_private_key_name_is_still_seen_as_paired() {
// The same substitution bug would make `private_keys_without_usable_public`
// think a paired key was orphaned.
let dir = tmpdir("dotted");
std::fs::write(
dir.join("id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----",
)
.unwrap();
std::fs::write(dir.join("id_ed25519.pub"), ED25519).unwrap();
assert!(private_keys_without_usable_public(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn default_label_is_never_empty() {
assert!(!default_label().is_empty());
}
}
tests/json_contract.rs +1 −0
@@ -55,6 +55,7 @@
"agent sessions",
"agent trigger",
"agent view",
"auth git-credential",
"auth login",
"auth logout",
"auth rotate",
tests/ssh_key_auto.rs +558 −0
@@ -1,0 +1,558 @@
//! 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(|_| ())
}