Implement SSH upload-pack fetches

This commit is contained in:
hugo committed 2026-08-25 20:31:10 +02:00
1 parent 5dbda4bbd2
commit 051e14235f
9 files changed
+1367 -53

No files matched your search

+138 -30
View File
@@ -489,6 +489,11 @@ pub enum GitError {
path: PathBuf,
},
SshProtocolFailed,
SshRemoteServiceFailed {
diagnostic: String,
},
GitProtocolFailed,
MalformedGitPack,
CredentialsUnavailable,
CredentialAccessDenied,
CredentialCancelled,
@@ -607,6 +612,14 @@ impl fmt::Display for GitError {
path.display()
),
Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"),
Self::SshRemoteServiceFailed { diagnostic } if diagnostic.is_empty() => {
formatter.write_str("the remote SSH Git service failed")
}
Self::SshRemoteServiceFailed { diagnostic } => {
write!(formatter, "the remote SSH Git service failed: {diagnostic}")
}
Self::GitProtocolFailed => formatter.write_str("the Git wire protocol failed"),
Self::MalformedGitPack => formatter.write_str("the remote sent a malformed Git pack"),
Self::CredentialsUnavailable => {
formatter.write_str("HTTPS Git credentials are unavailable")
}
@@ -617,7 +630,7 @@ impl fmt::Display for GitError {
formatter.write_str("HTTPS Git credential authentication was cancelled")
}
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
Self::NetworkUnavailable => formatter.write_str("the HTTPS Git remote is unavailable"),
Self::NetworkUnavailable => formatter.write_str("the Git remote is unavailable"),
Self::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"),
Self::Cancelled => formatter.write_str("the Git operation was cancelled"),
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
@@ -787,6 +800,10 @@ pub trait SshPassphraseProvider {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError>;
}
pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProvider {}
impl<T: GitCredentialProvider + SshPassphraseProvider> GitRemoteCredentialProvider for T {}
pub trait GitSmartHttpTransport {
fn advertise_receive_pack(
&self,
@@ -831,18 +848,18 @@ pub trait GitFetchTransport {
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError>;
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
control.checkpoint(GitProgressPhase::Receiving)?;
let result = self.fetch(repository, configured, credential)?;
let result = self.fetch(repository, configured, credentials)?;
control.checkpoint(GitProgressPhase::Receiving)?;
Ok(result)
}
@@ -856,19 +873,19 @@ impl GitFetchTransport for EmbeddedFetchTransport {
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
repository.fetch_embedded(configured, credential)
repository.fetch_embedded(configured, credentials)
}
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credential: &GitCredential,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
repository.fetch_embedded_controlled(configured, credential, control)
repository.fetch_embedded_controlled(configured, credentials, control)
}
}
@@ -968,6 +985,29 @@ fn map_reqwest_error(error: reqwest::Error) -> GitError {
}
}
#[cfg(feature = "ssh")]
fn map_ssh_fetch_error(
error: gix::remote::fetch::Error,
control: &GitOperationControl,
) -> GitError {
if control.is_cancelled() {
return GitError::Cancelled;
}
match error {
gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ConsumePack(_)) => {
GitError::MalformedGitPack
}
gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ReadRemainingBytes(_))
| gix::remote::fetch::Error::Client(_) => GitError::SshProtocolFailed,
gix::remote::fetch::Error::Fetch(
gix::protocol::fetch::Error::FetchResponse(_)
| gix::protocol::fetch::Error::Negotiate(_)
| gix::protocol::fetch::Error::MissingServerFeature { .. },
) => GitError::GitProtocolFailed,
error => invalid(error),
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FetchOutcome {
remote: String,
@@ -1018,7 +1058,7 @@ impl GitRepository {
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
Self::discover_remote_branches_with_transport(
@@ -1035,12 +1075,12 @@ impl GitRepository {
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
require_https_remote(configured)?;
ensure_remote_transport_available(configured.endpoint())?;
ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| {
@@ -1070,7 +1110,7 @@ impl GitRepository {
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
@@ -1088,7 +1128,7 @@ impl GitRepository {
identity: GitIdentity,
configured: &GitRemote,
branch: &str,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
@@ -1106,7 +1146,7 @@ impl GitRepository {
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
@@ -1125,12 +1165,12 @@ impl GitRepository {
identity: GitIdentity,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?;
require_https_remote(configured)?;
ensure_remote_transport_available(configured.endpoint())?;
if let Some(branch) = branch {
validate_remote_name(branch)?;
}
@@ -1449,7 +1489,7 @@ impl GitRepository {
pub fn fetch(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
}
@@ -1457,7 +1497,7 @@ impl GitRepository {
pub fn fetch_with_transport(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport_controlled(
@@ -1471,7 +1511,7 @@ impl GitRepository {
pub fn fetch_with_transport_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<FetchOutcome, GitError> {
@@ -1481,10 +1521,8 @@ impl GitRepository {
if !same_remote_endpoint(&actual_url, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl);
}
let (_, server_id, application_id) = require_https_remote(configured)?;
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = credentials.credential(server_id, application_id)?;
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
let received_pack = transport.fetch_controlled(self, configured, credentials, control)?;
Ok(FetchOutcome {
remote: name.to_owned(),
received_pack,
@@ -1498,9 +1536,9 @@ impl GitRepository {
fn fetch_embedded(
&self,
configured: &GitRemote,
credential: &GitCredential,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
self.fetch_embedded_controlled(configured, credential, &GitOperationControl::default())
self.fetch_embedded_controlled(configured, credentials, &GitOperationControl::default())
}
#[allow(
@@ -1508,6 +1546,37 @@ impl GitRepository {
reason = "the gix credential callback fixes its protocol error type"
)]
fn fetch_embedded_controlled(
&self,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
match configured.endpoint() {
RemoteEndpoint::Https(_) => {
let (_, server_id, application_id) = require_https_remote(configured)?;
let credential = credentials.credential(server_id, application_id)?;
self.fetch_embedded_https(configured, &credential, control)
}
RemoteEndpoint::Ssh(_) => {
#[cfg(feature = "ssh")]
{
self.fetch_embedded_ssh(configured, credentials, control)
}
#[cfg(not(feature = "ssh"))]
{
Err(GitError::UnsupportedRemoteTransport {
transport: RemoteTransport::Ssh,
})
}
}
}
}
#[allow(
clippy::result_large_err,
reason = "the gix credential callback fixes its protocol error type"
)]
fn fetch_embedded_https(
&self,
configured: &GitRemote,
credential: &GitCredential,
@@ -1583,11 +1652,47 @@ impl GitRepository {
))
}
#[cfg(feature = "ssh")]
fn fetch_embedded_ssh(
&self,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
let session = crate::ssh::SshSession::connect(configured, credentials, control)?;
let crate::ssh::SshGitCommand {
transport,
completion,
} = session.open_upload_pack(configured, control)?;
let fetch = (|| {
let remote = self
.repository
.find_fetch_remote(Some(configured.name().as_str().into()))
.map_err(invalid)?;
let connection = remote.to_connection_with_transport(transport);
let prepared = connection
.prepare_fetch(gix::progress::Discard, Default::default())
.map_err(|_| GitError::GitProtocolFailed)?;
prepared
.receive(gix::progress::Discard, control.cancelled.as_ref())
.map_err(|error| map_ssh_fetch_error(error, control))
})();
let command = session.finish_command(completion, control);
let close = session.close();
command?;
let outcome = fetch?;
close?;
Ok(matches!(
outcome.status,
gix::remote::fetch::Status::Change { .. }
))
}
pub fn pull(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<PullOutcome, GitError> {
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
}
@@ -1596,7 +1701,7 @@ impl GitRepository {
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<PullOutcome, GitError> {
self.pull_with_transport_controlled(
@@ -1612,7 +1717,7 @@ impl GitRepository {
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<PullOutcome, GitError> {
@@ -1833,7 +1938,7 @@ impl GitRepository {
pub fn sync(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
) -> Result<(PullOutcome, PushOutcome), GitError> {
let pull = self.pull(configured, None, credentials)?;
let push = self.push(configured, None, credentials)?;
@@ -1843,7 +1948,7 @@ impl GitRepository {
pub fn sync_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<(PullOutcome, PushOutcome), GitError> {
self.sync_with_transports_controlled(
@@ -1858,7 +1963,7 @@ impl GitRepository {
pub fn sync_with_transports_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
credentials: &impl GitRemoteCredentialProvider,
fetch: &impl GitFetchTransport,
push: &impl GitSmartHttpTransport,
control: &GitOperationControl,
@@ -3784,6 +3889,9 @@ fn io(operation: &'static str, path: &Path) -> GitError {
}
}
#[cfg(all(test, feature = "ssh"))]
mod ssh_tests;
#[cfg(test)]
mod tests {
use super::{GitIdentity, GitRepository};