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

nordisk/gunnar

crates/gunnar-ssh/tests/repo_verb.rs

//! **`gunnar-repo`: a repository's settings over the forge's SSH door**
//! (owner, 2026-10-14). The listener proves who asks and carries the answer;
//! the door (`gunnar-server`'s `Repos` handlers) decides. These tests hold the
//! listener's half: the authenticated principal reaches the door, an anonymous
//! session never does, a malformed line is the verb's usage, and a server
//! without a door says so.

mod common;

use std::sync::{Arc, Mutex};

use common::*;
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, GunnarSsh, RepoAction, RepoAsk, RepoDoor, SshConfig, REPO_VERB};

fn recording_door(heard: Arc<Mutex<Vec<RepoAsk>>>) -> RepoDoor {
    Arc::new(move |ask: RepoAsk| {
        let heard = Arc::clone(&heard);
        Box::pin(async move {
            let allowed = ask.principal == "alice";
            heard.lock().unwrap().push(ask.clone());
            if allowed {
                Ok(format!(
                    "repo {}/{}\nvisibility public\n",
                    ask.repo.namespace(),
                    ask.repo.name()
                ))
            } else {
                Err(format!(
                    "{} may not change {}/{}",
                    ask.principal,
                    ask.repo.namespace(),
                    ask.repo.name()
                ))
            }
        })
    })
}

async fn serve(plane: Plane, door: Option<RepoDoor>) -> gunnar_ssh::BoundServer {
    let transport = Arc::new(BannerTransport::new());
    let ssh = GunnarSsh::new(SshConfig::ephemeral_loopback(), plane.control, transport);
    let ssh = match door {
        Some(door) => ssh.with_repo_door(door),
        None => ssh,
    };
    ssh.bind(mint_key()).await.expect("bind")
}

#[tokio::test]
async fn the_door_hears_who_asked_and_its_answer_or_refusal_comes_back() {
    let (alice, bob) = (mint_key(), mint_key());
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("alice")
        .with_key("alice", &alice)
        .with_grant("alice", Access::Write)
        .with_principal("bob")
        .with_key("bob", &bob)
        .with_grant("bob", Access::Read);
    let heard = Arc::new(Mutex::new(Vec::new()));
    let server = serve(plane, Some(recording_door(Arc::clone(&heard)))).await;
    let repo = the_repo();
    let path = format!("'{}/{}.git'", repo.namespace(), repo.name());

    let a = authenticate(server.local_addr(), &alice, "git")
        .await
        .unwrap()
        .expect("alice's key");
    match exec(&a, &format!("{REPO_VERB} visibility {path} public"))
        .await
        .unwrap()
    {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert_eq!(exit_status, Some(0), "{stdout}");
            assert!(stdout.contains("visibility public"), "{stdout}");
        }
        other => panic!("alice's change must reach the door: {other:?}"),
    }
    let b = authenticate(server.local_addr(), &bob, "git")
        .await
        .unwrap()
        .expect("bob's key");
    match exec(
        &b,
        &format!("{REPO_VERB} default-branch {path} 'refs/heads/main'"),
    )
    .await
    .unwrap()
    {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
            assert!(stdout.starts_with("error: bob may not change"), "{stdout}");
        }
        other => panic!("{other:?}"),
    }
    let heard = heard.lock().unwrap().clone();
    assert_eq!(heard.len(), 2, "{heard:?}");
    assert_eq!(heard[0].principal, "alice");
    assert_eq!(heard[0].action, RepoAction::Visibility(true));
    assert!(
        !heard[0].fingerprint.is_empty(),
        "the key's fingerprint rides with the ask"
    );
    assert_eq!(heard[1].principal, "bob");
    assert_eq!(
        heard[1].action,
        RepoAction::DefaultBranch("refs/heads/main".into())
    );
}

#[tokio::test]
async fn a_malformed_line_is_the_verbs_usage_and_never_reaches_the_door() {
    let alice = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("alice")
        .with_key("alice", &alice)
        .with_grant("alice", Access::Write);
    let heard = Arc::new(Mutex::new(Vec::new()));
    let server = serve(plane, Some(recording_door(Arc::clone(&heard)))).await;
    let a = authenticate(server.local_addr(), &alice, "git")
        .await
        .unwrap()
        .expect("alice's key");
    for line in [
        format!("{REPO_VERB} visibility 'nordisk/osm-katana.git' maybe"),
        format!("{REPO_VERB} rename 'nordisk/osm-katana.git' x"),
        format!("{REPO_VERB} show"),
    ] {
        match exec(&a, &line).await.unwrap() {
            ExecOutcome::Accepted {
                stdout,
                exit_status,
            } => {
                assert_eq!(exit_status, Some(1), "{line}: {stdout}");
                assert!(stdout.starts_with("error: "), "{line}: {stdout}");
            }
            other => panic!("{line}: {other:?}"),
        }
    }
    assert!(
        heard.lock().unwrap().is_empty(),
        "a malformed line reached the door"
    );
}

#[tokio::test]
async fn a_server_with_no_door_says_it_answers_settings_only_on_its_control_plane() {
    let alice = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("alice")
        .with_key("alice", &alice)
        .with_grant("alice", Access::Write);
    let server = serve(plane, None).await;
    let a = authenticate(server.local_addr(), &alice, "git")
        .await
        .unwrap()
        .expect("alice's key");
    match exec(&a, &format!("{REPO_VERB} show 'nordisk/osm-katana.git'"))
        .await
        .unwrap()
    {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert_eq!(exit_status, Some(1), "{stdout}");
            assert!(stdout.contains("only on its control plane"), "{stdout}");
        }
        other => panic!("{other:?}"),
    }
}

#[test]
fn the_parser_reads_the_three_asks() {
    use gunnar_ssh::parse_repo_verb;
    let (repo, action) = parse_repo_verb(b"gunnar-repo show 'nordisk/knut.git'")
        .unwrap()
        .unwrap();
    assert_eq!(
        (repo.namespace(), repo.name(), action),
        ("nordisk", "knut", RepoAction::Show)
    );
    let (_, action) = parse_repo_verb(b"gunnar-repo visibility 'nordisk/knut' private")
        .unwrap()
        .unwrap();
    assert_eq!(action, RepoAction::Visibility(false));
    let (_, action) =
        parse_repo_verb(b"gunnar-repo default-branch 'nordisk/knut' 'refs/heads/main'")
            .unwrap()
            .unwrap();
    assert_eq!(action, RepoAction::DefaultBranch("refs/heads/main".into()));
    let (_, action) = parse_repo_verb(b"gunnar-repo create 'mikronova/tunnr' sha256")
        .unwrap()
        .unwrap();
    assert_eq!(action, RepoAction::Create { sha256: true });
    let (_, action) = parse_repo_verb(b"gunnar-repo create 'mikronova/tunnr' sha1")
        .unwrap()
        .unwrap();
    assert_eq!(action, RepoAction::Create { sha256: false });
    assert!(parse_repo_verb(b"gunnar-repo create 'mikronova/tunnr' md5")
        .unwrap()
        .is_err());
    assert!(
        parse_repo_verb(b"gunnar-whoami").is_none(),
        "another verb is not this one"
    );
    assert!(
        parse_repo_verb(b"gunnar-repo visibility 'nordisk/knut' world")
            .unwrap()
            .is_err()
    );
}