use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use russh::keys::{HashAlg, PrivateKey, PrivateKeyWithHashAlg};
use crate::config::GitwayConfig;
use crate::error::{GitwayError, GitwayErrorKind};
#[derive(Debug)]
#[expect(
clippy::large_enum_variant,
reason = "IdentityResolution is short-lived (created once per session on the \
non-hot auth path); boxing PrivateKey would harm ergonomics with no \
measurable benefit."
)]
pub enum IdentityResolution {
Found {
key: PrivateKey,
path: PathBuf,
},
Encrypted {
path: PathBuf,
},
NotFound,
}
#[cfg(unix)]
pub struct AgentConnection {
pub client: russh::keys::agent::client::AgentClient<tokio::net::UnixStream>,
pub identities: Vec<russh::keys::agent::AgentIdentity>,
}
#[cfg(unix)]
impl fmt::Debug for AgentConnection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentConnection")
.field("identities", &self.identities)
.finish_non_exhaustive()
}
}
pub fn find_identity(config: &GitwayConfig) -> Result<IdentityResolution, GitwayError> {
if let Some(ref path) = config.identity_file {
return probe_key(path);
}
for path in default_key_paths() {
if !path.exists() {
continue;
}
match probe_key(&path)? {
IdentityResolution::NotFound => {}
found => return Ok(found),
}
}
Ok(IdentityResolution::NotFound)
}
pub fn load_encrypted_key(path: &Path, passphrase: &str) -> Result<PrivateKey, GitwayError> {
russh::keys::load_secret_key(path, Some(passphrase)).map_err(GitwayError::from)
}
pub fn load_cert(path: &Path) -> Result<russh::keys::Certificate, GitwayError> {
russh::keys::load_openssh_certificate(path)
.map_err(|e| GitwayError::from(russh::keys::Error::from(e)))
}
#[must_use]
pub fn wrap_key(key: PrivateKey, rsa_hash: Option<HashAlg>) -> PrivateKeyWithHashAlg {
PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash)
}
#[cfg(unix)]
pub async fn connect_agent() -> Result<Option<AgentConnection>, GitwayError> {
use russh::keys::agent::client::AgentClient;
let mut client = match AgentClient::connect_env().await {
Ok(c) => c,
Err(russh::keys::Error::EnvVar(_)) => {
log::debug!("auth: SSH_AUTH_SOCK not set; skipping agent");
return Ok(None);
}
Err(russh::keys::Error::BadAuthSock) => {
log::debug!("auth: SSH_AUTH_SOCK socket not found; skipping agent");
return Ok(None);
}
Err(e) => return Err(GitwayError::from(e)),
};
let identities = client
.request_identities()
.await
.map_err(GitwayError::from)?;
if identities.is_empty() {
log::debug!("auth: SSH agent has no identities");
return Ok(None);
}
log::debug!(
"auth: SSH agent offered {} identity/identities",
identities.len()
);
Ok(Some(AgentConnection { client, identities }))
}
fn default_key_paths() -> Vec<PathBuf> {
let Some(home) = dirs::home_dir() else {
log::warn!("auth: could not determine home directory; skipping default key paths");
return Vec::new();
};
let ssh = home.join(".ssh");
vec![
ssh.join("id_ed25519"),
ssh.join("id_ecdsa"),
ssh.join("id_rsa"),
]
}
fn probe_key(path: &Path) -> Result<IdentityResolution, GitwayError> {
match russh::keys::load_secret_key(path, None) {
Ok(key) => {
log::debug!("auth: loaded identity key from {}", path.display());
Ok(IdentityResolution::Found {
key,
path: path.to_owned(),
})
}
Err(russh::keys::Error::KeyIsEncrypted) => {
log::debug!(
"auth: identity key at {} is passphrase-protected",
path.display()
);
Ok(IdentityResolution::Encrypted {
path: path.to_owned(),
})
}
Err(russh::keys::Error::CouldNotReadKey) => {
Ok(IdentityResolution::NotFound)
}
Err(russh::keys::Error::IO(e)) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(IdentityResolution::NotFound)
}
Err(e) => Err(GitwayError::new(GitwayErrorKind::Keys(e))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_nonexistent_path_returns_not_found() {
let config = GitwayConfig::builder("github.com")
.identity_file("/tmp/gitway_test_nonexistent_key_xyz")
.build();
let result = find_identity(&config).unwrap();
assert!(matches!(result, IdentityResolution::NotFound));
}
#[test]
fn explicit_path_takes_priority_over_defaults() {
let config = GitwayConfig::builder("github.com")
.identity_file("/tmp/gitway_test_explicit_priority_xyz")
.build();
let result = find_identity(&config).unwrap();
assert!(
matches!(result, IdentityResolution::NotFound),
"explicit path must short-circuit default search"
);
}
#[test]
fn no_identity_file_falls_through_to_defaults() {
let config = GitwayConfig::builder("github.com").build();
let result = find_identity(&config);
assert!(
result.is_ok(),
"missing default keys must yield Ok(NotFound), not Err"
);
}
#[test]
fn load_cert_nonexistent_file_returns_error() {
let result = load_cert(Path::new("/tmp/gitway_test_nonexistent_cert_xyz.pub"));
assert!(result.is_err(), "loading a missing cert must return Err");
}
#[test]
fn default_key_paths_order_is_ed25519_ecdsa_rsa() {
let paths = default_key_paths();
if paths.is_empty() {
return;
}
assert_eq!(paths.len(), 3);
assert!(
paths[0].ends_with("id_ed25519"),
"first path must be id_ed25519"
);
assert!(
paths[1].ends_with("id_ecdsa"),
"second path must be id_ecdsa"
);
assert!(paths[2].ends_with("id_rsa"), "third path must be id_rsa");
}
}