use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::hostkey::{
DEFAULT_CODEBERG_HOST, DEFAULT_GITHUB_HOST, DEFAULT_GITLAB_HOST, DEFAULT_PORT, FALLBACK_PORT,
GITHUB_FALLBACK_HOST, GITLAB_FALLBACK_HOST,
};
use crate::ssh_config::{ResolvedSshConfig, StrictHostKeyChecking};
#[derive(Debug, Clone)]
pub struct AnvilConfig {
pub host: String,
pub port: u16,
pub username: String,
pub identity_files: Vec<PathBuf>,
pub cert_file: Option<PathBuf>,
pub strict_host_key_checking: StrictHostKeyChecking,
pub inactivity_timeout: Duration,
pub custom_known_hosts: Option<PathBuf>,
pub verbose: bool,
pub fallback: Option<(String, u16)>,
pub kex_algorithms: Option<Vec<String>>,
pub ciphers: Option<Vec<String>>,
pub macs: Option<Vec<String>>,
pub host_key_algorithms: Option<Vec<String>>,
}
impl AnvilConfig {
pub fn builder(host: impl Into<String>) -> AnvilConfigBuilder {
AnvilConfigBuilder::new(host.into())
}
#[must_use]
pub fn github() -> Self {
Self::builder(DEFAULT_GITHUB_HOST)
.fallback(Some((GITHUB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
.build()
}
#[must_use]
pub fn gitlab() -> Self {
Self::builder(DEFAULT_GITLAB_HOST)
.fallback(Some((GITLAB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
.build()
}
#[must_use]
pub fn codeberg() -> Self {
Self::builder(DEFAULT_CODEBERG_HOST).build()
}
#[deprecated(since = "0.3.0", note = "read `identity_files` directly")]
#[must_use]
pub fn identity_file(&self) -> Option<&Path> {
self.identity_files.first().map(PathBuf::as_path)
}
#[deprecated(since = "0.3.0", note = "read `strict_host_key_checking` directly")]
#[must_use]
pub fn skip_host_check(&self) -> bool {
matches!(self.strict_host_key_checking, StrictHostKeyChecking::No)
}
}
#[derive(Debug)]
#[must_use]
pub struct AnvilConfigBuilder {
host: String,
port: u16,
username: String,
identity_files: Vec<PathBuf>,
cert_file: Option<PathBuf>,
strict_host_key_checking: StrictHostKeyChecking,
inactivity_timeout: Duration,
custom_known_hosts: Option<PathBuf>,
verbose: bool,
fallback: Option<(String, u16)>,
kex_algorithms: Option<Vec<String>>,
ciphers: Option<Vec<String>>,
macs: Option<Vec<String>>,
host_key_algorithms: Option<Vec<String>>,
}
impl AnvilConfigBuilder {
fn new(host: String) -> Self {
Self {
host,
port: DEFAULT_PORT,
username: "git".to_owned(),
identity_files: Vec::new(),
cert_file: None,
strict_host_key_checking: StrictHostKeyChecking::Yes,
inactivity_timeout: Duration::from_secs(60),
custom_known_hosts: None,
verbose: false,
fallback: None,
kex_algorithms: None,
ciphers: None,
macs: None,
host_key_algorithms: None,
}
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = username.into();
self
}
pub fn add_identity_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_files.push(path.into());
self
}
pub fn identity_files(mut self, paths: Vec<PathBuf>) -> Self {
self.identity_files = paths;
self
}
#[deprecated(
since = "0.3.0",
note = "use `add_identity_file` or `identity_files` for the multi-key API"
)]
pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_files.clear();
self.identity_files.push(path.into());
self
}
pub fn cert_file(mut self, path: impl Into<PathBuf>) -> Self {
self.cert_file = Some(path.into());
self
}
pub fn strict_host_key_checking(mut self, policy: StrictHostKeyChecking) -> Self {
self.strict_host_key_checking = policy;
self
}
#[deprecated(
since = "0.3.0",
note = "use `strict_host_key_checking(StrictHostKeyChecking::No)` for clarity"
)]
pub fn skip_host_check(mut self, skip: bool) -> Self {
self.strict_host_key_checking = if skip {
StrictHostKeyChecking::No
} else {
StrictHostKeyChecking::Yes
};
self
}
pub fn inactivity_timeout(mut self, timeout: Duration) -> Self {
self.inactivity_timeout = timeout;
self
}
pub fn custom_known_hosts(mut self, path: impl Into<PathBuf>) -> Self {
self.custom_known_hosts = Some(path.into());
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn fallback(mut self, fallback: Option<(String, u16)>) -> Self {
self.fallback = fallback;
self
}
pub fn kex_algorithms(mut self, list: Option<Vec<String>>) -> Self {
self.kex_algorithms = list;
self
}
pub fn ciphers(mut self, list: Option<Vec<String>>) -> Self {
self.ciphers = list;
self
}
pub fn macs(mut self, list: Option<Vec<String>>) -> Self {
self.macs = list;
self
}
pub fn host_key_algorithms(mut self, list: Option<Vec<String>>) -> Self {
self.host_key_algorithms = list;
self
}
pub fn apply_ssh_config(mut self, resolved: &ResolvedSshConfig) -> Self {
if let Some(hostname) = &resolved.hostname {
self.host.clone_from(hostname);
}
if let Some(port) = resolved.port {
self.port = port;
}
if let Some(user) = &resolved.user {
self.username.clone_from(user);
}
self.identity_files
.extend(resolved.identity_files.iter().cloned());
if let Some(policy) = resolved.strict_host_key_checking {
self.strict_host_key_checking = policy;
}
if self.custom_known_hosts.is_none() {
if let Some(p) = resolved.user_known_hosts_files.first() {
self.custom_known_hosts = Some(p.clone());
}
}
self.apply_alg_directive(
crate::algorithms::AlgCategory::Kex,
resolved.kex_algorithms.as_ref(),
crate::algorithms::anvil_default_kex,
|b, v| b.kex_algorithms = Some(v),
);
self.apply_alg_directive(
crate::algorithms::AlgCategory::Cipher,
resolved.ciphers.as_ref(),
crate::algorithms::anvil_default_ciphers,
|b, v| b.ciphers = Some(v),
);
self.apply_alg_directive(
crate::algorithms::AlgCategory::Mac,
resolved.macs.as_ref(),
crate::algorithms::anvil_default_macs,
|b, v| b.macs = Some(v),
);
self.apply_alg_directive(
crate::algorithms::AlgCategory::HostKey,
resolved.host_key_algorithms.as_ref(),
crate::algorithms::anvil_default_host_keys,
|b, v| b.host_key_algorithms = Some(v),
);
warn_unhonored_directives(resolved);
self
}
fn apply_alg_directive(
&mut self,
category: crate::algorithms::AlgCategory,
directive: Option<&crate::ssh_config::AlgList>,
default_fn: fn() -> Vec<String>,
setter: fn(&mut Self, Vec<String>),
) {
let Some(crate::ssh_config::AlgList(value)) = directive else {
return;
};
match crate::algorithms::apply_overrides(category, default_fn(), value) {
Ok(list) => setter(self, list),
Err(e) => {
log::warn!(
"ssh_config {category} directive '{value}' rejected: {e} \
(falling back to Anvil curated default)",
category = category.label(),
);
}
}
}
#[must_use]
pub fn build(self) -> AnvilConfig {
AnvilConfig {
host: self.host,
port: self.port,
username: self.username,
identity_files: self.identity_files,
cert_file: self.cert_file,
strict_host_key_checking: self.strict_host_key_checking,
inactivity_timeout: self.inactivity_timeout,
custom_known_hosts: self.custom_known_hosts,
verbose: self.verbose,
fallback: self.fallback,
kex_algorithms: self.kex_algorithms,
ciphers: self.ciphers,
macs: self.macs,
host_key_algorithms: self.host_key_algorithms,
}
}
}
fn warn_unhonored_directives(resolved: &ResolvedSshConfig) {
let mut m18: Vec<&'static str> = Vec::new();
if resolved.connect_timeout.is_some() {
m18.push("ConnectTimeout");
}
if resolved.connection_attempts.is_some() {
m18.push("ConnectionAttempts");
}
if !m18.is_empty() {
log::warn!(
"ssh_config: directive(s) {} parsed but not yet honored \
(landing in M18 — Gitway PRD §8)",
m18.join(", "),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults_yes_strict_host_check() {
let cfg = AnvilConfig::builder("h").build();
assert_eq!(cfg.strict_host_key_checking, StrictHostKeyChecking::Yes);
assert!(cfg.identity_files.is_empty());
}
#[test]
fn add_identity_file_accumulates() {
let cfg = AnvilConfig::builder("h")
.add_identity_file(PathBuf::from("/a"))
.add_identity_file(PathBuf::from("/b"))
.build();
assert_eq!(
cfg.identity_files,
vec![PathBuf::from("/a"), PathBuf::from("/b")],
);
}
#[test]
fn identity_files_replaces_list() {
let cfg = AnvilConfig::builder("h")
.add_identity_file(PathBuf::from("/old"))
.identity_files(vec![PathBuf::from("/new1"), PathBuf::from("/new2")])
.build();
assert_eq!(
cfg.identity_files,
vec![PathBuf::from("/new1"), PathBuf::from("/new2")],
);
}
#[test]
#[allow(deprecated, reason = "exercising the deprecated shim")]
fn deprecated_identity_file_shim_clears_then_pushes() {
let cfg = AnvilConfig::builder("h")
.add_identity_file(PathBuf::from("/should_be_cleared"))
.identity_file(PathBuf::from("/single"))
.build();
assert_eq!(cfg.identity_files, vec![PathBuf::from("/single")]);
assert_eq!(cfg.identity_file(), Some(Path::new("/single")));
}
#[test]
#[allow(deprecated, reason = "exercising the deprecated shim")]
fn deprecated_skip_host_check_maps_to_enum() {
let cfg_skip = AnvilConfig::builder("h").skip_host_check(true).build();
assert_eq!(cfg_skip.strict_host_key_checking, StrictHostKeyChecking::No);
assert!(cfg_skip.skip_host_check());
let cfg_check = AnvilConfig::builder("h").skip_host_check(false).build();
assert_eq!(
cfg_check.strict_host_key_checking,
StrictHostKeyChecking::Yes,
);
assert!(!cfg_check.skip_host_check());
}
#[test]
fn strict_host_key_checking_accepts_all_three() {
for policy in [
StrictHostKeyChecking::Yes,
StrictHostKeyChecking::No,
StrictHostKeyChecking::AcceptNew,
] {
let cfg = AnvilConfig::builder("h")
.strict_host_key_checking(policy)
.build();
assert_eq!(cfg.strict_host_key_checking, policy);
}
}
#[test]
fn apply_ssh_config_layers_resolved_values() {
let resolved = ResolvedSshConfig {
hostname: Some("real.example.com".to_owned()),
user: Some("alice".to_owned()),
port: Some(2222),
identity_files: vec![PathBuf::from("/cfg/key")],
strict_host_key_checking: Some(StrictHostKeyChecking::AcceptNew),
user_known_hosts_files: vec![PathBuf::from("/cfg/known_hosts")],
..ResolvedSshConfig::default()
};
let cfg = AnvilConfig::builder("alias")
.apply_ssh_config(&resolved)
.build();
assert_eq!(cfg.host, "real.example.com");
assert_eq!(cfg.port, 2222);
assert_eq!(cfg.username, "alice");
assert_eq!(cfg.identity_files, vec![PathBuf::from("/cfg/key")]);
assert_eq!(
cfg.strict_host_key_checking,
StrictHostKeyChecking::AcceptNew,
);
assert_eq!(
cfg.custom_known_hosts,
Some(PathBuf::from("/cfg/known_hosts"))
);
}
#[test]
fn apply_ssh_config_extends_identity_files_does_not_replace() {
let resolved = ResolvedSshConfig {
identity_files: vec![PathBuf::from("/cfg/a")],
..ResolvedSshConfig::default()
};
let cfg = AnvilConfig::builder("h")
.add_identity_file(PathBuf::from("/cli/first"))
.apply_ssh_config(&resolved)
.build();
assert_eq!(
cfg.identity_files,
vec![PathBuf::from("/cli/first"), PathBuf::from("/cfg/a")],
);
}
#[test]
fn apply_ssh_config_does_not_overwrite_explicit_known_hosts() {
let resolved = ResolvedSshConfig {
user_known_hosts_files: vec![PathBuf::from("/from/cfg")],
..ResolvedSshConfig::default()
};
let cfg = AnvilConfig::builder("h")
.custom_known_hosts(PathBuf::from("/from/cli"))
.apply_ssh_config(&resolved)
.build();
assert_eq!(cfg.custom_known_hosts, Some(PathBuf::from("/from/cli")));
}
}