crates/gunnar-cli/tests/named_identity.rs
#![cfg(all(feature = "server", feature = "client"))]
//! **`-i` names the identity, and gunnar authenticates as that or as nobody.**
//!
//! # The defect, and why no test caught it
//!
//! MEASURED from a laptop against the live server on 2026-08-06:
//! `gunnar ls-remote -i <a key the server has never seen>` printed the refs and
//! exited **0**. The server was right throughout — its log for that one call is
//!
//! ```text
//! ssh.auth.refused reason="unknown-key" fingerprint=SHA256:ySF3ogYe… ×4
//! ssh.auth.accepted fingerprint=SHA256:OC2Yqguq…
//! grpc.auth.refused reason="not-verified"
//! ```
//!
//! — it refused the stranger four times and then accepted a key the caller had
//! explicitly asked it not to use. The client had put `-i` at the *head of a
//! ladder* (`$GUNNAR_SSH_KEY`, the deploy key, `~/.ssh/id_*`) with `ssh-agent`
//! still in front of all of it, so it simply carried on down the list.
//!
//! **This is worse than a client bug.** An authorization test written as
//! `ls-remote -i <disallowed key>` was green on the machine of anyone whose own
//! key happened to be enrolled — a false green in exactly the test that is
//! supposed to show the door is shut. gunnar's whole ACL rests on the key being
//! the identity.
//!
//! # What was found when the tests were audited, stated honestly
//!
//! The audit expected to find hollow tests. It found something else: **the test
//! class did not exist at all.** Across the workspace on 2026-08-06 there was
//! not one test — binary or library — that drove gunnar's *own* client with a
//! disallowed key and asserted a refusal. Every real refusal proof is either
//! in-process against `gunnar-ssh` (`tests/auth.rs`, `tests/adversarial_auth.rs`,
//! `tests/sandbox.rs`, `tests/anonymous.rs`) or driven with **stock**
//! `git`+`ssh` under `IdentitiesOnly=yes -o IdentityAgent=none`, which has no
//! ladder to fall down. gunnar's client was the one caller nothing checked, and
//! that absence is why a substitution survived in it.
//!
//! This file is that missing class. It is deliberately end-to-end, through the
//! shipped binary, because the substitution happened between the flag and the
//! wire and no unit test of either end can see it.
//!
//! # The bait, and why every assertion here needs it
//!
//! Each test plants a **working** key at `$HOME/.ssh/id_ed25519` — the harness's
//! own enrolled key — and only then names a stranger with `-i`. Without the
//! bait every "must be refused" below would pass against the broken code too,
//! for the trivial reason that there would have been nothing to fall back to.
//! [`the_bait_is_real`] asserts the bait works before anything else is believed.
mod common;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use common::{local_repo, tools_present, Harness};
use gunnar_core::HashKind;
/// Plant the harness's enrolled key at `$HOME/.ssh/id_ed25519` and hand back
/// the `HOME` to run under.
///
/// This is the machine the defect needed: a developer box whose own identity
/// the server accepts. It is the *only* thing that makes the refusals below
/// meaningful, so it is created by one function and asserted by
/// [`the_bait_is_real`].
fn home_with_a_working_key(harness: &Harness) -> PathBuf {
let home = harness.dir.path().join("home");
std::fs::create_dir_all(home.join(".ssh")).expect("create the fake ~/.ssh");
let bait = home.join(".ssh").join("id_ed25519");
std::fs::copy(&harness.key_path, &bait).expect("plant the working key");
let mut perms = std::fs::metadata(&bait)
.expect("stat the bait")
.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
perms.set_mode(0o600);
}
std::fs::set_permissions(&bait, perms).expect("0600 the bait");
home
}
/// A key the server has never heard of, on disk.
fn a_stranger(harness: &Harness) -> PathBuf {
let path = harness.dir.path().join("stranger_ed25519");
let pem = gunnar_core::mint_ed25519().expect("mint a stranger key");
std::fs::write(&path, &pem).expect("write the stranger key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut perms = std::fs::metadata(&path).expect("stat").permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms).expect("0600 the stranger");
}
path
}
/// Run the shipped `gunnar` binary under `home`.
///
/// `SSH_AUTH_SOCK` is cleared: an agent on the developer's machine is a third
/// identity source, and a test that left it set would be measuring whatever
/// happened to be loaded when it ran.
fn gunnar(home: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_gunnar-cli"))
.args(args)
.env("HOME", home)
.env("SSH_AUTH_SOCK", "")
.env_remove("GUNNAR_SSH_KEY")
.output()
.expect("run the gunnar binary")
}
fn rendered(out: &Output) -> String {
format!(
"exit {:?}\n--- stdout ---\n{}--- stderr ---\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
}
/// A harness holding one repository with one commit in it, so `ls-remote` has
/// something to print and "no refs" cannot be mistaken for "refused".
fn seeded() -> (Harness, PathBuf, String) {
let harness = Harness::start("team", "project", HashKind::Sha256);
let work = harness.dir.path().join("work");
local_repo(&harness, &work, HashKind::Sha256, "named identity\n");
let url = harness.url("team", "project");
harness
.git(&work, &["push", &url, "--all"])
.expect_ok("git push --all to gunnar over ssh");
let home = home_with_a_working_key(&harness);
(harness, home, url)
}
/// ANTI-VACUITY, and the one test in this file that must pass **before** any
/// other result here means anything.
///
/// Two facts, both required:
///
/// 1. the planted `~/.ssh/id_ed25519` really does open this server, so there
/// is a substitution available to be made;
/// 2. it opens it **through the ladder**, with no `-i` at all — which is the
/// exact code path a named key must now be forbidden from reaching.
///
/// If this ever goes red, every "is refused" below is passing on nothing.
#[test]
fn the_bait_is_real() {
if !tools_present() {
eprintln!("SKIP: git or ssh missing");
return;
}
let (_harness, home, url) = seeded();
let out = gunnar(&home, &["ls-remote", &url]);
assert!(
out.status.success(),
"the key planted at ~/.ssh/id_ed25519 does not open this server, so every \
refusal in this file would pass with no fix at all.\n{}",
rendered(&out)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("refs/heads/"),
"the ladder connected but the remote advertised nothing, so 'refs were \
printed' cannot tell success from refusal here.\n{}",
rendered(&out)
);
}
/// **THE ONE.** RED-WHEN-BROKEN against the code as it shipped: an un-enrolled
/// key named with `-i` must be refused, on a machine that holds a key which
/// would have worked.
///
/// Watched go red on 2026-08-06 against the pre-fix `candidate_key_paths`:
/// exit 0 and the full ref list, from the bait.
#[test]
fn ls_remote_with_an_unenrolled_key_is_refused_even_with_a_working_key_in_home() {
if !tools_present() {
eprintln!("SKIP: git or ssh missing");
return;
}
let (harness, home, url) = seeded();
let stranger = a_stranger(&harness);
let out = gunnar(
&home,
&["ls-remote", "-i", stranger.to_str().unwrap(), &url],
);
assert!(
!out.status.success(),
"`gunnar ls-remote -i <un-enrolled key>` SUCCEEDED. The client fell back to \
another identity, and every authorization test written in this shape is a \
false green.\n{}",
rendered(&out)
);
assert!(
out.stdout.is_empty(),
"it printed refs for a key the server refused:\n{}",
rendered(&out)
);
// The refusal must be about the key, not about the repository: gunnar
// deliberately answers "no such repo" and "not allowed" with one string, so
// a test that accepted any failure would also accept a typo in the URL.
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("publickey auth failed") || stderr.contains("ssh identity"),
"it failed for some other reason than the key being refused:\n{}",
rendered(&out)
);
assert!(
!stderr.contains("id_ed25519"),
"the client offered ~/.ssh/id_ed25519 after being told which key to use:\n{}",
rendered(&out)
);
}
/// The same door, on the verbs that shared the same expansion.
///
/// `fetch` and the shallow branch of `clone` both went through `SshOptions`,
/// which is where the ladder lived; plain `clone` did not, which is why the laptop
/// found it already behaved. All three are asserted here so the three cannot
/// drift apart again — the drift IS the defect (one implementation).
#[test]
fn fetch_and_both_clone_paths_refuse_an_unenrolled_key_too() {
if !tools_present() {
eprintln!("SKIP: git or ssh missing");
return;
}
let (harness, home, url) = seeded();
let stranger = a_stranger(&harness);
let key = stranger.to_str().unwrap();
let dest_plain = home.join("clone-plain");
let dest_shallow = home.join("clone-shallow");
let into = harness.dir.path().join("fetch-into");
std::fs::create_dir_all(&into).unwrap();
// A real repository to fetch into, or `fetch` fails on the local half and
// the assertion below would be measuring the wrong refusal.
local_repo(&harness, &into, HashKind::Sha256, "fetch target\n");
let cases: Vec<(&str, Vec<String>)> = vec![
(
"clone",
vec![
"clone".into(),
"-i".into(),
key.into(),
url.clone(),
dest_plain.display().to_string(),
],
),
(
"clone --depth 1",
vec![
"clone".into(),
"--depth".into(),
"1".into(),
"-i".into(),
key.into(),
url.clone(),
dest_shallow.display().to_string(),
],
),
(
"fetch",
vec![
"fetch".into(),
"-C".into(),
into.display().to_string(),
"-i".into(),
key.into(),
url.clone(),
],
),
];
for (verb, args) in cases {
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let out = gunnar(&home, &argv);
assert!(
!out.status.success(),
"`gunnar {verb} -i <un-enrolled key>` succeeded — this verb still \
substitutes another identity.\n{}",
rendered(&out)
);
assert!(
!String::from_utf8_lossy(&out.stderr).contains("id_ed25519"),
"`gunnar {verb}` offered ~/.ssh/id_ed25519 after being told which key \
to use.\n{}",
rendered(&out)
);
}
// Applied output, not just the exit code: a refused clone must leave no
// checkout behind, because a directory that exists is what a script tests.
assert!(
!dest_plain.join(".git").exists(),
"the refused clone left a repository at {}",
dest_plain.display()
);
assert!(
!dest_shallow.join(".git").exists(),
"the refused shallow clone left a repository at {}",
dest_shallow.display()
);
}
/// A `-i` that points at nothing is a **refusal**, not "no identity given".
///
/// The old `exists()` filter dropped it from the ladder, so a typo'd path
/// authenticated as `~/.ssh/id_ed25519` and reported success. That is the same
/// substitution with a worse trigger: a fat finger rather than a wrong key.
#[test]
fn an_identity_that_does_not_exist_is_refused_by_name() {
if !tools_present() {
eprintln!("SKIP: git or ssh missing");
return;
}
let (harness, home, url) = seeded();
let typo = harness.dir.path().join("id_ed25519_typo");
assert!(
!typo.exists(),
"the fixture must name a path that is absent"
);
let out = gunnar(&home, &["ls-remote", "-i", typo.to_str().unwrap(), &url]);
assert!(
!out.status.success(),
"`-i <a path that is not there>` succeeded, so it was silently ignored.\n{}",
rendered(&out)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("id_ed25519_typo"),
"the refusal must name the path that was asked for, or a user cannot tell \
a typo from a rejected key.\n{}",
rendered(&out)
);
}