Use repository Git remotes and SSH config
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user