Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
    );
}