mod common;
use std::sync::{Arc, Mutex, OnceLock};
use common::*;
use gunnar_core::{Grant, Level};
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, GunnarSsh, SshConfig, FIRE_DRILL_VERB};
type Said = Arc<Mutex<Vec<(tracing::Level, String)>>>;
fn said() -> &'static Said {
static SAID: OnceLock<Said> = OnceLock::new();
SAID.get_or_init(|| {
use tracing_subscriber::layer::SubscriberExt;
let lines: Said = Arc::default();
struct Capture(Said);
struct Text(String);
impl tracing::field::Visit for Text {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.push_str(&format!(" {}={:?}", field.name(), value));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push_str(&format!(" {}={}", field.name(), value));
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
let mut t = Text(String::new());
event.record(&mut t);
self.0
.lock()
.unwrap()
.push((*event.metadata().level(), t.0));
}
}
let _ = tracing::subscriber::set_global_default(
tracing_subscriber::registry().with(Capture(Arc::clone(&lines))),
);
lines
})
}
fn lines_with(needle: &str) -> Vec<(tracing::Level, String)> {
said()
.lock()
.unwrap()
.iter()
.filter(|(_, l)| l.contains(needle))
.cloned()
.collect()
}
async fn serve(plane: Plane) -> gunnar_ssh::BoundServer {
let transport = Arc::new(BannerTransport::new());
GunnarSsh::new(SshConfig::ephemeral_loopback(), plane.control, transport)
.bind(mint_key())
.await
.expect("bind")
}
#[tokio::test]
async fn a_second_exec_on_one_channel_is_a_warn_once_per_connection_never_an_error() {
said();
let key = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("reader")
.with_key("reader", &key)
.with_grant("reader", Access::Read);
let (transport, hold) = BannerTransport::holding();
let server = GunnarSsh::new(
SshConfig::ephemeral_loopback(),
plane.control,
Arc::new(transport),
)
.bind(mint_key())
.await
.expect("bind");
let command = format!("git-upload-pack '{}'", the_repo());
let within = std::time::Duration::from_secs(30);
for connection in 0..2 {
let s = authenticate(server.local_addr(), &key, "git")
.await
.unwrap()
.expect("key accepted");
let mut ch = s.channel_open_session().await.expect("session");
ch.exec(true, command.as_bytes()).await.expect("exec sent");
let (mut accepted, mut banner) = (false, false);
while !(accepted && banner) {
match tokio::time::timeout(within, ch.wait())
.await
.expect("the first exec answered")
{
Some(russh::ChannelMsg::Success) => accepted = true,
Some(russh::ChannelMsg::Data { .. }) => banner = true,
Some(russh::ChannelMsg::Failure) => {
panic!("connection {connection}: the first exec was refused")
}
Some(_) => {}
None => panic!("connection {connection}: the channel closed under the first exec"),
}
}
for _ in 0..2 {
ch.exec(true, command.as_bytes()).await.expect("exec sent");
}
let mut refused = 0;
while refused < 2 {
match tokio::time::timeout(within, ch.wait())
.await
.expect("the misuses answered")
{
Some(russh::ChannelMsg::Failure) => refused += 1,
Some(russh::ChannelMsg::Success) => {
panic!("connection {connection}: a second exec on one channel was accepted")
}
Some(_) => {}
None => panic!(
"connection {connection}: the channel closed before the misuses were answered"
),
}
}
let _ = s
.disconnect(russh::Disconnect::ByApplication, "", "en")
.await;
}
hold.release();
let misuse = lines_with("exec request for a channel");
let errors: Vec<_> = misuse
.iter()
.filter(|(l, _)| *l == tracing::Level::ERROR)
.collect();
assert!(
errors.is_empty(),
"a client's misuse raised the server's ERROR: {errors:?}"
);
let warns: Vec<_> = misuse
.iter()
.filter(|(l, _)| *l == tracing::Level::WARN)
.collect();
assert_eq!(
warns.len(),
2,
"one WARN per connection (2 connections × 2 misuses each): {misuse:?}"
);
}
#[tokio::test]
async fn a_server_admin_raises_one_fire_drill_error_and_nobody_else_can() {
said();
let admin = mint_key();
let reader = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("admin")
.with_key("admin", &admin)
.with_principal("reader")
.with_key("reader", &reader)
.with_grant("reader", Access::Admin);
plane
.auth()
.grant(Grant::server("admin", Level::Admin))
.expect("server admin");
let server = serve(plane).await;
let r = authenticate(server.local_addr(), &reader, "git")
.await
.unwrap()
.expect("reader");
match exec(&r, FIRE_DRILL_VERB).await.unwrap() {
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(
exit_status,
Some(gunnar_ssh::server::EXIT_DENIED),
"{stdout}"
);
assert!(stdout.contains("server-wide Admin"), "{stdout}");
}
ExecOutcome::Refused => {}
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let drills = || {
lines_with("fire drill")
.into_iter()
.filter(|(l, _)| *l == tracing::Level::ERROR)
.collect::<Vec<_>>()
};
assert!(
drills().is_empty(),
"a repository admin raised the drill: {:?}",
drills()
);
let s = authenticate(server.local_addr(), &admin, "git")
.await
.unwrap()
.expect("admin");
match exec(&s, FIRE_DRILL_VERB).await.unwrap() {
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(0), "{stdout}");
assert!(stdout.contains("fire drill"), "{stdout}");
}
other => panic!("a server admin must reach the drill: {other:?}"),
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let d = drills();
assert_eq!(d.len(), 1, "exactly one ERROR for one drill: {d:?}");
assert!(d[0].1.contains("admin"), "the drill names who asked: {d:?}");
}