gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! The `env` allowlist, over a real SSH connection.
//!
//! gunnar accepts exactly one environment variable, `GIT_PROTOCOL` (which is
//! how a git client asks for protocol v2 over SSH), and refuses every other
//! name. Both halves are asserted here, because an allowlist that refuses
//! *everything* would pass a test that only ever checked `LD_PRELOAD`, and one
//! that accepts everything would pass a test that only ever checked
//! `GIT_PROTOCOL`.
//!
//! The value is asserted where it actually has to arrive: on the
//! [`ServiceRequest`](gunnar_ssh::ServiceRequest) that reaches the wire seam.
//! Asserting that the server said "success" would prove only that a message was
//! answered, not that anything was carried.

mod common;

use gunnar_ssh::Access;

use common::{authenticate, exec, exec_with_env, mint_key, start_with_key, EnvOutcome};

const UPLOAD_PACK: &str = "git-upload-pack 'nordisk/osm-katana.git'";

#[tokio::test]
async fn git_protocol_is_accepted_and_reaches_the_wire_seam() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let (env, outcome) = exec_with_env(&session, &[("GIT_PROTOCOL", "version=2")], UPLOAD_PACK)
        .await
        .expect("exec");

    assert_eq!(
        env,
        vec![EnvOutcome::Accepted],
        "GIT_PROTOCOL must be accepted; it is how a client asks for v2"
    );
    assert!(
        matches!(&outcome, common::ExecOutcome::Accepted { .. }),
        "{outcome:?}"
    );
    // Applied output: the value the wire seam was handed, not the message the
    // client got back.
    assert_eq!(
        fixture.transport.git_protocols(),
        vec![Some("version=2".to_string())],
        "the allowlisted value must reach the seam, or accepting it changes nothing"
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_variable_that_is_not_on_the_allowlist_is_refused() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    // The ones that would matter if gunnar ever executed anything, plus the
    // git-shaped ones a client might reasonably try.
    let hostile = [
        ("LD_PRELOAD", "/tmp/evil.so"),
        ("LD_LIBRARY_PATH", "/tmp"),
        ("PATH", "/tmp/bin"),
        ("GIT_DIR", "/etc"),
        ("GIT_ALTERNATE_OBJECT_DIRECTORIES", "/etc"),
        ("GIT_CONFIG", "/etc/passwd"),
        ("RUST_LOG", "trace"),
        // Near misses: an allowlist matched with `starts_with` or
        // case-insensitively would let these through.
        ("GIT_PROTOCOL_", "version=2"),
        ("git_protocol", "version=2"),
        ("XGIT_PROTOCOL", "version=2"),
    ];

    for (name, value) in hostile {
        let (env, _) = exec_with_env(&session, &[(name, value)], UPLOAD_PACK)
            .await
            .expect("exec");
        assert_eq!(
            env,
            vec![EnvOutcome::Refused],
            "{name} was not refused; gunnar's environment is an allowlist of one"
        );
    }

    // And none of them was quietly filed as a protocol announcement either.
    assert!(
        fixture
            .transport
            .git_protocols()
            .iter()
            .all(|value| value.is_none()),
        "a refused variable reached the seam anyway: {:?}",
        fixture.transport.git_protocols()
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// **The v0 half.** A client that sends nothing must still be served, and the
/// seam must see `None` rather than a value someone defaulted in.
///
/// Without this, "the server speaks v2" and "the server always speaks v2" look
/// identical.
#[tokio::test]
async fn a_client_that_sends_no_environment_carries_no_protocol() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, UPLOAD_PACK).await.expect("exec");
    assert!(
        matches!(&outcome, common::ExecOutcome::Accepted { .. }),
        "{outcome:?}"
    );
    assert_eq!(
        fixture.transport.git_protocols(),
        vec![None],
        "a session with no GIT_PROTOCOL must reach the seam with no protocol at all"
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// An allowlisted *name* is not a licence to store an unbounded *value*.
#[tokio::test]
async fn an_implausibly_long_value_is_refused() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let huge = "version=2:".repeat(1024);
    assert!(huge.len() > gunnar_ssh::server::MAX_ENV_VALUE);
    let (env, _) = exec_with_env(&session, &[("GIT_PROTOCOL", &huge)], UPLOAD_PACK)
        .await
        .expect("exec");
    assert_eq!(env, vec![EnvOutcome::Refused], "{env:?}");
    assert_eq!(fixture.transport.git_protocols(), vec![None]);

    // The boundary from the other side, so the cap is seen to admit as well as
    // to refuse: one byte under the limit is accepted.
    let big = "v".repeat(gunnar_ssh::server::MAX_ENV_VALUE);
    let (env, _) = exec_with_env(&session, &[("GIT_PROTOCOL", &big)], UPLOAD_PACK)
        .await
        .expect("exec");
    assert_eq!(env, vec![EnvOutcome::Accepted], "{env:?}");

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// The allowlist is what the tests above assume it is.
///
/// A one-line guard against the failure where someone adds a name to
/// `ENV_ALLOWLIST` and every test here keeps passing while the server has
/// quietly grown a second input.
#[test]
fn the_allowlist_holds_exactly_one_name() {
    assert_eq!(
        gunnar_ssh::ENV_ALLOWLIST.as_slice(),
        ["GIT_PROTOCOL"],
        "gunnar accepts one environment variable; adding another is a security decision"
    );
    assert_eq!(gunnar_ssh::ENV_GIT_PROTOCOL, "GIT_PROTOCOL");
}

/// The routine/anomalous split ([`gunnar_ssh::ENV_ROUTINE`]) grants nothing and
/// keeps every name an attacker would try at `warn`.
///
/// MEASURED on the reference desktop 2026-08-05: an 18-repository ingest over real SSH with stock
/// git 2.53.0, 203 connections, `RUST_LOG=info` — **406 WARN lines, 406 of them
/// (100%) `ssh.env.refused` for `LANG` and `COLORTERM`**, and not one warning of
/// any other kind in the whole run. OpenSSH's shipped `ssh_config` carries
/// `SendEnv LANG LC_*`, so every `git` over `ssh` produces two before it has
/// asked for anything, and an operator grepping `WARN` on a live gunnar reads
/// noise at a 100% false-positive rate.
///
/// Both directions are asserted. A split that classified everything as routine
/// would pass a test that only checked `LANG`, and it would silence exactly the
/// lines that are worth waking someone for.
#[test]
fn the_routine_env_refusals_are_the_ones_openssh_sends_unasked() {
    for sent_by_every_client in ["LANG", "LC_ALL", "LC_CTYPE", "LC_TIME", "COLORTERM", "TERM"] {
        assert!(
            gunnar_ssh::env_refusal_is_routine(sent_by_every_client),
            "{sent_by_every_client} is sent by stock OpenSSH without being asked, so refusing \
             it is not an anomaly and must not warn"
        );
    }
    for worth_waking_someone_for in [
        "LD_PRELOAD",
        "LD_LIBRARY_PATH",
        "PATH",
        "GIT_DIR",
        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
        "GIT_CONFIG",
        "RUST_LOG",
        // A near miss: a prefix match would let this through as routine.
        "LANGUAGES_I_MADE_UP",
    ] {
        assert!(
            !gunnar_ssh::env_refusal_is_routine(worth_waking_someone_for),
            "{worth_waking_someone_for} is not something a client sends by accident; refusing \
             it must stay at warn"
        );
    }
    // And the split changes the LEVEL only: the allowlist is still one name.
    assert_eq!(gunnar_ssh::ENV_ALLOWLIST.as_slice(), ["GIT_PROTOCOL"]);
    for routine in gunnar_ssh::ENV_ROUTINE {
        assert!(
            !gunnar_ssh::ENV_ALLOWLIST.contains(&routine),
            "{routine} is quieter, not permitted"
        );
    }
}