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

@@ -13,7 +13,11 @@ use std::{
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
use gix::bstr::ByteSlice as _;
use serde::Deserialize;
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
use sha2::{Digest as _, Sha256};
use url::Url;
use crate::{
@@ -1694,7 +1698,14 @@ fn validate_config(
});
}
};
let git_remotes = validate_remotes(raw.git.remotes, Some(base))?;
let configured_git_remotes = raw.git.remotes;
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
let git_remotes = match repository_git_remotes(&vault)? {
Some(remotes) => remotes,
None => validate_remotes(configured_git_remotes, Some(base))?,
};
#[cfg(any(target_os = "ios", target_os = "watchos"))]
let git_remotes = validate_remotes(configured_git_remotes, Some(base))?;
Ok(Config {
source,
@@ -1715,6 +1726,75 @@ fn validate_config(
})
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
fn repository_git_remotes(vault: &Path) -> Result<Option<Vec<GitRemote>>, ConfigError> {
let path = vault.join(".git/config");
if !path.is_file() {
return Ok(None);
}
let config =
gix_config::File::from_path_no_includes(path, gix_config::Source::Local).map_err(|_| {
ConfigError::InvalidField {
field: "git.repository_remotes",
}
})?;
let mut remotes = Vec::new();
if let Some(sections) = config.sections_by_name("remote") {
for section in sections {
let name = section
.header()
.subsection_name()
.and_then(|name| name.to_str().ok())
.ok_or(ConfigError::InvalidField {
field: "git.repository_remotes",
})?;
let url = config
.raw_value_by("remote", Some(name.into()), "url")
.ok()
.and_then(|url| url.to_str().ok().map(str::to_owned))
.ok_or(ConfigError::InvalidRemoteUrl {
name: name.to_owned(),
})?;
let endpoint =
RemoteEndpoint::parse(&url).map_err(|_| ConfigError::InvalidRemoteUrl {
name: name.to_owned(),
})?;
let remote = match endpoint {
RemoteEndpoint::Https(ref endpoint) => GitRemote::https(
name,
&url,
stable_identifier(
"server",
format!(
"{}://{}:{}",
endpoint.scheme(),
endpoint.host_str().unwrap_or_default(),
endpoint.port_or_known_default().unwrap_or(443)
)
.as_bytes(),
),
stable_identifier("repository", url.as_bytes()),
)?,
RemoteEndpoint::Ssh(_) => GitRemote::ssh(name, &url)?,
};
remotes.push(remote);
}
}
Ok((!remotes.is_empty()).then_some(remotes))
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
fn stable_identifier(prefix: &str, value: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let digest = Sha256::digest(value);
let suffix: String = digest[..8]
.iter()
.flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]])
.map(char::from)
.collect();
format!("{prefix}-{suffix}")
}
fn resolve_required_path(
base: &Path,
value: Option<PathBuf>,

View File

@@ -892,13 +892,11 @@ pub fn confirm_ssh_host(remote: &GitRemote, host_key: &SshHostKey) -> Result<(),
.endpoint()
.as_ssh()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
if endpoint.host() != host_key.host() || endpoint.port() != host_key.port() {
let connection = crate::ssh::resolve_connection(endpoint, remote.ssh_authentication())?;
if connection.host != host_key.host() || connection.port != host_key.port() {
return Err(GitError::SshProtocolFailed);
}
let authentication = remote
.ssh_authentication()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
crate::ssh::persist_confirmed_host(authentication.known_hosts_file(), host_key)
crate::ssh::persist_confirmed_host(&connection.known_hosts, host_key)
}
pub trait GitSmartHttpTransport {

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},

View File

@@ -102,6 +102,37 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
Ok(())
}
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
#[test]
fn desktop_configuration_uses_repository_remotes_without_duplicate_settings() -> TestResult {
let fixture = ConfigurationFixture::new()?;
let vault = fixture.temporary.path().join("cwd/vault");
fs::create_dir_all(vault.join(".git"))?;
fs::write(
vault.join(".git/config"),
"[remote \"origin\"]\n\turl = git@git.example:team/store.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
)?;
fixture.write_explicit(
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
"#,
)?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.git_remotes().len(), 1);
assert_eq!(config.git_remotes()[0].name().as_str(), "origin");
assert_eq!(
config.git_remotes()[0].url(),
"git@git.example:team/store.git"
);
assert!(config.git_remotes()[0].ssh_authentication().is_none());
Ok(())
}
#[test]
fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
let fixture = ConfigurationFixture::new()?;