From 6edcd688b4d2ca6bf9251e80033486f921d3616c Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Mon, 4 May 2026 07:15:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(ssh=5Fconfig):=20M12.9=20=E2=80=94=20NFR-1?= =?UTF-8?q?5=20latency=20bench=20+=20matrix=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the NFR-15 enforcement gate and rounds out the acceptance matrix to cover the directives PRD §5.8.1 calls out by name. benches/ssh_config_latency.rs (new): - Criterion harness with two scenarios: * resolve_typical_user_config: a representative ~30-line user config spanning every directive Anvil currently honors. Median ~283 µs on this Windows host. * resolve_no_match: worst single-file walk (every Host block inspected, no match → only Global directives returned). - Hard-fail enforce_budget(): runs 32 cold resolves outside Criterion, asserts median <= 5 ms, and panics with an actionable message if exceeded. Surfaces NFR-15 regressions as a CI failure rather than a Criterion warning. Cargo.toml: registers the new bench harness. tests/ssh_config_matrix/*.yaml (new): - 04_proxy_jump.yaml — ProxyJump captured raw (M13 will spawn). - 05_proxy_command.yaml — ProxyCommand re-joined with single spaces. - 06_connect_timeout.yaml — ConnectTimeout / ConnectionAttempts. - 07_multi_identity.yaml — IdentityFile (multi) accumulates. - 08_global_then_specific.yaml — Global User wins via first-occurrence rule even when followed by a more specific Host block. - 09_specific_then_global.yaml — canonical layout with per-host overrides at the top and Host * defaults at the bottom. Tests: existing matrix_walks_pass still green; total now 153 lib + 6 YAML fixtures + 1 matrix harness test + 6 integration tests, 0 failures. Plan: M12.9 of let-us-plan-on-bright-cosmos.md. No version bump: benches and matrix YAMLs are dev-only, never shipped in the published crate. --- Cargo.toml | 4 + benches/ssh_config_latency.rs | 161 ++++++++++++++++++ tests/ssh_config_matrix/04_proxy_jump.yaml | 14 ++ tests/ssh_config_matrix/05_proxy_command.yaml | 12 ++ .../ssh_config_matrix/06_connect_timeout.yaml | 14 ++ .../ssh_config_matrix/07_multi_identity.yaml | 17 ++ .../08_global_then_specific.yaml | 18 ++ .../09_specific_then_global.yaml | 15 ++ 8 files changed, 255 insertions(+) create mode 100644 benches/ssh_config_latency.rs create mode 100644 tests/ssh_config_matrix/04_proxy_jump.yaml create mode 100644 tests/ssh_config_matrix/05_proxy_command.yaml create mode 100644 tests/ssh_config_matrix/06_connect_timeout.yaml create mode 100644 tests/ssh_config_matrix/07_multi_identity.yaml create mode 100644 tests/ssh_config_matrix/08_global_then_specific.yaml create mode 100644 tests/ssh_config_matrix/09_specific_then_global.yaml diff --git a/Cargo.toml b/Cargo.toml index 1aa5c78..c053b7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,10 @@ serde_yml = "0.0.12" name = "throughput" harness = false +[[bench]] +name = "ssh_config_latency" +harness = false + # ── Lints ───────────────────────────────────────────────────────────────────── [lints.rust] diff --git a/benches/ssh_config_latency.rs b/benches/ssh_config_latency.rs new file mode 100644 index 0000000..021a654 --- /dev/null +++ b/benches/ssh_config_latency.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Rust guideline compliant 2026-03-30 +//! NFR-15 latency benchmark for `anvil_ssh::ssh_config::resolve()`. +//! +//! Asserts that resolving a typical user `ssh_config(5)` (≤100 directives, +//! no `Include`s) completes in ≤ 5 ms cold per [Gitway PRD §10 NFR-15]. +//! Hard-fails via `panic!` if the median exceeds the budget so the +//! regression shows up in CI as a build failure rather than a noisy +//! Criterion print. +//! +//! # Running +//! +//! ```sh +//! cargo bench --bench ssh_config_latency +//! ``` +//! +//! No environment variables required — the benchmark is fully hermetic +//! (writes its fixture to a [`tempfile::TempDir`] and tears it down on +//! drop). +//! +//! # Why a separate bench harness +//! +//! `cargo test` measures correctness; this measures *speed*. We want +//! the latency budget enforced on every release, but we do not want it +//! gated on `GITWAY_INTEGRATION_TESTS=1` the way the throughput bench +//! is — the resolver path involves zero network I/O. + +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use anvil_ssh::ssh_config::{resolve, SshConfigPaths}; +use criterion::{criterion_group, criterion_main, Criterion}; +use tempfile::TempDir; + +/// NFR-15 budget: ≤ 5 ms cold per resolve call. +const LATENCY_BUDGET: Duration = Duration::from_millis(5); + +/// Builds a representative `ssh_config(5)` covering the directives Anvil +/// understands today plus a handful of additional `Host` blocks so the +/// matcher actually walks more than one section. +fn write_typical_config(dir: &TempDir) -> PathBuf { + let path = dir.path().join("config"); + let body = "\ +# Global defaults — apply to every host. +User defaultuser +ServerAliveInterval 30 + +Host gh + HostName github.com + User git + Port 22 + IdentityFile ~/.ssh/id_ed25519 + IdentityFile ~/.ssh/id_rsa + IdentitiesOnly yes + StrictHostKeyChecking yes + UserKnownHostsFile ~/.ssh/known_hosts + HostKeyAlgorithms ssh-ed25519,rsa-sha2-512 + KexAlgorithms curve25519-sha256 + Ciphers chacha20-poly1305@openssh.com + MACs hmac-sha2-256-etm@openssh.com + ConnectTimeout 30 + ConnectionAttempts 3 + +Host gl + HostName gitlab.com + User git + Port 22 + IdentityFile ~/.ssh/id_ed25519 + +Host *.work.example.com + User work + ProxyJump bastion.work.example.com + +Host bastion.work.example.com + User bastion + ProxyCommand ssh -W %h:%p jump.work.example.com + +Host work + HostName work.example.com + User worker + Port 2222 + +Host !legacy * + PreferredAuthentications publickey +"; + fs::write(&path, body).expect("write fixture"); + path +} + +fn bench_resolve_typical(c: &mut Criterion) { + let dir = tempfile::tempdir().expect("tempdir"); + let conf = write_typical_config(&dir); + let paths = SshConfigPaths { + user: Some(conf), + system: None, + }; + + c.bench_function("resolve_typical_user_config", |b| { + b.iter(|| { + // The resolver re-reads the file each call; that is intentional + // — NFR-15 is about cold latency and the hot-path mtime cache + // listed as a fallback in PRD §10 has not been implemented. + resolve("gh", &paths).expect("resolve") + }); + }); + + // Hard-fail enforcement: re-measure outside Criterion's statistical + // pipeline and panic if the median exceeds the budget. Criterion's + // own threshold detection emits warnings but does not fail the run. + enforce_budget(&paths); +} + +fn bench_resolve_no_match(c: &mut Criterion) { + // Worst-case path within a single-file config: walk every Host block + // and find no match, so the resolver returns just the Global block's + // directives. + let dir = tempfile::tempdir().expect("tempdir"); + let conf = write_typical_config(&dir); + let paths = SshConfigPaths { + user: Some(conf), + system: None, + }; + + c.bench_function("resolve_no_match", |b| { + b.iter(|| resolve("nothing-matches.example.org", &paths).expect("resolve")); + }); +} + +/// Median of 32 cold runs must stay under the NFR-15 budget; hard-fail +/// otherwise. Runs after the Criterion measurement so the panic appears +/// after the per-bench summary in stdout. +fn enforce_budget(paths: &SshConfigPaths) { + const RUNS: usize = 32; + let mut measurements: Vec = Vec::with_capacity(RUNS); + for _ in 0..RUNS { + let start = std::time::Instant::now(); + let _ = resolve("gh", paths).expect("resolve"); + measurements.push(start.elapsed()); + } + measurements.sort(); + let median = measurements[RUNS / 2]; + + eprintln!( + "ssh_config_latency: median resolve() = {} µs (budget = {} µs)", + median.as_micros(), + LATENCY_BUDGET.as_micros(), + ); + + assert!( + median <= LATENCY_BUDGET, + "ssh_config_latency: NFR-15 budget exceeded — median {} µs > {} µs. \ + If this is intentional (e.g. richer matrix coverage), bump the \ + budget in benches/ssh_config_latency.rs.", + median.as_micros(), + LATENCY_BUDGET.as_micros(), + ); +} + +criterion_group!(benches, bench_resolve_typical, bench_resolve_no_match); +criterion_main!(benches); diff --git a/tests/ssh_config_matrix/04_proxy_jump.yaml b/tests/ssh_config_matrix/04_proxy_jump.yaml new file mode 100644 index 0000000..ad38bcc --- /dev/null +++ b/tests/ssh_config_matrix/04_proxy_jump.yaml @@ -0,0 +1,14 @@ +description: "ProxyJump captured raw (M13 will spawn the chain)" + +config: | + Host work + HostName work.example.com + User worker + ProxyJump bastion.example.com + +host: work + +expected: + hostname: work.example.com + user: worker + proxy_jump: bastion.example.com diff --git a/tests/ssh_config_matrix/05_proxy_command.yaml b/tests/ssh_config_matrix/05_proxy_command.yaml new file mode 100644 index 0000000..d7821e0 --- /dev/null +++ b/tests/ssh_config_matrix/05_proxy_command.yaml @@ -0,0 +1,12 @@ +description: "ProxyCommand re-joined with single spaces (M13 will spawn the shell)" + +config: | + Host work + HostName work.example.com + ProxyCommand ssh -W %h:%p bastion + +host: work + +expected: + hostname: work.example.com + proxy_command: "ssh -W %h:%p bastion" diff --git a/tests/ssh_config_matrix/06_connect_timeout.yaml b/tests/ssh_config_matrix/06_connect_timeout.yaml new file mode 100644 index 0000000..87c5c0a --- /dev/null +++ b/tests/ssh_config_matrix/06_connect_timeout.yaml @@ -0,0 +1,14 @@ +description: "ConnectTimeout (seconds, M18 will honor)" + +config: | + Host gh + HostName github.com + ConnectTimeout 30 + ConnectionAttempts 5 + +host: gh + +expected: + hostname: github.com + connect_timeout_secs: 30 + connection_attempts: 5 diff --git a/tests/ssh_config_matrix/07_multi_identity.yaml b/tests/ssh_config_matrix/07_multi_identity.yaml new file mode 100644 index 0000000..7423433 --- /dev/null +++ b/tests/ssh_config_matrix/07_multi_identity.yaml @@ -0,0 +1,17 @@ +description: "Multiple IdentityFile lines accumulate in source order" + +config: | + Host gh + HostName github.com + IdentityFile /tmp/test_id_a + IdentityFile /tmp/test_id_b + IdentityFile /tmp/test_id_c + +host: gh + +expected: + hostname: github.com + identity_files: + - /tmp/test_id_a + - /tmp/test_id_b + - /tmp/test_id_c diff --git a/tests/ssh_config_matrix/08_global_then_specific.yaml b/tests/ssh_config_matrix/08_global_then_specific.yaml new file mode 100644 index 0000000..ff33202 --- /dev/null +++ b/tests/ssh_config_matrix/08_global_then_specific.yaml @@ -0,0 +1,18 @@ +description: "Global directives apply, Host block extends them with first-wins precedence" + +# Global User comes first → wins because it appears earlier in source order. +# Host block contributes IdentityFile (no conflict with Global → applied). +config: | + User globaluser + Host gh + HostName github.com + User specificuser + IdentityFile /tmp/specific_id + +host: gh + +expected: + hostname: github.com + user: globaluser + identity_files: + - /tmp/specific_id diff --git a/tests/ssh_config_matrix/09_specific_then_global.yaml b/tests/ssh_config_matrix/09_specific_then_global.yaml new file mode 100644 index 0000000..5c291fa --- /dev/null +++ b/tests/ssh_config_matrix/09_specific_then_global.yaml @@ -0,0 +1,15 @@ +description: "Host block before Global wildcard wins for the matched host" + +# This is the canonical ssh_config(5) layout: per-host overrides at the +# top, fall-through `Host *` defaults at the bottom. First-wins means +# the per-host User wins over the wildcard User. +config: | + Host gh + User specificuser + Host * + User wilduser + +host: gh + +expected: + user: specificuser