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}"
);
}