Skip to content

Commit 8019513

Browse files
feat(config): M17.2 — plumb algorithm overrides into AnvilConfig + russh Preferred (FR-76) (#26)
Closes the M12.6 algorithm-override loop. AnvilConfig gains four new public fields, `apply_ssh_config` honors `KexAlgorithms` / `Ciphers` / `MACs` / `HostKeyAlgorithms` directives, and `build_russh_config` consumes the resulting lists when building the russh `Preferred` set. src/config.rs: - AnvilConfig: four new pub fields kex_algorithms: Option<Vec<String>> ciphers: Option<Vec<String>> macs: Option<Vec<String>> host_key_algorithms: Option<Vec<String>> Each `None` selects Anvil's curated default (`anvil_ssh::algorithms::anvil_default_*`); each `Some` is a list that has already passed through `apply_overrides` (denylist applied, +/-/^ resolved). - AnvilConfigBuilder: four new pub setters mirroring the field names. - apply_ssh_config: new private helper `apply_alg_directive` reads one ResolvedSshConfig algorithm directive, runs it through `algorithms::apply_overrides` against the curated default, and stores via the corresponding setter. Malformed values (denylisted entries) log a warn and leave the field on its None default so the curated list is used at session-build time. - warn_unhonored_directives: dropped the four algorithm-directive entries from the M17 deferral warning; only ConnectTimeout + ConnectionAttempts (M18) remain. src/session.rs: - build_russh_config(config: &AnvilConfig) — signature changed from the previous (Duration) form. Now consumes config's algorithm fields, falling back to anvil_default_* when each Option is None. Builds russh `Preferred` from the lists via three new private lookups (russh_kex_name / russh_cipher_name / russh_mac_name) that map user-supplied strings to russh's published Name constants. Unknown names are silently dropped from the set because russh's `Name` types only accept `&'static str`; Strict upfront validation lives in M17.4 against `algorithms::all_supported()`. - Host-key field uses the existing russh::keys::Algorithm::FromStr impl which round-trips unknown names via Algorithm::Other. - New `tracing::trace!` event at CAT_KEX listing the four offered preference vectors before connect — answers "what did Gitway TRY to negotiate?" as a M15 / FR-66 instrumentation companion to the existing `check_server_key` event. - Test sites updated to call `build_russh_config(&AnvilConfig::builder("test.example").build())`. Public API: pure additive. Existing consumers that destructure AnvilConfig with `..` syntax keep working. Existing callers of build_russh_config are internal-only — the signature change is crate-private. Plan: M17.2 of anvil-gitway-milestone-plan.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d47e952 commit 8019513

2 files changed

Lines changed: 279 additions & 49 deletions

File tree

src/config.rs

Lines changed: 141 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ pub struct AnvilConfig {
9191
/// GitHub: `ssh.github.com:443`. GitLab: `altssh.gitlab.com:443`.
9292
/// Codeberg has no published port-443 fallback.
9393
pub fallback: Option<(String, u16)>,
94+
/// Key-exchange algorithm preference (PRD §5.8.6 FR-76).
95+
///
96+
/// `None` selects [`crate::algorithms::anvil_default_kex`] — the
97+
/// curated default. `Some(list)` overrides; the list has
98+
/// already passed through
99+
/// [`crate::algorithms::apply_overrides`] (so any `+`/`-`/`^`
100+
/// prefix has been resolved and the FR-78 denylist applied).
101+
pub kex_algorithms: Option<Vec<String>>,
102+
/// Cipher preference (PRD §5.8.6 FR-76). `None` → curated default.
103+
pub ciphers: Option<Vec<String>>,
104+
/// MAC preference (PRD §5.8.6 FR-76). `None` → curated default.
105+
/// Mostly cosmetic for AEAD ciphers (chacha20-poly1305, AES-GCM)
106+
/// since they carry their own auth tag.
107+
pub macs: Option<Vec<String>>,
108+
/// Host-key algorithm preference (PRD §5.8.6 FR-76). `None` →
109+
/// curated default.
110+
pub host_key_algorithms: Option<Vec<String>>,
94111
}
95112

96113
impl AnvilConfig {
@@ -170,6 +187,10 @@ pub struct AnvilConfigBuilder {
170187
custom_known_hosts: Option<PathBuf>,
171188
verbose: bool,
172189
fallback: Option<(String, u16)>,
190+
kex_algorithms: Option<Vec<String>>,
191+
ciphers: Option<Vec<String>>,
192+
macs: Option<Vec<String>>,
193+
host_key_algorithms: Option<Vec<String>>,
173194
}
174195

175196
impl AnvilConfigBuilder {
@@ -190,6 +211,14 @@ impl AnvilConfigBuilder {
190211
// No fallback by default; provider-specific convenience
191212
// constructors set this when a known fallback exists.
192213
fallback: None,
214+
// Algorithm preferences default to None (= use the
215+
// curated `algorithms::anvil_default_*` lists at session
216+
// build time). M17.4 CLI flags overwrite these via the
217+
// four setters below.
218+
kex_algorithms: None,
219+
ciphers: None,
220+
macs: None,
221+
host_key_algorithms: None,
193222
}
194223
}
195224

@@ -292,6 +321,36 @@ impl AnvilConfigBuilder {
292321
self
293322
}
294323

324+
/// Override the key-exchange algorithm preference (PRD §5.8.6 FR-76).
325+
///
326+
/// Pass `None` to keep the curated default
327+
/// ([`crate::algorithms::anvil_default_kex`]). The list is
328+
/// expected to have already passed through
329+
/// [`crate::algorithms::apply_overrides`] so any
330+
/// `+`/`-`/`^` prefix is resolved and the FR-78 denylist applied.
331+
pub fn kex_algorithms(mut self, list: Option<Vec<String>>) -> Self {
332+
self.kex_algorithms = list;
333+
self
334+
}
335+
336+
/// Override the cipher preference (PRD §5.8.6 FR-76).
337+
pub fn ciphers(mut self, list: Option<Vec<String>>) -> Self {
338+
self.ciphers = list;
339+
self
340+
}
341+
342+
/// Override the MAC preference (PRD §5.8.6 FR-76).
343+
pub fn macs(mut self, list: Option<Vec<String>>) -> Self {
344+
self.macs = list;
345+
self
346+
}
347+
348+
/// Override the host-key algorithm preference (PRD §5.8.6 FR-76).
349+
pub fn host_key_algorithms(mut self, list: Option<Vec<String>>) -> Self {
350+
self.host_key_algorithms = list;
351+
self
352+
}
353+
295354
/// Layer values from a [`ResolvedSshConfig`] into this builder.
296355
///
297356
/// Provides ssh_config-derived defaults that subsequent builder calls
@@ -308,10 +367,23 @@ impl AnvilConfigBuilder {
308367
/// | `UserKnownHostsFile` (first) | `custom_known_hosts` (filled if `None`) |
309368
///
310369
/// Algorithm directives (`HostKeyAlgorithms`, `KexAlgorithms`,
311-
/// `Ciphers`, `MACs`) and `ConnectTimeout` / `ConnectionAttempts` are
312-
/// not yet plumbed through to the session builder; they are recorded
313-
/// in [`ResolvedSshConfig`] for `gitway config show` but consumption
314-
/// is deferred to M17 / M18.
370+
/// `Ciphers`, `MACs`) are honored as of M17 (PRD §5.8.6 FR-76):
371+
/// each parsed `AlgList` is fed through
372+
/// [`crate::algorithms::apply_overrides`] against the matching
373+
/// curated default, so an `ssh_config` value of `+algo,algo`
374+
/// appends to Anvil's defaults rather than replacing them.
375+
/// `ConnectTimeout` / `ConnectionAttempts` remain deferred to
376+
/// M18.
377+
///
378+
/// # Errors
379+
///
380+
/// This method is infallible by signature, but a malformed
381+
/// algorithm list (denylisted entry referenced by an override)
382+
/// is logged at `warn` level and silently dropped — the
383+
/// connection then falls back to the curated default. Callers
384+
/// who want strict validation should run the same value
385+
/// through [`crate::algorithms::apply_overrides`] explicitly
386+
/// before calling here.
315387
pub fn apply_ssh_config(mut self, resolved: &ResolvedSshConfig) -> Self {
316388
if let Some(hostname) = &resolved.hostname {
317389
self.host.clone_from(hostname);
@@ -332,10 +404,66 @@ impl AnvilConfigBuilder {
332404
self.custom_known_hosts = Some(p.clone());
333405
}
334406
}
407+
// M17 / FR-76: plumb algorithm directives through the
408+
// `+`/`-`/`^` parser against Anvil's curated defaults. CLI
409+
// overrides applied AFTER `apply_ssh_config` win over the
410+
// ssh_config-derived value (matches OpenSSH precedence).
411+
self.apply_alg_directive(
412+
crate::algorithms::AlgCategory::Kex,
413+
resolved.kex_algorithms.as_ref(),
414+
crate::algorithms::anvil_default_kex,
415+
|b, v| b.kex_algorithms = Some(v),
416+
);
417+
self.apply_alg_directive(
418+
crate::algorithms::AlgCategory::Cipher,
419+
resolved.ciphers.as_ref(),
420+
crate::algorithms::anvil_default_ciphers,
421+
|b, v| b.ciphers = Some(v),
422+
);
423+
self.apply_alg_directive(
424+
crate::algorithms::AlgCategory::Mac,
425+
resolved.macs.as_ref(),
426+
crate::algorithms::anvil_default_macs,
427+
|b, v| b.macs = Some(v),
428+
);
429+
self.apply_alg_directive(
430+
crate::algorithms::AlgCategory::HostKey,
431+
resolved.host_key_algorithms.as_ref(),
432+
crate::algorithms::anvil_default_host_keys,
433+
|b, v| b.host_key_algorithms = Some(v),
434+
);
335435
warn_unhonored_directives(resolved);
336436
self
337437
}
338438

439+
/// Internal helper for `apply_ssh_config`. Reads one algorithm
440+
/// directive from the resolved config, runs it through
441+
/// `apply_overrides` against the curated default, and stores the
442+
/// result via `setter`. Malformed values (denylisted entries)
443+
/// log a warning and leave the field on its `None` default so
444+
/// the curated list is used at session-build time.
445+
fn apply_alg_directive(
446+
&mut self,
447+
category: crate::algorithms::AlgCategory,
448+
directive: Option<&crate::ssh_config::AlgList>,
449+
default_fn: fn() -> Vec<String>,
450+
setter: fn(&mut Self, Vec<String>),
451+
) {
452+
let Some(crate::ssh_config::AlgList(value)) = directive else {
453+
return;
454+
};
455+
match crate::algorithms::apply_overrides(category, default_fn(), value) {
456+
Ok(list) => setter(self, list),
457+
Err(e) => {
458+
log::warn!(
459+
"ssh_config {category} directive '{value}' rejected: {e} \
460+
(falling back to Anvil curated default)",
461+
category = category.label(),
462+
);
463+
}
464+
}
465+
}
466+
339467
// (Unhonored-directives warning helper lives below `impl` block.)
340468

341469
/// Finalise and return the [`AnvilConfig`].
@@ -352,6 +480,10 @@ impl AnvilConfigBuilder {
352480
custom_known_hosts: self.custom_known_hosts,
353481
verbose: self.verbose,
354482
fallback: self.fallback,
483+
kex_algorithms: self.kex_algorithms,
484+
ciphers: self.ciphers,
485+
macs: self.macs,
486+
host_key_algorithms: self.host_key_algorithms,
355487
}
356488
}
357489
}
@@ -362,29 +494,14 @@ impl AnvilConfigBuilder {
362494
/// when a config is actually being prepared for connection — `gitway
363495
/// config show` and similar inspection callers do not trigger it.
364496
///
365-
/// The split:
366-
/// - `HostKeyAlgorithms`, `KexAlgorithms`, `Ciphers`, `MACs` — parsed
367-
/// into [`ResolvedSshConfig`] for `gitway config show`, but
368-
/// [`crate::session::build_russh_config`] uses hardcoded preferences
369-
/// today. M17 plumbs the `+`/`-`/`^` modifier semantics through to
370-
/// russh's preference list.
497+
/// The remaining unhonored set after M17:
371498
/// - `ConnectTimeout`, `ConnectionAttempts` — parsed but not yet wired
372499
/// into `connect()`. M18 honors them.
500+
///
501+
/// `HostKeyAlgorithms` / `KexAlgorithms` / `Ciphers` / `MACs` were
502+
/// the M17 deferral; they're now consumed by `apply_ssh_config` via
503+
/// the `apply_alg_directive` helper above.
373504
fn warn_unhonored_directives(resolved: &ResolvedSshConfig) {
374-
let mut m17: Vec<&'static str> = Vec::new();
375-
if resolved.host_key_algorithms.is_some() {
376-
m17.push("HostKeyAlgorithms");
377-
}
378-
if resolved.kex_algorithms.is_some() {
379-
m17.push("KexAlgorithms");
380-
}
381-
if resolved.ciphers.is_some() {
382-
m17.push("Ciphers");
383-
}
384-
if resolved.macs.is_some() {
385-
m17.push("MACs");
386-
}
387-
388505
let mut m18: Vec<&'static str> = Vec::new();
389506
if resolved.connect_timeout.is_some() {
390507
m18.push("ConnectTimeout");
@@ -393,14 +510,6 @@ fn warn_unhonored_directives(resolved: &ResolvedSshConfig) {
393510
m18.push("ConnectionAttempts");
394511
}
395512

396-
if !m17.is_empty() {
397-
log::warn!(
398-
"ssh_config: directive(s) {} parsed but not yet honored \
399-
(landing in M17 — Gitway PRD §8); current connections use \
400-
Anvil's hardcoded algorithm preferences",
401-
m17.join(", "),
402-
);
403-
}
404513
if !m18.is_empty() {
405514
log::warn!(
406515
"ssh_config: directive(s) {} parsed but not yet honored \

0 commit comments

Comments
 (0)