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/twin_serves_no_git.rs

//! **A twin serves no git — from the instant the role lands, not from the next
//! restart** (2026-09-21, lane T4).
//!
//! `serve::start` chooses the git listener's bind ONCE, from `stores.is_twin()`
//! at that instant: a twin gets `127.0.0.1:0`, everything else gets the
//! operator's address, and the reason is one line in `serve.rs` —
//! *"No forge listener on a twin. It serves no git to anyone: a read would grow
//! the caches the twin must not hold."*
//!
//! A box is made a twin over the wire, by `Bootstrap.BecomeTwin`, long after
//! that line ran. Under the owner's one-medium ruling that is the ONLY way a
//! twin is ever made, because a twin is installed from the same image as a main
//! with no per-box parameter to say which it is. MEASURED under KVM by lane T3,
//! 2026-09-21: a box conferred the role at runtime kept `0.0.0.0:2222` bound
//! for the rest of its life, the `this box is a TWIN` warning never printed,
//! and its volume ended up holding five `objects.exploded` cache files —
//! precisely the caches that comment exists to prevent.
//!
//! The answer taken here is the one that stops the harm now: the socket cannot
//! be moved without a restart, so the DOOR refuses instead — every git service,
//! read and write, keyed and anonymous, from the instant the flag flips. The
//! restart is reported separately (`BecomeTwinResponse.restart_required`) so
//! nothing pretends the socket is where it belongs.
//!
//! `Dispatch::authorise_service` is the fan-in SSH and smart-HTTP share and it
//! runs before a pack byte is read, so a refusal here IS "no git was served".
//!
//! To see these fail before the fix, delete the `if self.control.is_twin()`
//! block at the top of `Dispatch::authorise_service`: every `expect_err` below
//! turns into an allowed service on a twin.

mod common;

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use common::{mint_key, plane_with_key, principal, the_repo};
use gunnar_ssh::command::GitService;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::Access;

/// Every git service the door knows, so a later one cannot be added outside the
/// refusal by accident.
const SERVICES: &[GitService] = &[
    GitService::UploadPack,
    GitService::ReceivePack,
    GitService::LfsTransfer(gunnar_ssh::command::Operation::Download),
    GitService::LfsTransfer(gunnar_ssh::command::Operation::Upload),
];

#[test]
fn a_box_told_it_is_a_twin_while_it_runs_refuses_every_git_service_at_once_and_not_at_the_next_restart(
) {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Write);
    // The SAME flag `ServerStores` holds and `Bootstrap.BecomeTwin` flips: the
    // door must read it live, not copy it at start-up.
    let twin = Arc::new(AtomicBool::new(false));
    let dispatch = Dispatch::new(plane.control.with_twin_flag(Arc::clone(&twin)));

    // Before the role: the box is a primary and serves, exactly as it always did.
    for service in SERVICES {
        dispatch
            .authorise_service(&principal("principal-1"), None, *service, the_repo())
            .unwrap_or_else(|e| panic!("a primary must still serve {service:?}: {e:?}"));
    }

    // `Bootstrap.BecomeTwin` returns. Nothing restarted; nothing rebound.
    twin.store(true, Ordering::SeqCst);

    for service in SERVICES {
        let err = dispatch
            .authorise_service(&principal("principal-1"), None, *service, the_repo())
            .expect_err("a twin serves no git, and a READ is the one that grows the caches");
        let DispatchError::Twin {
            principal: who,
            repo,
        } = &err
        else {
            panic!("the refusal must be the twin's own arm — not a denial, not a standby: {err:?}");
        };
        assert_eq!(who, "principal-1");
        assert_eq!(*repo, the_repo());
        let line = err.client_message().expect("the client is told plainly");
        assert!(
            line.contains("TWIN") && line.contains("serves no git"),
            "{line}"
        );
        assert!(line.contains("primary"), "and where to go instead: {line}");
    }
}

#[test]
fn a_twin_refuses_a_stranger_too_even_though_become_twin_sets_the_browse_flag() {
    // `become_twin` sets `ServerStores::browse()`, and `Control::is_browse()`
    // OVERRIDES `--anonymous refuse`: without the twin gate a runtime-conferred
    // twin would serve anonymous reads even on a box configured to send
    // strangers elsewhere. The twin refusal runs first, before the anonymous
    // policy and before the catalog, so a stranger learns that this is a twin
    // and nothing whatever about what it holds.
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Read);
    let twin = Arc::new(AtomicBool::new(true));
    let browse = Arc::new(AtomicBool::new(true));
    let dispatch = Dispatch::new(
        plane
            .control
            .with_browse_flag(browse)
            .with_twin_flag(twin)
            .with_anonymous_policy(gunnar_ssh::AnonymousPolicy::Refuse { browse_host: None }),
    );

    let stranger = gunnar_core::Principal::anonymous();
    let err = dispatch
        .authorise_service(&stranger, None, GitService::UploadPack, the_repo())
        .expect_err("a stranger gets no git off a twin either");
    assert!(
        matches!(err, DispatchError::Twin { .. }),
        "the twin refusal runs BEFORE the anonymous policy and the browse override: {err:?}"
    );
    let line = err.client_message().expect("told plainly");
    assert!(
        !line.contains(&the_repo().to_string()),
        "and it names no repository: {line}"
    );
}