Skip to content

Commit 0690387

Browse files
feat(ssh_config): M12.9 — NFR-15 latency bench + matrix completion (#4)
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.
1 parent 2cbdeac commit 0690387

8 files changed

Lines changed: 255 additions & 0 deletions

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ serde_yml = "0.0.12"
6161
name = "throughput"
6262
harness = false
6363

64+
[[bench]]
65+
name = "ssh_config_latency"
66+
harness = false
67+
6468
# ── Lints ─────────────────────────────────────────────────────────────────────
6569

6670
[lints.rust]

benches/ssh_config_latency.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Rust guideline compliant 2026-03-30
3+
//! NFR-15 latency benchmark for `anvil_ssh::ssh_config::resolve()`.
4+
//!
5+
//! Asserts that resolving a typical user `ssh_config(5)` (≤100 directives,
6+
//! no `Include`s) completes in ≤ 5 ms cold per [Gitway PRD §10 NFR-15].
7+
//! Hard-fails via `panic!` if the median exceeds the budget so the
8+
//! regression shows up in CI as a build failure rather than a noisy
9+
//! Criterion print.
10+
//!
11+
//! # Running
12+
//!
13+
//! ```sh
14+
//! cargo bench --bench ssh_config_latency
15+
//! ```
16+
//!
17+
//! No environment variables required — the benchmark is fully hermetic
18+
//! (writes its fixture to a [`tempfile::TempDir`] and tears it down on
19+
//! drop).
20+
//!
21+
//! # Why a separate bench harness
22+
//!
23+
//! `cargo test` measures correctness; this measures *speed*. We want
24+
//! the latency budget enforced on every release, but we do not want it
25+
//! gated on `GITWAY_INTEGRATION_TESTS=1` the way the throughput bench
26+
//! is — the resolver path involves zero network I/O.
27+
28+
use std::fs;
29+
use std::path::PathBuf;
30+
use std::time::Duration;
31+
32+
use anvil_ssh::ssh_config::{resolve, SshConfigPaths};
33+
use criterion::{criterion_group, criterion_main, Criterion};
34+
use tempfile::TempDir;
35+
36+
/// NFR-15 budget: ≤ 5 ms cold per resolve call.
37+
const LATENCY_BUDGET: Duration = Duration::from_millis(5);
38+
39+
/// Builds a representative `ssh_config(5)` covering the directives Anvil
40+
/// understands today plus a handful of additional `Host` blocks so the
41+
/// matcher actually walks more than one section.
42+
fn write_typical_config(dir: &TempDir) -> PathBuf {
43+
let path = dir.path().join("config");
44+
let body = "\
45+
# Global defaults — apply to every host.
46+
User defaultuser
47+
ServerAliveInterval 30
48+
49+
Host gh
50+
HostName github.com
51+
User git
52+
Port 22
53+
IdentityFile ~/.ssh/id_ed25519
54+
IdentityFile ~/.ssh/id_rsa
55+
IdentitiesOnly yes
56+
StrictHostKeyChecking yes
57+
UserKnownHostsFile ~/.ssh/known_hosts
58+
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
59+
KexAlgorithms curve25519-sha256
60+
Ciphers chacha20-poly1305@openssh.com
61+
MACs hmac-sha2-256-etm@openssh.com
62+
ConnectTimeout 30
63+
ConnectionAttempts 3
64+
65+
Host gl
66+
HostName gitlab.com
67+
User git
68+
Port 22
69+
IdentityFile ~/.ssh/id_ed25519
70+
71+
Host *.work.example.com
72+
User work
73+
ProxyJump bastion.work.example.com
74+
75+
Host bastion.work.example.com
76+
User bastion
77+
ProxyCommand ssh -W %h:%p jump.work.example.com
78+
79+
Host work
80+
HostName work.example.com
81+
User worker
82+
Port 2222
83+
84+
Host !legacy *
85+
PreferredAuthentications publickey
86+
";
87+
fs::write(&path, body).expect("write fixture");
88+
path
89+
}
90+
91+
fn bench_resolve_typical(c: &mut Criterion) {
92+
let dir = tempfile::tempdir().expect("tempdir");
93+
let conf = write_typical_config(&dir);
94+
let paths = SshConfigPaths {
95+
user: Some(conf),
96+
system: None,
97+
};
98+
99+
c.bench_function("resolve_typical_user_config", |b| {
100+
b.iter(|| {
101+
// The resolver re-reads the file each call; that is intentional
102+
// — NFR-15 is about cold latency and the hot-path mtime cache
103+
// listed as a fallback in PRD §10 has not been implemented.
104+
resolve("gh", &paths).expect("resolve")
105+
});
106+
});
107+
108+
// Hard-fail enforcement: re-measure outside Criterion's statistical
109+
// pipeline and panic if the median exceeds the budget. Criterion's
110+
// own threshold detection emits warnings but does not fail the run.
111+
enforce_budget(&paths);
112+
}
113+
114+
fn bench_resolve_no_match(c: &mut Criterion) {
115+
// Worst-case path within a single-file config: walk every Host block
116+
// and find no match, so the resolver returns just the Global block's
117+
// directives.
118+
let dir = tempfile::tempdir().expect("tempdir");
119+
let conf = write_typical_config(&dir);
120+
let paths = SshConfigPaths {
121+
user: Some(conf),
122+
system: None,
123+
};
124+
125+
c.bench_function("resolve_no_match", |b| {
126+
b.iter(|| resolve("nothing-matches.example.org", &paths).expect("resolve"));
127+
});
128+
}
129+
130+
/// Median of 32 cold runs must stay under the NFR-15 budget; hard-fail
131+
/// otherwise. Runs after the Criterion measurement so the panic appears
132+
/// after the per-bench summary in stdout.
133+
fn enforce_budget(paths: &SshConfigPaths) {
134+
const RUNS: usize = 32;
135+
let mut measurements: Vec<Duration> = Vec::with_capacity(RUNS);
136+
for _ in 0..RUNS {
137+
let start = std::time::Instant::now();
138+
let _ = resolve("gh", paths).expect("resolve");
139+
measurements.push(start.elapsed());
140+
}
141+
measurements.sort();
142+
let median = measurements[RUNS / 2];
143+
144+
eprintln!(
145+
"ssh_config_latency: median resolve() = {} µs (budget = {} µs)",
146+
median.as_micros(),
147+
LATENCY_BUDGET.as_micros(),
148+
);
149+
150+
assert!(
151+
median <= LATENCY_BUDGET,
152+
"ssh_config_latency: NFR-15 budget exceeded — median {} µs > {} µs. \
153+
If this is intentional (e.g. richer matrix coverage), bump the \
154+
budget in benches/ssh_config_latency.rs.",
155+
median.as_micros(),
156+
LATENCY_BUDGET.as_micros(),
157+
);
158+
}
159+
160+
criterion_group!(benches, bench_resolve_typical, bench_resolve_no_match);
161+
criterion_main!(benches);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
description: "ProxyJump captured raw (M13 will spawn the chain)"
2+
3+
config: |
4+
Host work
5+
HostName work.example.com
6+
User worker
7+
ProxyJump bastion.example.com
8+
9+
host: work
10+
11+
expected:
12+
hostname: work.example.com
13+
user: worker
14+
proxy_jump: bastion.example.com
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
description: "ProxyCommand re-joined with single spaces (M13 will spawn the shell)"
2+
3+
config: |
4+
Host work
5+
HostName work.example.com
6+
ProxyCommand ssh -W %h:%p bastion
7+
8+
host: work
9+
10+
expected:
11+
hostname: work.example.com
12+
proxy_command: "ssh -W %h:%p bastion"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
description: "ConnectTimeout (seconds, M18 will honor)"
2+
3+
config: |
4+
Host gh
5+
HostName github.com
6+
ConnectTimeout 30
7+
ConnectionAttempts 5
8+
9+
host: gh
10+
11+
expected:
12+
hostname: github.com
13+
connect_timeout_secs: 30
14+
connection_attempts: 5
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
description: "Multiple IdentityFile lines accumulate in source order"
2+
3+
config: |
4+
Host gh
5+
HostName github.com
6+
IdentityFile /tmp/test_id_a
7+
IdentityFile /tmp/test_id_b
8+
IdentityFile /tmp/test_id_c
9+
10+
host: gh
11+
12+
expected:
13+
hostname: github.com
14+
identity_files:
15+
- /tmp/test_id_a
16+
- /tmp/test_id_b
17+
- /tmp/test_id_c
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
description: "Global directives apply, Host block extends them with first-wins precedence"
2+
3+
# Global User comes first → wins because it appears earlier in source order.
4+
# Host block contributes IdentityFile (no conflict with Global → applied).
5+
config: |
6+
User globaluser
7+
Host gh
8+
HostName github.com
9+
User specificuser
10+
IdentityFile /tmp/specific_id
11+
12+
host: gh
13+
14+
expected:
15+
hostname: github.com
16+
user: globaluser
17+
identity_files:
18+
- /tmp/specific_id
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
description: "Host block before Global wildcard wins for the matched host"
2+
3+
# This is the canonical ssh_config(5) layout: per-host overrides at the
4+
# top, fall-through `Host *` defaults at the bottom. First-wins means
5+
# the per-host User wins over the wildcard User.
6+
config: |
7+
Host gh
8+
User specificuser
9+
Host *
10+
User wilduser
11+
12+
host: gh
13+
14+
expected:
15+
user: specificuser

0 commit comments

Comments
 (0)