crates/gunnar-cli/tests/clone_checks_the_host_key.rs
#![cfg(feature = "server")]
//! **GUN-002: `gunnar clone` checks the host key, like every other verb.**
//!
//! `SshOptions` carries `host_key` and `known_hosts`, and `SshOptions::auth()`
//! is the one place that expands them. `push`, `fetch`, `ls-remote`,
//! `mirror sync` and `clone --depth/--filter` all go through it and stop hard
//! on a changed key. `clone` — the verb a user runs first, and the one whose
//! output they then build — did not: `clone_or_fetch_with` took the key path
//! alone and handed it to `nornir_git::gitio::clone_or_fetch`, which builds its
//! own `SshAuth::key(path)`, and `SshAuth::key` sets
//! `HostKeyPolicy::AcceptAny`. No warning, no error, no log line — a full MITM
//! on the clone path, materialising attacker-chosen source straight into a
//! working tree.
//!
//! The asymmetry was the dangerous part, so the asymmetry is what this file
//! measures: the same poisoned `known_hosts` is put in front of BOTH verbs and
//! both must refuse, and the same clean one in front of both and both must
//! work.
//!
//! Nothing here is simulated. The harness runs gunnar's real SSH listener with
//! a real host key; the wrong key is a real ed25519 key minted in-process.
mod common;
use std::path::{Path, PathBuf};
use common::{tools_present, Harness};
use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
use gunnar_core::HashKind;
const NS: &str = "nordisk";
const REPO: &str = "gunnar";
/// A `known_hosts` naming `127.0.0.1:<port>` under a key that is NOT this
/// server's — the on-path attacker's position, from the client's side.
fn poisoned_known_hosts(dir: &Path, port: u16) -> PathBuf {
let other = gunnar_core::mint_ed25519().expect("mint a key");
let other = gunnar_core::ssh_key::PrivateKey::from_openssh(&other).expect("parse");
let line = other.public_key().to_openssh().expect("an openssh line");
let path = dir.join("known_hosts");
// The `[host]:port` form, which is what russh's `known_hosts` writer and
// reader both use for a non-22 port.
std::fs::write(&path, format!("[127.0.0.1]:{port} {line}\n")).expect("write known_hosts");
path
}
/// One commit on `main`, through stock git over this harness's SSH listener.
fn push_one_commit(harness: &Harness, url: &str) {
let work = harness.dir.path().join("seed");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), "base\n").expect("write");
harness.git(&work, &["add", "."]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "base"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", url, "main"])
.expect_ok("push main");
}
fn options(harness: &Harness, known_hosts: PathBuf) -> SshOptions {
let mut opts = SshOptions::new()
.with_key(&harness.key_path)
.with_host_key_policy(HostKeyPolicy::TofuKnownHosts);
// Never `~/.ssh/known_hosts`: a test must not write to the developer's own.
opts.known_hosts = Some(known_hosts);
// The agent holds other keys and this is about the HOST key, not ours.
opts.use_agent = false;
opts
}
/// **The finding.** A server whose key does not match `known_hosts` does not
/// get to put bytes in a working tree.
///
/// Mutation-proven 2026-09-11: drop the `verify_host_key` call from
/// `clone_or_fetch_with` and this goes green on the clone — `dest/.git` exists
/// and `HEAD` is the attacker-served commit, with nothing printed.
#[test]
fn a_changed_host_key_stops_the_clone_the_way_it_stops_a_fetch() {
assert!(tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NS, REPO, HashKind::Sha1);
let url = harness.url(NS, REPO);
// **The repository must have a commit in it.** An unborn remote takes
// `clone_or_fetch_with`'s `init_empty_clone` branch, which always went
// through `opts` and therefore always checked the host key — so a fixture
// with an empty repository would pass this file without ever reaching the
// path the finding is about.
push_one_commit(&harness, &url);
let poisoned = poisoned_known_hosts(harness.dir.path(), harness.ssh_port);
let opts = options(&harness, poisoned.clone());
let dest = harness.dir.path().join("clone-refused");
let err = gunnar_client::clone_or_fetch_with(&url, &dest, &opts)
.expect_err("a clone from a server whose host key changed was served");
// The chain, not the top line: `Error::Ssh`'s own Display is the one word
// `ssh`, and the sentence an operator has to act on is underneath it.
let message = gunnar_client::chain(&err);
assert!(
message.contains("host key") || message.contains("authenticate"),
"the refusal must be about the connection, not about git: {message}"
);
assert!(
!dest.join(".git").exists(),
"the clone was refused and a repository was written anyway: {}",
dest.display()
);
// The verb that always checked, against the same poisoned file, so this
// test measures the ASYMMETRY the finding is about rather than one verb in
// isolation. Both refuse now.
assert!(
gunnar_client::ls_remote_with(&url, &opts).is_err(),
"ls-remote has always refused a changed host key; if it stopped, this \
test would be proving nothing"
);
// The other direction, on the same server and the same key: an EMPTY
// known_hosts is trust-on-first-use, the clone goes through, and the key
// that was learned is the one this server actually holds. Without this the
// refusal above would be satisfied by a clone path that refused
// everything.
let clean = harness.dir.path().join("known_hosts.clean");
std::fs::write(&clean, "").expect("write an empty known_hosts");
let opts = options(&harness, clean.clone());
let dest = harness.dir.path().join("clone-allowed");
gunnar_client::clone_or_fetch_with(&url, &dest, &opts)
.expect("a clone from a server whose key is unknown must be learned and served");
assert!(
dest.join(".git").exists(),
"the allowed clone wrote nothing"
);
let learned = std::fs::read_to_string(&clean).expect("read known_hosts back");
assert!(
learned.contains(&format!("[127.0.0.1]:{}", harness.ssh_port)),
"trust-on-first-use must have recorded this server: {learned:?}"
);
// …and the recorded key is now enforced: a second clone against the
// POISONED file still refuses, so what was learned is a real constraint
// and not a file nothing reads.
let opts = options(&harness, poisoned);
let dest = harness.dir.path().join("clone-refused-again");
assert!(
gunnar_client::clone_or_fetch_with(&url, &dest, &opts).is_err(),
"a recorded-but-different host key must still stop the clone"
);
assert!(!dest.join(".git").exists());
harness.stop();
}