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

nordisk/gunnar

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])
}