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

nordisk/gunnar

xtask/tests/ssh_server_advisory_floor.rs

//! **Gunnar's SSH server resolves no russh with a known advisory** (lane OSSF-NEXT,
//! 2026-10-30).
//!
//! `gunnar-ssh` is the network-facing SSH server. russh below 0.64.0 accepted a
//! publickey authentication whose signature was made with another algorithm than the
//! one offered (GHSA-j7h2-66j4-ghjf), and below 0.64.1 a crafted agent constraint
//! allocated without a bound (GHSA-4wc5-f2rc-q74m). The four russh advisories of
//! 2026-10-30 are fixed only in 0.65.0, and GHSA-fw5c-8223-6h42 hits servers too: a peer
//! that declares a zero maximum packet size stalls the channel's writes for good
//! (GHSA-4p9f-7p9m-5vfg, GHSA-9wfm-jcj8-jfv9 and GHSA-vp5q-ff62-jwqv are the client's
//! EXT_INFO and the agent server's). The test reads every committed lock
//! that builds `gunnar-ssh` and asks which russh THAT package links: a `^0.64`
//! requirement over a stale lock would still ship the old one.
//!
//! The root lock may carry a second, older russh until edda's nornir-git 0.2.3 (the
//! client's git transport, on russh 0.64.1 since edda b02925f) is on crates.io; this
//! test is about the server's russh, and the client side is the vulnerability gate's.

use std::path::{Path, PathBuf};

const FLOOR: (u64, u64, u64) = (0, 65, 0);

/// The advisories under [`FLOOR`].
const UNDER_FLOOR: &str = "GHSA-j7h2-66j4-ghjf, GHSA-4wc5-f2rc-q74m, GHSA-fw5c-8223-6h42, \
                           GHSA-4p9f-7p9m-5vfg, GHSA-9wfm-jcj8-jfv9, GHSA-vp5q-ff62-jwqv";

fn parse(v: &str) -> (u64, u64, u64) {
    let core = v.split(['-', '+']).next().unwrap_or(v);
    let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
    (
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
    )
}

/// The packages of a lock: (name, version, dependency lines).
fn packages(lock: &str) -> Vec<(String, String, Vec<String>)> {
    let mut out: Vec<(String, String, Vec<String>)> = Vec::new();
    let mut in_deps = false;
    for line in lock.lines() {
        let l = line.trim();
        if l == "[[package]]" {
            out.push((String::new(), String::new(), Vec::new()));
            in_deps = false;
        } else if let Some(cur) = out.last_mut() {
            if let Some(v) = l.strip_prefix("name = ") {
                cur.0 = v.trim_matches('"').to_string();
            } else if let Some(v) = l.strip_prefix("version = ") {
                cur.1 = v.trim_matches('"').to_string();
            } else if l.starts_with("dependencies = [") {
                in_deps = true;
            } else if in_deps && l == "]" {
                in_deps = false;
            } else if in_deps {
                cur.2
                    .push(l.trim_end_matches(',').trim_matches('"').to_string());
            }
        }
    }
    out
}

/// The russh version `pkg` links in `lock`: its dependency line names it
/// (`russh` alone when the lock has one russh, `russh 0.64.1` when it has two).
fn russh_of(lock: &str, pkg: &str) -> Option<String> {
    let pk = packages(lock);
    let dep = pk
        .iter()
        .find(|p| p.0 == pkg)?
        .2
        .iter()
        .find(|d| d.split(' ').next() == Some("russh"))?
        .clone();
    match dep.split(' ').nth(1) {
        Some(v) => Some(v.to_string()),
        None => pk.iter().find(|p| p.0 == "russh").map(|p| p.1.clone()),
    }
}

fn locks_with(root: &Path, pkg: &str) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(d) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&d) else {
            continue;
        };
        for e in rd.flatten() {
            let p = e.path();
            let n = e.file_name().to_string_lossy().into_owned();
            if p.is_dir() {
                if !matches!(n.as_str(), "target" | ".git" | "fixtures" | "vendor") {
                    stack.push(p);
                }
            } else if n == "Cargo.lock"
                && std::fs::read_to_string(&p)
                    .is_ok_and(|s| s.contains(&format!("name = \"{pkg}\"")))
            {
                out.push(p);
            }
        }
    }
    out.sort();
    out
}

#[test]
fn the_reader_sees_which_russh_a_package_links() {
    let two = "[[package]]\nname = \"russh\"\nversion = \"0.63.3\"\n\n[[package]]\nname = \"russh\"\nversion = \"0.64.1\"\n\n[[package]]\nname = \"gunnar-ssh\"\nversion = \"2.0.0\"\ndependencies = [\n \"russh 0.63.3\",\n \"tokio\",\n]\n";
    assert_eq!(russh_of(two, "gunnar-ssh").as_deref(), Some("0.63.3"));
    let one = "[[package]]\nname = \"russh\"\nversion = \"0.64.1\"\n\n[[package]]\nname = \"gunnar-ssh\"\nversion = \"2.0.0\"\ndependencies = [\n \"russh\",\n]\n";
    assert_eq!(russh_of(one, "gunnar-ssh").as_deref(), Some("0.64.1"));
    assert!(parse("0.63.3") < FLOOR && parse("0.64.1") < FLOOR && parse("0.65.0") >= FLOOR);
}

#[test]
fn every_lock_builds_the_ssh_server_on_a_russh_without_advisories() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
    let locks = locks_with(&root, "gunnar-ssh");
    assert!(
        locks
            .iter()
            .any(|l| l.parent().and_then(|p| p.canonicalize().ok()) == root.canonicalize().ok()),
        "the root lock builds gunnar-ssh: {locks:?}"
    );
    let mut bad = Vec::new();
    for lock in &locks {
        let text = std::fs::read_to_string(lock).unwrap();
        for pkg in ["gunnar-ssh", "gunnar-client"] {
            if let Some(v) = russh_of(&text, pkg) {
                if parse(&v) < FLOOR {
                    bad.push(format!(
                        "{}: {pkg} links russh {v} (< 0.65.0: {UNDER_FLOOR})",
                        lock.display()
                    ));
                }
            }
        }
    }
    assert!(bad.is_empty(), "{}", bad.join("\n"));
}