Enable SSH remotes in native frontends (#117)

This commit is contained in:
hugo committed 2026-08-25 21:39:35 +02:00
1 parent 76e707f645
commit a737e74aae
22 files changed
+1214 -140

No files matched your search

+178 -3
View File
@@ -155,6 +155,7 @@ impl GitConflictResolution {
pub struct GitRemoteStatus {
name: String,
url: String,
transport: RemoteTransport,
ahead: usize,
behind: usize,
}
@@ -166,6 +167,9 @@ impl GitRemoteStatus {
pub fn url(&self) -> &str {
&self.url
}
pub const fn transport(&self) -> RemoteTransport {
self.transport
}
pub fn ahead(&self) -> usize {
self.ahead
}
@@ -834,6 +838,69 @@ pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProv
impl<T: GitCredentialProvider + SshPassphraseProvider> GitRemoteCredentialProvider for T {}
/// Adds one explicitly supplied SSH key passphrase to an existing credential
/// provider without changing HTTPS credential lookup or key selection.
pub struct GitRemoteCredentialOverride<'a, P> {
provider: &'a P,
ssh_passphrase: Option<(&'a SshFingerprint, &'a SecretBytes)>,
}
impl<'a, P> GitRemoteCredentialOverride<'a, P> {
pub const fn new(provider: &'a P) -> Self {
Self {
provider,
ssh_passphrase: None,
}
}
pub const fn with_ssh_passphrase(
provider: &'a P,
fingerprint: &'a SshFingerprint,
passphrase: &'a SecretBytes,
) -> Self {
Self {
provider,
ssh_passphrase: Some((fingerprint, passphrase)),
}
}
}
impl<P: GitCredentialProvider> GitCredentialProvider for GitRemoteCredentialOverride<'_, P> {
fn credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<GitCredential, GitError> {
self.provider.credential(server, application)
}
}
impl<P: SshPassphraseProvider> SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
if let Some((supplied_fingerprint, passphrase)) = self.ssh_passphrase
&& supplied_fingerprint == fingerprint
{
return Ok(SecretBytes::new(passphrase.expose().to_vec()));
}
self.provider.ssh_key_passphrase(fingerprint)
}
}
#[cfg(feature = "ssh")]
pub fn confirm_ssh_host(remote: &GitRemote, host_key: &SshHostKey) -> Result<(), GitError> {
let endpoint = remote
.endpoint()
.as_ssh()
.ok_or(GitError::SshAuthenticationNotConfigured)?;
if endpoint.host() != host_key.host() || endpoint.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)
}
pub trait GitSmartHttpTransport {
fn advertise_receive_pack(
&self,
@@ -1718,6 +1785,20 @@ impl GitRepository {
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
}
pub fn fetch_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport_controlled(
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn fetch_with_transport(
&self,
configured: &GitRemote,
@@ -1921,6 +2002,22 @@ impl GitRepository {
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
}
pub fn pull_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<PullOutcome, GitError> {
self.pull_with_transport_controlled(
configured,
branch,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn pull_with_transport(
&self,
configured: &GitRemote,
@@ -2065,7 +2162,7 @@ impl GitRepository {
)
}
fn push_controlled(
pub fn push_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
@@ -2349,6 +2446,7 @@ impl GitRepository {
Ok(GitRemoteStatus {
name: name.to_owned(),
url: actual_url,
transport: configured.endpoint().transport(),
ahead,
behind,
})
@@ -2404,6 +2502,7 @@ impl GitRepository {
remote: GitRemoteStatus {
name: name.to_owned(),
url: actual_url,
transport: configured.endpoint().transport(),
ahead,
behind,
},
@@ -4226,8 +4325,24 @@ mod ssh_push_tests;
#[cfg(test)]
mod tests {
use super::{GitIdentity, GitRepository};
use crate::repository::Repository;
use super::{
GitError, GitIdentity, GitRemoteCredentialOverride, GitRepository, SshPassphraseProvider,
};
use crate::{
config::SshFingerprint,
repository::{Repository, SecretBytes},
};
struct Passphrases;
impl SshPassphraseProvider for Passphrases {
fn ssh_key_passphrase(
&self,
_fingerprint: &SshFingerprint,
) -> Result<SecretBytes, GitError> {
Ok(SecretBytes::new(b"stored".to_vec()))
}
}
#[test]
fn embedded_identity_survives_local_config_updates() {
@@ -4240,4 +4355,64 @@ mod tests {
.expect("add remote");
assert!(repository.repository.committer().is_some());
}
#[test]
fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() {
let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
.expect("fingerprint");
let other = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
let supplied = SecretBytes::new(b"prompted".to_vec());
let credentials =
GitRemoteCredentialOverride::with_ssh_passphrase(&Passphrases, &selected, &supplied);
assert_eq!(
credentials
.ssh_key_passphrase(&selected)
.expect("override")
.expose(),
b"prompted"
);
assert_eq!(
credentials
.ssh_key_passphrase(&other)
.expect("stored fallback")
.expose(),
b"stored"
);
}
#[cfg(feature = "ssh")]
#[test]
fn host_confirmation_is_bound_to_the_configured_endpoint() {
let temporary = tempfile::tempdir().expect("temporary directory");
let known_hosts = temporary.path().join("known_hosts");
let fingerprint =
SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU")
.expect("fingerprint");
let authentication = crate::config::SshRemoteAuthentication::agent(
fingerprint.clone(),
None,
known_hosts.clone(),
)
.expect("authentication");
let remote = crate::config::GitRemote::ssh_with_authentication(
"origin",
"ssh://git@example.test:2222/team/store.git",
authentication,
)
.expect("remote");
let observed = super::SshHostKey::new(
"other.example.test".to_owned(),
2222,
"ssh-ed25519".to_owned(),
fingerprint,
"invalid-key".to_owned(),
);
assert_eq!(
super::confirm_ssh_host(&remote, &observed),
Err(GitError::SshProtocolFailed)
);
assert!(!known_hosts.exists());
}
}