crates/gunnar-cli/tests/common/served_binary.rs
//! **The binary-driving harness: a real `gunnar serve` child, driven by the
//! real `gunnar` binary.**
//!
//! Extracted from `tests/second_key.rs`, where it grew, so that
//! `grants_admin_cli.rs` did not become the third copy (one implementation) —
//! `control_cli.rs` was already the second-and-a-half. Include it with
//!
//! ```text
//! #[path = "common/served_binary.rs"]
//! mod served_binary;
//! ```
//!
//! and NOT via `mod common;`, which would drag the in-process SSH harness and
//! its tracing capture into a test binary that drives processes instead.
//!
//! Everything here is the real thing: the server is a spawned child on
//! ephemeral loopback ports, the client is the built binary, and [`Ran`]
//! separates stdout from the merged streams because `control.rs` announces
//! which key it signs with **on stderr** — a count taken over both streams
//! counts that announcement's `SHA256:` as a credential row, which is exactly
//! the miscount that broke `second_key.rs` on 2026-08-31.
#![allow(dead_code)]
use std::io::BufRead as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
pub fn gunnar_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_gunnar-cli"))
}
/// A `gunnar serve` on a data root **the test owns**, so it can be stopped and
/// started again on the same state.
pub struct Serving {
pub child: Child,
pub ssh: String,
/// The gRPC address as the banner announced it — or the literal
/// `"disabled"` from a `--no-grpc` server, which prints
/// `grpc disabled` and binds nothing.
pub grpc: String,
}
impl Serving {
pub fn start(data_root: &Path, home: &Path, log: &Path) -> Serving {
Self::start_with(data_root, home, log, &[])
}
/// [`Serving::start`] with extra `serve` flags — `--no-grpc`,
/// `--admin-key @…`, whatever the test is about.
pub fn start_with(data_root: &Path, home: &Path, log: &Path, extra: &[&str]) -> Serving {
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.expect("open the server log");
let mut child = Command::new(gunnar_binary())
.arg("--data-root")
.arg(data_root)
.arg("serve")
.arg("--ssh-bind")
.arg("127.0.0.1:0")
.arg("--grpc-bind")
.arg("127.0.0.1:0")
.args(extra)
.env("HOME", home)
.env("RUST_LOG", "info")
.stdout(Stdio::piped())
.stderr(Stdio::from(log))
.spawn()
.expect("spawn gunnar serve");
let stdout = child.stdout.take().expect("piped");
let mut reader = std::io::BufReader::new(stdout);
let (mut ssh, mut grpc) = (None, None);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while std::time::Instant::now() < deadline && (ssh.is_none() || grpc.is_none()) {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if let Some(rest) = line.strip_prefix("ssh ") {
let addr = rest.split_whitespace().next().unwrap_or_default();
if !addr.is_empty() && addr != "disabled" {
ssh = Some(addr.to_owned());
}
}
if let Some(rest) = line.strip_prefix("grpc ") {
// `disabled` is KEPT rather than skipped: a `--no-grpc` server
// announces it, and a test about that server has to be able to
// see the announcement instead of timing out here.
let addr = rest.split_whitespace().next().unwrap_or_default();
if !addr.is_empty() {
grpc = Some(addr.to_owned());
}
}
}
// Keep draining for the server's life: Rust ignores SIGPIPE, so a
// `println!` into a closed pipe panics the server. `control_cli.rs`
// learned that as a 1-in-8 flake that looked like a server bug.
std::thread::spawn(move || {
let mut sink = String::new();
let _ = std::io::Read::read_to_string(&mut reader, &mut sink);
});
Serving {
child,
ssh: ssh.expect("gunnar serve never announced an ssh address"),
grpc: grpc.expect("gunnar serve never announced a gRPC address"),
}
}
pub fn stop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for Serving {
fn drop(&mut self) {
self.stop();
}
}
pub struct Ran {
pub ok: bool,
/// Both streams, for reporting a failure and for [`Ran::says`]: a message
/// may legitimately arrive on either, and a panic that showed only half of
/// what the command said would be worse than useless.
pub out: String,
/// **stdout alone — the verb's answer.**
///
/// `control.rs` puts its diagnostics on stderr *on purpose*, with the
/// reason written next to them: "stderr, so nothing that parses the verb's
/// stdout changes shape". Anything here that counts or parses rows must
/// read this field, or it is counting the diagnostics too — see
/// `second_key.rs::keys_of`, which did exactly that and went red the day
/// the CLI started announcing which key it signs with.
pub stdout: String,
}
impl Ran {
pub fn says(&self, needle: &str) -> bool {
self.out.contains(needle)
}
}
pub fn gunnar(home: &Path, args: &[&str]) -> Ran {
let out = Command::new(gunnar_binary())
.args(args)
.env("HOME", home)
.env_remove("GUNNAR_CONTROL_ADDR")
.env_remove("GUNNAR_SSH_KEY")
.env("SSH_AUTH_SOCK", "")
.output()
.expect("run gunnar");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr);
Ran {
ok: out.status.success(),
out: format!("{stdout}{stderr}"),
stdout,
}
}
pub fn mint(dir: &Path, name: &str) -> (PathBuf, PathBuf) {
let private = gunnar_core::mint_ed25519().expect("mint");
let parsed =
gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
let public = parsed.public_key().to_openssh().expect("render");
let priv_path = dir.join(name);
let pub_path = dir.join(format!("{name}.pub"));
std::fs::write(&priv_path, &private).expect("write");
std::fs::write(&pub_path, format!("{public}\n")).expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).expect("0600");
}
(priv_path, pub_path)
}
/// Can this key reach that repository over **real SSH**, as the principal that
/// owns it?
///
/// `ls-remote` and not a control-plane call, because the control plane and the
/// SSH listener are two readers of one table and only the second is the door
/// the world comes through. `-i` now means *exactly this key* (see
/// `tests/named_identity.rs`), so this cannot silently answer for another one.
pub fn ssh_reaches(home: &Path, ssh_addr: &str, key: &Path, url_path: &str) -> Ran {
let url = format!("ssh://git@{ssh_addr}/{url_path}.git");
gunnar(home, &["ls-remote", "-i", key.to_str().unwrap(), &url])
}