crates/gunnar-cli/tests/common/mod.rs
//! A whole gunnar server, on a temp directory, reachable over real SSH.
//!
//! Nothing here mocks anything. The stores are the real `fs` backend, the
//! listener is the real russh server, the identity table is the real one, and
//! the client in every test that uses this is the **stock `git` binary**,
//! because the only honest proof that a git server works is that git talks to
//! it. A test in which gunnar's client agrees with gunnar's server proves
//! nothing; both halves can be wrong the same way.
#![allow(dead_code)]
/// The `tracing` capture the observability suite asserts against.
pub mod capture;
/// A member of an encrypted repository (HOME, key, principal).
#[cfg(feature = "client")]
pub mod member;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use gunnar_cli::server::{ServeConfig, Server, ServerStores, WireConfig};
use gunnar_core::{Grant, HashKind, Level, NewRepo, Principal, PrincipalKind, RepoRef, Visibility};
/// How a [`Harness`] should be brought up.
///
/// A struct rather than four positional `bool`s: `start_with(ns, repo, hash,
/// true, false, true)` is unreadable at the call site and a swapped pair would
/// quietly test the wrong server.
#[derive(Debug, Clone, Copy)]
pub struct HarnessOptions {
/// Bind the gRPC control plane too. Off by default: most tests are
/// about the git wire, and leaving it off keeps them from competing for a
/// port.
pub grpc: bool,
/// Require a **client certificate** on the gRPC control plane, minted from
/// this server's own issuer (`--grpc-tls-self`, the control plane's mutual TLS).
///
/// Implies `grpc`. Off by default so that every existing gRPC test keeps
/// exercising the shipped loopback shape: a suite that quietly moved to
/// mTLS would stop proving that the metadata credential is what the default
/// deployment authenticates with.
pub grpc_tls: bool,
/// Whether the repository is created [`Visibility::Public`], readable with
/// no key at all.
pub visibility: Visibility,
/// Whether the SSH listener admits an unauthenticated caller at all.
/// The **shipped default is `true`**, and the harness keeps it, so a test
/// that does not care is exercising the configuration an appliance runs.
pub allow_anonymous: bool,
/// `--anonymous allow|refuse` (the browse replica): whether an
/// anonymous read is served here or sent to the browse replica. A `Copy`
/// stand-in for `AnonymousPolicy` (whose host is a `String`), so the
/// options struct stays `Copy` for every test that reuses one.
pub anonymous: Anonymous,
/// Also bind the smart-HTTP listener on an ephemeral loopback port.
#[cfg(feature = "http")]
pub http: bool,
/// Also bind the scrape endpoint on an ephemeral loopback port.
///
/// NOT behind `#[cfg(feature = "http")]`, and that is the point of it: the
/// endpoint is in the `server` feature because the shipped appliance atom
/// is, so a suite that could only reach it with the git HTTP transport
/// compiled in would be testing a different build from the one that ships.
pub metrics: bool,
/// What identities and repositories the server starts with.
pub seed: Seed,
/// `--disk-floor`, when a test must name it. `None` keeps
/// `ServeConfig`'s own default (`5%,2GiB`), so every test that does not
/// care goes on exercising the shipped gate.
pub disk_floor: Option<gunnar_ssh::DiskFloor>,
/// Where the server's data root lives. `None` is `<tempdir>/data`; a test
/// about a FULL data set points it at a filesystem of its own size (a
/// tmpfs with a cap), so the disk that fills is the server's alone.
pub data_root: Option<&'static std::path::Path>,
/// **Measure the data set as a disk of this many bytes** — its capacity
/// less what the data root holds ([`VirtualDisk`]) — instead of the
/// filesystem it is on. How a test fills a data set of a stated size with
/// no privileges and no mount: the floor and the space ledger read it,
/// the bytes still land on the real disk.
pub virtual_capacity: Option<u64>,
}
/// **A data set that is only a number**: `capacity` bytes, of which what the
/// data root's files occupy (allocated blocks, as `du` counts them) is used.
#[derive(Debug)]
pub struct VirtualDisk {
pub root: PathBuf,
pub capacity: u64,
}
impl VirtualDisk {
/// Bytes the tree under `root` occupies: `st_blocks × 512`, summed. A file
/// that vanishes mid-walk (a spool dropped) counts as gone.
pub fn used(root: &Path) -> u64 {
use std::os::unix::fs::MetadataExt;
let mut total = 0u64;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(meta) = entry.path().symlink_metadata() else {
continue;
};
if meta.is_dir() {
stack.push(entry.path());
} else {
total += meta.blocks() * 512;
}
}
}
total
}
}
impl gunnar_ssh::SpaceProbe for VirtualDisk {
fn measure(&self) -> std::io::Result<(u64, u64)> {
Ok((
self.capacity.saturating_sub(Self::used(&self.root)),
self.capacity,
))
}
}
/// How a [`Harness`] seeds its identity table and catalog.
/// See [`HarnessOptions::anonymous`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Anonymous {
/// Serve anonymous reads here (the shipped default).
Allow,
/// Refuse them, naming the browse host when given.
Refuse { browse_host: Option<&'static str> },
}
impl Anonymous {
fn policy(self) -> gunnar_cli::server::serve::AnonymousPolicy {
use gunnar_cli::server::serve::AnonymousPolicy;
match self {
Anonymous::Allow => AnonymousPolicy::Allow,
Anonymous::Refuse { browse_host } => AnonymousPolicy::Refuse {
browse_host: browse_host.map(str::to_owned),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Seed {
/// One repository, one `tester` principal holding Write on it plus a
/// server-wide Read — the shape almost every wire test wants.
TesterWithRepo,
/// **The installed appliance, exactly**: an EMPTY catalog, no principals,
/// and the single identity `--admin-key` installs on first boot —
/// `AuthTable::bootstrap_admin`, principal id `admin`, holding a
/// `Server`/`Admin` grant and nothing else. No repository exists; making
/// the first one exist is what the first-push door is for.
BootstrapAdminOnly,
}
impl Default for HarnessOptions {
fn default() -> Self {
HarnessOptions {
grpc: false,
grpc_tls: false,
visibility: Visibility::Private,
allow_anonymous: true,
anonymous: Anonymous::Allow,
#[cfg(feature = "http")]
http: false,
metrics: false,
seed: Seed::TesterWithRepo,
disk_floor: None,
data_root: None,
virtual_capacity: None,
}
}
}
/// A running gunnar, plus the client key that may reach it.
pub struct Harness {
pub dir: tempfile::TempDir,
pub runtime: tokio::runtime::Runtime,
pub server: Option<Server>,
pub stores: Arc<ServerStores>,
/// The client's private key, on disk with mode 0600 so `ssh` will use it.
pub key_path: PathBuf,
pub ssh_port: u16,
/// The gRPC control plane's address, when it was started.
pub grpc_addr: Option<std::net::SocketAddr>,
/// The smart-HTTP listener's address, when it was started.
#[cfg(feature = "http")]
pub http_addr: Option<std::net::SocketAddr>,
/// The scrape endpoint's address, when it was started.
pub metrics_addr: Option<std::net::SocketAddr>,
/// The SHA-256 fingerprint the client key was registered under. It is the
/// value a `RefUpdated` or a `PushAttestation` must carry.
pub key_fingerprint: String,
/// The same key, parsed, so a test can sign a gRPC credential with it.
/// gRPC authenticates against the SAME key table as SSH (the allow-all
/// hole is closed), so there is exactly one key in this harness and both
/// listeners take it.
pub private_key: gunnar_core::ssh_key::PrivateKey,
}
impl Harness {
/// Bring up a server holding one repository, with one principal that may
/// write to it. The gRPC listener is **off**: most tests here are about the
/// git wire and leaving it off keeps them from competing for a port.
pub fn start(namespace: &str, repo: &str, hash: HashKind) -> Harness {
Harness::start_with(namespace, repo, hash, HarnessOptions::default(), None, None)
}
/// A server whose `gunnar-wire` knobs are the caller's rather than the
/// defaults — what `gunnar serve --no-filter`, `--want-policy` and the
/// fetch ceilings produce.
///
/// In-process for the same reason `start_replicating` is: the knobs reach
/// the state machines through `WireTransport::with_wire_config`, and a
/// test that could not set them could only ever prove the defaults.
pub fn start_with_wire(
namespace: &str,
repo: &str,
hash: HashKind,
wire: WireConfig,
) -> Harness {
Harness::start_with_wire_options(namespace, repo, hash, wire, HarnessOptions::default())
}
/// The same, with the listeners chosen too.
///
/// `tests/lfs_http.rs` needs both at once: an operator's `WireConfig::lfs`
/// quota **and** the HTTP listener, to show that the number binds on the
/// second transport. Two constructors rather than one taking five
/// positional arguments, and [`Harness::start_with_wire`] delegates here so
/// there is one startup path (one implementation).
pub fn start_with_wire_options(
namespace: &str,
repo: &str,
hash: HashKind,
wire: WireConfig,
options: HarnessOptions,
) -> Harness {
Harness::start_with(namespace, repo, hash, options, None, Some(wire))
}
/// The same server with the gRPC control plane bound on an ephemeral
/// loopback port, so a test can subscribe to `Events.Watch`.
pub fn start_with_grpc(namespace: &str, repo: &str, hash: HashKind) -> Harness {
Harness::start_with(
namespace,
repo,
hash,
HarnessOptions {
grpc: true,
..HarnessOptions::default()
},
None,
None,
)
}
/// A server configured for the anonymous-read tests: the repository's
/// visibility and the listener's anonymous switch chosen by the caller.
pub fn start_with_options(
namespace: &str,
repo: &str,
hash: HashKind,
options: HarnessOptions,
) -> Harness {
Harness::start_with(namespace, repo, hash, options, None, None)
}
/// **A freshly installed appliance**: empty catalog, no repositories, and
/// the bootstrap admin from `--admin-key` as the ONLY identity. The
/// harness key is the admin's key. The `namespace`/`repo` arguments of
/// the other constructors do not apply — nothing is created; creating the
/// first repository is the scenario itself.
pub fn start_appliance_bootstrap() -> Harness {
Harness::start_with(
"unused",
"unused",
HashKind::Sha256,
HarnessOptions {
seed: Seed::BootstrapAdminOnly,
..HarnessOptions::default()
},
None,
None,
)
}
/// A server that replicates onward.
///
/// The configuration is passed in-process rather than written to
/// `replication.json`, because the standby it points at only learns its
/// port when it binds. That is exactly why `ServeConfig::replication`
/// exists as an override.
pub fn start_replicating(
namespace: &str,
repo: &str,
hash: HashKind,
replication: gunnar_cli::server::ReplicationConfig,
) -> Harness {
Harness::start_with(
namespace,
repo,
hash,
HarnessOptions::default(),
Some(replication),
None,
)
}
/// [`Harness::start_replicating`] with explicit options — the browse
/// replica tests need the primary's gRPC listener AND a replication
/// config on the same box.
pub fn start_replicating_with_options(
namespace: &str,
repo: &str,
hash: HashKind,
options: HarnessOptions,
replication: gunnar_cli::server::ReplicationConfig,
) -> Harness {
Harness::start_with(namespace, repo, hash, options, Some(replication), None)
}
fn start_with(
namespace: &str,
repo: &str,
hash: HashKind,
options: HarnessOptions,
replication: Option<gunnar_cli::server::ReplicationConfig>,
wire: Option<WireConfig>,
) -> Harness {
let HarnessOptions {
grpc,
grpc_tls,
visibility,
allow_anonymous,
anonymous,
#[cfg(feature = "http")]
http,
metrics,
seed,
disk_floor,
data_root,
virtual_capacity,
} = options;
assert!(
!grpc_tls || grpc,
"grpc_tls without grpc asks for mutual TLS on a listener that is never started; the \
test would connect to nothing and read as a TLS failure"
);
// Only when asked. The server logs from its own runtime threads, which
// cargo does not capture, so an unconditional subscriber would bury the
// test results under the server's narration. Run
// `RUST_LOG=debug cargo test … -- --nocapture` when something goes
// wrong on the wire.
if std::env::var_os("RUST_LOG").is_some() {
gunnar_cli::init_tracing(false);
}
let dir = tempfile::tempdir().expect("a temp dir");
let data = data_root.map_or_else(|| dir.path().join("data"), std::path::Path::to_path_buf);
let stores = Arc::new(ServerStores::open(&data).expect("open the stores"));
// A real ed25519 key, minted in process. No `ssh-keygen`.
let private = gunnar_core::mint_ed25519().expect("mint a client key");
let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private)
.expect("the minted key parses");
let public = parsed
.public_key()
.to_openssh()
.expect("render the public key");
let key_path = dir.path().join("id_ed25519");
std::fs::write(&key_path, &private).expect("write the client key");
set_private_mode(&key_path);
let principal_id = match seed {
Seed::TesterWithRepo => {
stores
.catalog()
.create_namespace(namespace)
.expect("create the namespace");
let path = RepoRef::new(namespace, repo).expect("a legal repo path");
let created = stores
.catalog()
.create(
NewRepo::new(path.clone())
.hash_kind(hash)
.visibility(visibility),
)
.expect("create the repository");
stores
.repo_store(&created)
.expect("materialise the repository on disk");
stores
.auth()
.add_principal(
Principal::new("tester", "tester", PrincipalKind::User).expect("valid"),
)
.expect("add the principal");
stores
.auth()
.register_key("tester", &public, None)
.expect("register the client key");
stores
.auth()
.grant(Grant::repo("tester", path, Level::Write))
.expect("grant write");
// …and a server-wide Read, which is what the gRPC control
// plane's server-scoped reads (`Admin.Health`, `Admin.Stats`,
// `Events.Watch`) need now that the allow-all hole is closed.
// Deliberately Read and not Admin: a test principal that could
// write the key table over gRPC would hide a missing check
// rather than exercise one.
stores
.auth()
.grant(Grant::server("tester", Level::Read))
.expect("grant a server-wide read");
"tester"
}
// The appliance's first boot, verbatim: `prepare_to_serve` lands
// the `--admin-key` value here. NOTHING else is seeded — no
// namespace, no repository — because nothing else exists on an
// installed medium, and that emptiness is what the first-push
// door tests are about.
Seed::BootstrapAdminOnly => {
stores
.auth()
.bootstrap_admin(&public)
.expect("install the bootstrap admin, as --admin-key does");
gunnar_core::BOOTSTRAP_ADMIN_ID
}
};
let key_fingerprint = stores
.auth()
.keys_of(principal_id)
.expect("read the key back")
.first()
.expect("the key that was just registered")
.fingerprint_sha256
.clone();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a tokio runtime");
let server = runtime
.block_on(Server::start(Arc::clone(&stores), {
let mut config = ServeConfig {
no_grpc: !grpc,
grpc_tls: grpc_tls.then_some(gunnar_cli::server::GrpcTls::SelfMinted),
allow_anonymous,
anonymous: anonymous.policy(),
replication,
wire: wire.unwrap_or_default(),
#[cfg(feature = "http")]
http_bind: http.then(|| std::net::SocketAddr::from(([127, 0, 0, 1], 0))),
// Loopback, and the test is the operator vouching for it.
// The shipped default refuses a credential on a plain
// connection; `tests/http_transport.rs` asserts that
// separately rather than losing it to this line.
#[cfg(feature = "http")]
http_cleartext: gunnar_http::Cleartext::Allowed,
..ServeConfig::ephemeral()
};
// `metrics_bind` is set only when it is ASKED FOR, and is not
// named in the literal above. Written the obvious way —
// `metrics_bind: metrics.then(…)` — an explicit `None` would
// overwrite `ServeConfig`'s own default on every harness in the
// suite, and `a_server_that_was_not_asked_for_metrics_binds_no_
// metrics_port` would then be asserting about a value this
// function wrote rather than about the shipped default. It was,
// and a red probe that changed the default to bind a port left
// that test green.
if metrics {
config.metrics_bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
}
// The same rule as `metrics_bind`: only when asked, so the
// shipped default is what every other harness runs under.
if let Some(floor) = disk_floor {
config.disk_floor = floor;
}
if let Some(capacity) = virtual_capacity {
config.space_probe = Some(Arc::new(VirtualDisk {
root: data.clone(),
capacity,
}));
}
config
}))
.expect("both listeners bind");
let ssh_port = server.ssh_addr().port();
#[cfg(feature = "http")]
let http_addr = server.http_addr();
#[cfg(feature = "http")]
assert_eq!(
http_addr.is_some(),
http,
"the harness asked for http={http} and got {http_addr:?}; a test that \
cloned from a listener that was never started would hang rather than fail"
);
let metrics_addr = server.metrics_addr();
assert_eq!(
metrics_addr.is_some(),
metrics,
"the harness asked for metrics={metrics} and got {metrics_addr:?}; a test that \
scraped a listener that was never started would fail on `connect` and read as a \
harness fault rather than as the missing endpoint"
);
let grpc_addr = server.grpc_addr();
assert_eq!(
grpc_addr.is_some(),
grpc,
"the harness asked for grpc={grpc} and got {grpc_addr:?}; a test that subscribes to \
a listener that was never started would hang rather than fail"
);
Harness {
dir,
runtime,
server: Some(server),
stores,
key_path,
ssh_port,
grpc_addr,
#[cfg(feature = "http")]
http_addr,
metrics_addr,
key_fingerprint,
private_key: parsed,
}
}
/// A fresh, single-use gRPC credential signed with this harness's key,
/// **bound to `method`** (`Service.Method`): the server refuses the
/// method-blind v1 shape, and a bound one anywhere but its own method.
///
/// Each call mints a new nonce: the server's replay guard refuses a
/// credential twice, which is the point of it.
pub fn grpc_credential_for(&self, method: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_millis() as i64;
let nonce = format!(
"gunnar-test-{}-{:016x}",
SEQ.fetch_add(1, Ordering::Relaxed),
now
);
gunnar_grpc::sign_credential_for(&self.private_key, now, &nonce, method)
.expect("sign a gRPC credential")
.to_header_value()
}
/// Attach a fresh credential for `method` to a gRPC request.
pub fn authed<T>(&self, method: &str, message: T) -> tonic::Request<T> {
let mut request = tonic::Request::new(message);
request.metadata_mut().insert(
gunnar_grpc::CREDENTIAL_METADATA_KEY,
self.grpc_credential_for(method)
.parse()
.expect("an ascii credential"),
);
request
}
/// The `http://` origin of the gRPC control plane.
pub fn grpc_url(&self) -> String {
format!(
"http://{}",
self.grpc_addr
.expect("this harness was started without the gRPC listener")
)
}
/// The `https://` origin of a control plane started with mutual TLS.
///
/// The **host name matters**: the listener's certificate carries
/// `localhost` and `127.0.0.1` as SANs, and rustls checks the name it was
/// asked to dial against them. Spelling the address as `localhost` here
/// rather than as the socket's numeric form keeps the handshake failing for
/// the reason a test is about — the certificate — instead of for a name
/// mismatch that looks identical from the outside.
pub fn grpc_tls_url(&self) -> String {
format!(
"https://localhost:{}",
self.grpc_addr
.expect("this harness was started without the gRPC listener")
.port()
)
}
/// **Which of `ids` this repository actually holds**, asked of the store.
///
/// The engine-neutral replacement for "read every `.idx` under
/// `objects/pack`". That worked on the packfile engine and answered
/// *nothing at all* on the shipped znippy arm, which keeps its objects in an
/// archive and writes no index files — so the suites that used it failed
/// with `read …/objects/pack: No such file or directory`, reporting a
/// missing directory as a missing push.
///
/// It takes the candidate ids rather than enumerating everything because
/// `GitServe` has no "list every object" call, and should not grow one for
/// a test: what these suites actually ask is *"is this object here"*, one
/// object at a time, and `header` answers that without inflating anything.
pub fn holds_objects(
&self,
namespace: &str,
repo: &str,
ids: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
let repo_ref = RepoRef::new(namespace, repo).expect("a legal repository path");
let entry = self
.stores
.catalog()
.get(&repo_ref)
.expect("read the catalogue")
.expect("the repository exists");
let store = self.stores.repo_store(&entry).expect("open the repository");
ids.iter()
.filter(|id| {
let Ok(oid) = gix_hash::ObjectId::from_hex(id.as_bytes()) else {
return false;
};
store
.serve()
.header(oid.as_bytes())
.expect("ask the store")
.is_some()
})
.cloned()
.collect()
}
/// This server's own issuer certificate, PEM, for a client to pin.
pub fn issuer_pem(&self) -> String {
self.stores
.pki()
.expect("the harness stores carry a PKI")
.issuer()
.expect("read the issuer")
.expect("the server minted an issuer at first start")
.cert_pem
}
/// The replicator on this server.
///
/// Panics when the harness was not started with one, rather than returning
/// `None`: a replication test that quietly did nothing because the feature
/// was never wired is the exact silent green this suite is written against.
pub fn replicator(&self) -> Arc<gunnar_cli::server::Replicator> {
self.server
.as_ref()
.expect("the server is running")
.replication()
.expect("this harness was started without any replication target")
}
/// The event bus this server publishes to.
pub fn bus(&self) -> Arc<gunnar_grpc::EventBus> {
Arc::clone(self.server.as_ref().expect("the server is running").bus())
}
/// The attestation log this server appends to.
pub fn attestation_log(&self) -> gunnar_cli::server::AttestationLog {
gunnar_cli::server::AttestationLog::under(self.stores.root())
}
/// The git directory of one of this server's repositories.
pub fn git_dir(&self, namespace: &str, repo: &str) -> PathBuf {
let path = RepoRef::new(namespace, repo).expect("a legal repo path");
let opened = self.stores.catalog().open(&path).expect("the repository");
self.stores
.repo_store(&opened)
.expect("the repository is open")
.git_dir()
.to_path_buf()
}
/// The `ssh://` URL for a repository on this server.
pub fn url(&self, namespace: &str, repo: &str) -> String {
format!(
"ssh://git@127.0.0.1:{}/{namespace}/{repo}.git",
self.ssh_port
)
}
/// The `ssh://` URL a **stranger** uses: no key, and the reserved
/// `anonymous` username that selects the anonymous `auth_none` path.
pub fn anonymous_url(&self, namespace: &str, repo: &str) -> String {
format!(
"ssh://{}@127.0.0.1:{}/{namespace}/{repo}.git",
gunnar_ssh::ANONYMOUS_SSH_USER,
self.ssh_port
)
}
/// Change a repository's visibility on the **live** server.
///
/// The catalog is shared with the running listener, so this is what a
/// `Repos.SetVisibility` call does, which is how a test can ask the same
/// question of the same server before and after publishing.
pub fn set_visibility(&self, namespace: &str, repo: &str, visibility: Visibility) {
let path = RepoRef::new(namespace, repo).expect("a legal repo path");
self.stores
.catalog()
.set_visibility(&path, visibility)
.expect("set the visibility");
}
/// `GIT_SSH_COMMAND` for a caller with **no key at all**.
///
/// `IdentitiesOnly=yes` with no `-i` and no agent means `ssh` has nothing to
/// offer. It still opens with the mandatory `none` probe, which is where
/// the anonymous path lives.
pub fn anonymous_ssh_command(&self) -> String {
"ssh -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR"
.to_string()
}
/// Run `git` as a stranger: no key, nothing in the agent.
pub fn git_anonymous(&self, cwd: &Path, args: &[&str]) -> Output {
self.git_with_ssh(cwd, &self.anonymous_ssh_command(), args)
}
/// `GIT_SSH_COMMAND` pointing at this harness's key.
///
/// `IdentitiesOnly=yes` matters: without it `ssh` offers every key in the
/// agent first, gunnar refuses each one, and the connection dies on
/// `max_auth_attempts` before the right key is ever tried, which looks
/// exactly like a broken server.
pub fn ssh_command(&self) -> String {
format!(
"ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR",
self.key_path.display()
)
}
/// Run `git` with this harness's SSH configuration.
pub fn git(&self, cwd: &Path, args: &[&str]) -> Output {
self.git_with_ssh(cwd, &self.ssh_command(), args)
}
/// Run `git` with an explicit `GIT_SSH_COMMAND`.
///
/// One implementation, because [`Harness::git`] and
/// [`Harness::git_anonymous`] must differ in **exactly** the credential and
/// in nothing else: a second copy that drifted on `HOME` or on
/// `GIT_CONFIG_NOSYSTEM` would make an anonymous failure look like an
/// authorisation result when it was an environment difference (one implementation).
pub fn git_with_ssh(&self, cwd: &Path, ssh_command: &str, args: &[&str]) -> Output {
let mut command = Command::new("git");
command
.current_dir(cwd)
.env("GIT_SSH_COMMAND", ssh_command)
.env("GIT_TERMINAL_PROMPT", "0")
// **English, whatever the desktop speaks.** git is translated, and
// several assertions in this suite read its own words. On a Swedish
// machine they arrive as `fatal: protokollfel: …` and the test fails
// on correct behaviour — a verdict about the operator's locale
// rather than about the server. `LC_ALL` outranks every other locale
// variable, so one line settles it.
.env("LC_ALL", "C")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", self.dir.path())
.env("GIT_AUTHOR_NAME", "Gunnar Test")
.env("GIT_AUTHOR_EMAIL", "gunnar@example.invalid")
.env("GIT_COMMITTER_NAME", "Gunnar Test")
.env("GIT_COMMITTER_EMAIL", "gunnar@example.invalid")
.args(args);
let out = command.output().expect("git is on PATH");
Output {
argv: args.iter().map(|a| a.to_string()).collect(),
status: out.status.code().unwrap_or(-1),
ok: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
/// The `http://` origin of the smart-HTTP listener.
#[cfg(feature = "http")]
pub fn http_origin(&self) -> String {
format!(
"http://{}",
self.http_addr
.expect("this harness was started without the HTTP listener")
)
}
/// The clone URL for a repository over smart HTTP, with no credentials.
#[cfg(feature = "http")]
pub fn http_url(&self, namespace: &str, repo: &str) -> String {
format!("{}/{namespace}/{repo}.git", self.http_origin())
}
/// The same URL with an access token as the HTTP Basic password.
///
/// The username is `x-access-token` and is decorative: gunnar reads the
/// password half and nothing else. Putting it in the URL is what lets
/// a test run non-interactively, and is what a CI job does.
#[cfg(feature = "http")]
pub fn http_url_with_token(&self, namespace: &str, repo: &str, token: &str) -> String {
let origin = self.http_origin();
let rest = origin
.strip_prefix("http://")
.expect("the origin is http://");
format!("http://x-access-token:{token}@{rest}/{namespace}/{repo}.git")
}
/// Mint an access token for a principal.
#[cfg(feature = "http")]
pub fn mint_token(&self, principal: &str) -> String {
let secret = gunnar_core::mint_token_secret();
self.stores
.auth()
.register_token(principal, &secret, "test", None)
.expect("register the token");
secret
}
/// Run `git` against the HTTP listener.
///
/// **Deliberately built on [`Harness::git_with_ssh`]**, with an SSH
/// command that cannot work: the environment a git invocation runs in —
/// `HOME`, `GIT_CONFIG_NOSYSTEM`, the author identity — must be identical
/// to the SSH tests' or a difference in results would not be a difference
/// in transports (one implementation). Nothing here opens an SSH
/// connection; the URL is `http://`.
#[cfg(feature = "http")]
pub fn git_http(&self, cwd: &Path, args: &[&str]) -> Output {
self.git_with_ssh(cwd, "false", args)
}
/// Stop both listeners.
pub fn stop(&mut self) {
if let Some(server) = self.server.take() {
let _ = self.runtime.block_on(server.shutdown());
}
}
}
impl Drop for Harness {
fn drop(&mut self) {
self.stop();
}
}
/// What one `git` invocation did.
pub struct Output {
pub argv: Vec<String>,
pub status: i32,
pub ok: bool,
pub stdout: String,
pub stderr: String,
}
impl Output {
/// Assert it succeeded, printing everything git said if it did not. An
/// assertion whose failure message is only "false" costs an hour.
pub fn expect_ok(&self, what: &str) -> &Self {
assert!(
self.ok,
"{what} failed (exit {})\n git {}\n stdout: {}\n stderr: {}",
self.status,
self.argv.join(" "),
self.stdout.trim(),
self.stderr.trim()
);
self
}
/// Assert it failed. Used for the red half of a guard.
pub fn expect_failure(&self, what: &str) -> &Self {
assert!(
!self.ok,
"{what} was expected to fail and did not\n git {}\n stdout: {}\n stderr: {}",
self.argv.join(" "),
self.stdout.trim(),
self.stderr.trim()
);
self
}
}
#[cfg(unix)]
fn set_private_mode(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600 the client key");
}
#[cfg(not(unix))]
fn set_private_mode(_path: &Path) {}
/// Is `git` usable, and is `ssh` on PATH? Both are required; a test that
/// silently skipped when they were missing would be a permanent green.
pub fn tools_present() -> bool {
which("git") && which("ssh")
}
fn which(program: &str) -> bool {
// Spawnability, not exit status: `ssh -V` prints to stderr and `ssh
// --version` exits non-zero, so a status check would report a perfectly
// present OpenSSH as missing and every test below would "pass" by
// skipping.
Command::new(program).arg("-V").output().is_ok()
}
/// Make a local working repository with one commit, ready to push.
pub fn local_repo(harness: &Harness, at: &Path, hash: HashKind, content: &str) -> String {
std::fs::create_dir_all(at).expect("create the working tree");
let format = match hash {
HashKind::Sha1 => "sha1",
HashKind::Sha256 => "sha256",
};
harness
.git(
at,
&[
"init",
"--quiet",
&format!("--object-format={format}"),
"--initial-branch=main",
".",
],
)
.expect_ok("git init");
std::fs::write(at.join("hello.txt"), content).expect("write the file");
harness.git(at, &["add", "hello.txt"]).expect_ok("git add");
harness
.git(at, &["commit", "--quiet", "-m", "the first commit"])
.expect_ok("git commit");
let head = harness.git(at, &["rev-parse", "HEAD"]);
head.expect_ok("git rev-parse HEAD");
head.stdout.trim().to_owned()
}
// ───────────────────────────────────────────────────────────────────────────
// Raw ssh
// ───────────────────────────────────────────────────────────────────────────
/// Run stock `ssh` against the harness with `extra` options, executing
/// `remote_command`, and hand back the raw process output.
///
/// Written once because several tests need the same nine options and the only
/// thing that differs between them is one `-o SetEnv=…`. It is the only way to
/// read what gunnar actually **advertises**: `git` parses the advertisement and
/// then acts on it, so a capability that is missing shows up as a behaviour
/// change three steps later, if at all.
pub fn raw_ssh(harness: &Harness, extra: &[&str], remote_command: &str) -> std::process::Output {
let port = harness.ssh_port.to_string();
let mut args: Vec<String> = vec![
"-i".into(),
harness.key_path.display().to_string(),
"-o".into(),
"IdentitiesOnly=yes".into(),
"-o".into(),
"IdentityAgent=none".into(),
"-o".into(),
"StrictHostKeyChecking=no".into(),
"-o".into(),
"UserKnownHostsFile=/dev/null".into(),
"-o".into(),
"BatchMode=yes".into(),
"-o".into(),
"LogLevel=ERROR".into(),
"-p".into(),
port,
];
args.extend(extra.iter().map(|arg| (*arg).to_string()));
args.push("git@127.0.0.1".into());
args.push(remote_command.into());
std::process::Command::new("ssh")
.args(&args)
.stdin(std::process::Stdio::null())
.output()
.expect("ssh runs")
}
// ───────────────────────────────────────────────────────────────────────────
// A hand-written HTTP/1.1 client
// ───────────────────────────────────────────────────────────────────────────
/// One HTTP/1.1 request, written by hand, returning `(status, whole response)`.
///
/// **No HTTP client crate in the dev-dependencies.** What these suites assert
/// is a status code, a header and an exact body, and a client that helpfully
/// retried, followed a redirect, decompressed something or turned a `401` into
/// an `Err` would put its own behaviour between the test and the answer.
///
/// It lives here rather than in one suite because two suites need it — the
/// smart-HTTP transport's (`tests/http_transport.rs`, behind `--features
/// http`) and the scrape endpoint's (`tests/metrics_endpoint.rs`, in the plain
/// `server` build) — and a second copy in the second one would be the twin one
/// implementation forbids. `Connection: close` throughout, so `read_to_end`
/// terminates without this having to understand chunked encoding or
/// content-length.
pub fn http_request(
method: &str,
host: &str,
path: &str,
credentials: Option<&str>,
git_protocol: Option<&str>,
) -> (u16, String) {
use std::io::{Read, Write};
let mut stream = std::net::TcpStream::connect(host).expect("connect");
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n");
if let Some(protocol) = git_protocol {
head.push_str(&format!("Git-Protocol: {protocol}\r\n"));
}
if let Some(credentials) = credentials {
use base64ct::Encoding as _;
head.push_str(&format!(
"Authorization: Basic {}\r\n",
base64ct::Base64::encode_string(credentials.as_bytes())
));
}
head.push_str("\r\n");
stream.write_all(head.as_bytes()).expect("write");
let mut response = Vec::new();
stream.read_to_end(&mut response).expect("read");
let text = String::from_utf8_lossy(&response).into_owned();
let status = text
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or_else(|| panic!("no status line in {text:?}"));
(status, text)
}
/// The value of one response header, case-insensitively, if it is there.
pub fn http_header(response: &str, name: &str) -> Option<String> {
response.split("\r\n\r\n").next()?.lines().find_map(|line| {
let (header, value) = line.split_once(':')?;
header
.trim()
.eq_ignore_ascii_case(name)
.then(|| value.trim().to_owned())
})
}
/// Everything after the blank line.
pub fn http_body(response: &str) -> String {
response
.split_once("\r\n\r\n")
.map(|(_, body)| body.to_owned())
.unwrap_or_default()
}
/// **Total bytes of pack a repository holds**, in whichever shape its engine
/// stores them.
///
/// The packfile engine writes `objects/pack/*.pack`; the znippy arm appends
/// verbatim into `gunnar-git.znippy/objects.pack`. `bench/src/store_bytes.rs`
/// measured both at the same number for the same repository, so this is one
/// quantity with two spellings rather than two quantities.
///
/// Here rather than in each suite because it was written twice — once in
/// `observability.rs` and once in `push_facts.rs` — and both copies knew only
/// the first shape, so both reported `0` on the shipped engine and both read
/// that as "the push wrote nothing" (one implementation).
pub fn stored_pack_bytes(git_dir: &Path) -> u64 {
let packfile_shape: u64 = std::fs::read_dir(git_dir.join("objects").join("pack"))
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.path().extension().and_then(|e| e.to_str()) == Some("pack"))
.filter_map(|entry| entry.metadata().ok())
.map(|meta| meta.len())
.sum();
if packfile_shape > 0 {
return packfile_shape;
}
std::fs::metadata(git_dir.join("gunnar-git.znippy").join("objects.pack"))
.map(|meta| meta.len())
.unwrap_or(0)
}