Use repository Git remotes and SSH config
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled

This commit is contained in:
Georg Bauer
2026-08-26 09:32:21 +02:00
parent a3da9fda69
commit 300ccf5f1f
7 changed files with 316 additions and 23 deletions

View File

@@ -24,7 +24,10 @@ use russh::{
use sha1::Sha1;
use crate::{
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource, SshRepositoryPath},
config::{
GitRemote, RemoteEndpoint, SshEndpoint, SshFingerprint, SshIdentitySource,
SshRemoteAuthentication, SshRepositoryPath,
},
git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider},
};
@@ -124,18 +127,16 @@ impl SshSession {
let RemoteEndpoint::Ssh(endpoint) = remote.endpoint() else {
return Err(GitError::ForbiddenRemoteUrl);
};
let authentication = remote
.ssh_authentication()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
let connection = resolve_connection(endpoint, remote.ssh_authentication())?;
control.report(GitProgressPhase::Validating)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|_| GitError::SshProtocolFailed)?;
let host = endpoint.host().to_owned();
let port = endpoint.port();
let known_hosts = authentication.known_hosts_file().to_owned();
let host = connection.host.clone();
let port = connection.port;
let known_hosts = connection.known_hosts.clone();
let verifier = HostVerifier {
host: host.clone(),
port,
@@ -159,8 +160,8 @@ impl SshSession {
};
control.report(GitProgressPhase::Authenticating)?;
let user = endpoint.user().unwrap_or("git");
let authenticated = match authentication.identity() {
let user = connection.user.as_deref().unwrap_or("git");
let authenticated = match &connection.identity {
SshIdentitySource::KeyFile(path) => runtime.block_on(authenticate_key_file(
&mut handle,
user,
@@ -289,6 +290,150 @@ impl SshSession {
}
}
pub(crate) struct SshConnection {
pub(crate) host: String,
pub(crate) port: u16,
user: Option<String>,
identity: SshIdentitySource,
pub(crate) known_hosts: PathBuf,
}
pub(crate) fn resolve_connection(
endpoint: &SshEndpoint,
authentication: Option<&SshRemoteAuthentication>,
) -> Result<SshConnection, GitError> {
let home = home_directory().ok_or(GitError::SshAuthenticationNotConfigured)?;
let mut connection = SshConnection {
host: endpoint.host().to_owned(),
port: endpoint.port(),
user: endpoint.user().map(str::to_owned),
identity: authentication
.map(|authentication| authentication.identity().clone())
.unwrap_or_else(|| SshIdentitySource::KeyFile(home.join(".ssh/id_ed25519"))),
known_hosts: authentication
.map(|authentication| authentication.known_hosts_file().to_owned())
.unwrap_or_else(|| home.join(".ssh/known_hosts")),
};
apply_openssh_config(
&home.join(".ssh/config"),
endpoint.host(),
&home,
authentication.is_none(),
&mut connection,
)?;
Ok(connection)
}
fn home_directory() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let home = std::env::var_os("USERPROFILE");
#[cfg(not(target_os = "windows"))]
let home = std::env::var_os("HOME");
home.filter(|home| !home.is_empty()).map(PathBuf::from)
}
fn apply_openssh_config(
path: &Path,
alias: &str,
home: &Path,
use_identity: bool,
connection: &mut SshConnection,
) -> Result<(), GitError> {
let Ok(contents) = fs::read_to_string(path) else {
return Ok(());
};
let mut active = true;
let mut hostname_set = false;
let mut user_set = connection.user.is_some();
let mut port_set = connection.port != 22;
let mut identity_set = !use_identity;
for line in contents.lines() {
let Some(words) = shlex::split(line.split('#').next().unwrap_or_default()) else {
return Err(GitError::SshAuthenticationNotConfigured);
};
let Some((keyword, values)) = words.split_first() else {
continue;
};
if keyword.eq_ignore_ascii_case("host") {
active = host_patterns_match(values, alias);
} else if active && keyword.eq_ignore_ascii_case("hostname") && !hostname_set {
if let Some(value) = values.first() {
connection.host = value.clone();
hostname_set = true;
}
} else if active && keyword.eq_ignore_ascii_case("user") && !user_set {
if let Some(value) = values.first() {
connection.user = Some(value.clone());
user_set = true;
}
} else if active && keyword.eq_ignore_ascii_case("port") && !port_set {
if let Some(value) = values.first() {
connection.port = value
.parse()
.ok()
.filter(|port| *port != 0)
.ok_or(GitError::SshAuthenticationNotConfigured)?;
port_set = true;
}
} else if active
&& keyword.eq_ignore_ascii_case("identityfile")
&& !identity_set
&& let Some(value) = values.first()
{
let expanded = value
.replace("%h", &connection.host)
.replace("%p", &connection.port.to_string())
.replace("%r", connection.user.as_deref().unwrap_or("git"));
let identity = expanded
.strip_prefix("~/")
.map_or_else(|| PathBuf::from(&expanded), |path| home.join(path));
connection.identity = SshIdentitySource::KeyFile(identity);
identity_set = true;
}
}
Ok(())
}
fn host_patterns_match(patterns: &[String], host: &str) -> bool {
let mut matched = false;
for pattern in patterns {
let (negated, pattern) = pattern
.strip_prefix('!')
.map_or((false, pattern.as_str()), |pattern| (true, pattern));
if wildcard_match(pattern.as_bytes(), host.as_bytes()) {
if negated {
return false;
}
matched = true;
}
}
matched
}
fn wildcard_match(pattern: &[u8], value: &[u8]) -> bool {
let (mut pattern_index, mut value_index, mut star, mut retry) = (0, 0, None, 0);
while value_index < value.len() {
if pattern
.get(pattern_index)
.is_some_and(|byte| *byte == b'?' || byte.eq_ignore_ascii_case(&value[value_index]))
{
pattern_index += 1;
value_index += 1;
} else if pattern.get(pattern_index) == Some(&b'*') {
star = Some(pattern_index);
pattern_index += 1;
retry = value_index;
} else if let Some(star_index) = star {
pattern_index = star_index + 1;
retry += 1;
value_index = retry;
} else {
return false;
}
}
pattern[pattern_index..].iter().all(|byte| *byte == b'*')
}
fn client_config() -> client::Config {
client::Config {
inactivity_timeout: Some(CONNECTION_TIMEOUT),
@@ -1034,6 +1179,39 @@ mod tests {
server,
};
#[test]
fn openssh_host_alias_resolves_connection_and_identity() {
let temporary = tempfile::tempdir().expect("temporary directory");
let config = temporary.path().join("config");
fs::write(
&config,
"Host *\n IdentityFile ~/.ssh/id_ed25519\nHost git.example\n HostName internal.example\n User git\n Port 2222\n",
)
.expect("SSH config");
let mut connection = super::SshConnection {
host: "git.example".to_owned(),
port: 22,
user: None,
identity: crate::config::SshIdentitySource::KeyFile(temporary.path().join("unused")),
known_hosts: temporary.path().join("known_hosts"),
};
super::apply_openssh_config(
&config,
"git.example",
temporary.path(),
true,
&mut connection,
)
.expect("resolve SSH config");
assert_eq!(connection.host, "internal.example");
assert_eq!(connection.user.as_deref(), Some("git"));
assert_eq!(connection.port, 2222);
assert_eq!(
connection.identity.key_file(),
Some(temporary.path().join(".ssh/id_ed25519").as_path())
);
}
use crate::{
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshRemoteAuthentication},
git::{GitError, GitOperationControl, SshPassphraseProvider},