Implement SSH upload-pack fetches
This commit is contained in:
@@ -15,7 +15,7 @@ use std::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||||
git::{GitCredential, GitCredentialProvider, GitError},
|
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
secret_store::{
|
secret_store::{
|
||||||
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference,
|
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference,
|
||||||
@@ -441,6 +441,33 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<B: SecretStoreBackend, C: AuthenticationClock> SshPassphraseProvider
|
||||||
|
for AuthenticationHandle<B, C>
|
||||||
|
{
|
||||||
|
fn ssh_key_passphrase(
|
||||||
|
&self,
|
||||||
|
fingerprint: &crate::config::SshFingerprint,
|
||||||
|
) -> Result<SecretBytes, GitError> {
|
||||||
|
let _operation =
|
||||||
|
self.shared
|
||||||
|
.operation()
|
||||||
|
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})?;
|
||||||
|
self.shared
|
||||||
|
.expire_if_needed()
|
||||||
|
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})?;
|
||||||
|
self.shared
|
||||||
|
.with_active(self.generation, |_| ())
|
||||||
|
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})?;
|
||||||
|
self.shared.store.ssh_key_passphrase(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
||||||
fn operation(&self) -> Result<std::sync::MutexGuard<'_, ()>, AuthenticationError> {
|
fn operation(&self) -> Result<std::sync::MutexGuard<'_, ()>, AuthenticationError> {
|
||||||
self.operation
|
self.operation
|
||||||
|
|||||||
@@ -1343,6 +1343,13 @@ impl GitRemote {
|
|||||||
GitRemoteCredentials::Https { .. } => None,
|
GitRemoteCredentials::Https { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssh"))]
|
||||||
|
pub(crate) fn set_ssh_test_port(&mut self, port: u16) {
|
||||||
|
if let RemoteEndpoint::Ssh(endpoint) = &mut self.endpoint {
|
||||||
|
endpoint.port = port;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An executable and arguments. It is never interpreted by a shell.
|
/// An executable and arguments. It is never interpreted by a shell.
|
||||||
|
|||||||
@@ -489,6 +489,11 @@ pub enum GitError {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
SshProtocolFailed,
|
SshProtocolFailed,
|
||||||
|
SshRemoteServiceFailed {
|
||||||
|
diagnostic: String,
|
||||||
|
},
|
||||||
|
GitProtocolFailed,
|
||||||
|
MalformedGitPack,
|
||||||
CredentialsUnavailable,
|
CredentialsUnavailable,
|
||||||
CredentialAccessDenied,
|
CredentialAccessDenied,
|
||||||
CredentialCancelled,
|
CredentialCancelled,
|
||||||
@@ -607,6 +612,14 @@ impl fmt::Display for GitError {
|
|||||||
path.display()
|
path.display()
|
||||||
),
|
),
|
||||||
Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"),
|
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 => {
|
Self::CredentialsUnavailable => {
|
||||||
formatter.write_str("HTTPS Git credentials are unavailable")
|
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")
|
formatter.write_str("HTTPS Git credential authentication was cancelled")
|
||||||
}
|
}
|
||||||
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
|
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::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"),
|
||||||
Self::Cancelled => formatter.write_str("the Git operation was cancelled"),
|
Self::Cancelled => formatter.write_str("the Git operation was cancelled"),
|
||||||
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
|
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>;
|
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 {
|
pub trait GitSmartHttpTransport {
|
||||||
fn advertise_receive_pack(
|
fn advertise_receive_pack(
|
||||||
&self,
|
&self,
|
||||||
@@ -831,18 +848,18 @@ pub trait GitFetchTransport {
|
|||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError>;
|
) -> Result<bool, GitError>;
|
||||||
|
|
||||||
fn fetch_controlled(
|
fn fetch_controlled(
|
||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
control.checkpoint(GitProgressPhase::Receiving)?;
|
control.checkpoint(GitProgressPhase::Receiving)?;
|
||||||
let result = self.fetch(repository, configured, credential)?;
|
let result = self.fetch(repository, configured, credentials)?;
|
||||||
control.checkpoint(GitProgressPhase::Receiving)?;
|
control.checkpoint(GitProgressPhase::Receiving)?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -856,19 +873,19 @@ impl GitFetchTransport for EmbeddedFetchTransport {
|
|||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
repository.fetch_embedded(configured, credential)
|
repository.fetch_embedded(configured, credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_controlled(
|
fn fetch_controlled(
|
||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<bool, GitError> {
|
) -> 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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct FetchOutcome {
|
pub struct FetchOutcome {
|
||||||
remote: String,
|
remote: String,
|
||||||
@@ -1018,7 +1058,7 @@ impl GitRepository {
|
|||||||
parent: &Path,
|
parent: &Path,
|
||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Vec<String>, GitError> {
|
) -> Result<Vec<String>, GitError> {
|
||||||
Self::discover_remote_branches_with_transport(
|
Self::discover_remote_branches_with_transport(
|
||||||
@@ -1035,12 +1075,12 @@ impl GitRepository {
|
|||||||
parent: &Path,
|
parent: &Path,
|
||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Vec<String>, GitError> {
|
) -> Result<Vec<String>, GitError> {
|
||||||
control.report(GitProgressPhase::Validating)?;
|
control.report(GitProgressPhase::Validating)?;
|
||||||
require_https_remote(configured)?;
|
ensure_remote_transport_available(configured.endpoint())?;
|
||||||
ensure_clone_parent(parent)?;
|
ensure_clone_parent(parent)?;
|
||||||
let temporary = private_temporary_directory(parent, "probe")?;
|
let temporary = private_temporary_directory(parent, "probe")?;
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
@@ -1070,7 +1110,7 @@ impl GitRepository {
|
|||||||
destination: &Path,
|
destination: &Path,
|
||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
Self::clone_into_with_transport_controlled(
|
Self::clone_into_with_transport_controlled(
|
||||||
destination,
|
destination,
|
||||||
@@ -1088,7 +1128,7 @@ impl GitRepository {
|
|||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
Self::clone_into_with_transport_controlled(
|
Self::clone_into_with_transport_controlled(
|
||||||
@@ -1106,7 +1146,7 @@ impl GitRepository {
|
|||||||
destination: &Path,
|
destination: &Path,
|
||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
Self::clone_into_with_transport_controlled(
|
Self::clone_into_with_transport_controlled(
|
||||||
@@ -1125,12 +1165,12 @@ impl GitRepository {
|
|||||||
identity: GitIdentity,
|
identity: GitIdentity,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
branch: Option<&str>,
|
branch: Option<&str>,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
control.report(GitProgressPhase::Validating)?;
|
control.report(GitProgressPhase::Validating)?;
|
||||||
require_https_remote(configured)?;
|
ensure_remote_transport_available(configured.endpoint())?;
|
||||||
if let Some(branch) = branch {
|
if let Some(branch) = branch {
|
||||||
validate_remote_name(branch)?;
|
validate_remote_name(branch)?;
|
||||||
}
|
}
|
||||||
@@ -1449,7 +1489,7 @@ impl GitRepository {
|
|||||||
pub fn fetch(
|
pub fn fetch(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
) -> Result<FetchOutcome, GitError> {
|
) -> Result<FetchOutcome, GitError> {
|
||||||
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
|
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
|
||||||
}
|
}
|
||||||
@@ -1457,7 +1497,7 @@ impl GitRepository {
|
|||||||
pub fn fetch_with_transport(
|
pub fn fetch_with_transport(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
) -> Result<FetchOutcome, GitError> {
|
) -> Result<FetchOutcome, GitError> {
|
||||||
self.fetch_with_transport_controlled(
|
self.fetch_with_transport_controlled(
|
||||||
@@ -1471,7 +1511,7 @@ impl GitRepository {
|
|||||||
pub fn fetch_with_transport_controlled(
|
pub fn fetch_with_transport_controlled(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<FetchOutcome, GitError> {
|
) -> Result<FetchOutcome, GitError> {
|
||||||
@@ -1481,10 +1521,8 @@ impl GitRepository {
|
|||||||
if !same_remote_endpoint(&actual_url, configured.url())? {
|
if !same_remote_endpoint(&actual_url, configured.url())? {
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
}
|
}
|
||||||
let (_, server_id, application_id) = require_https_remote(configured)?;
|
|
||||||
control.checkpoint(GitProgressPhase::Authenticating)?;
|
control.checkpoint(GitProgressPhase::Authenticating)?;
|
||||||
let credential = credentials.credential(server_id, application_id)?;
|
let received_pack = transport.fetch_controlled(self, configured, credentials, control)?;
|
||||||
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
|
|
||||||
Ok(FetchOutcome {
|
Ok(FetchOutcome {
|
||||||
remote: name.to_owned(),
|
remote: name.to_owned(),
|
||||||
received_pack,
|
received_pack,
|
||||||
@@ -1498,9 +1536,9 @@ impl GitRepository {
|
|||||||
fn fetch_embedded(
|
fn fetch_embedded(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
self.fetch_embedded_controlled(configured, credential, &GitOperationControl::default())
|
self.fetch_embedded_controlled(configured, credentials, &GitOperationControl::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
@@ -1508,6 +1546,37 @@ impl GitRepository {
|
|||||||
reason = "the gix credential callback fixes its protocol error type"
|
reason = "the gix credential callback fixes its protocol error type"
|
||||||
)]
|
)]
|
||||||
fn fetch_embedded_controlled(
|
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,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
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(
|
pub fn pull(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
branch: Option<&str>,
|
branch: Option<&str>,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
) -> Result<PullOutcome, GitError> {
|
) -> Result<PullOutcome, GitError> {
|
||||||
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
|
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
|
||||||
}
|
}
|
||||||
@@ -1596,7 +1701,7 @@ impl GitRepository {
|
|||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
branch: Option<&str>,
|
branch: Option<&str>,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
) -> Result<PullOutcome, GitError> {
|
) -> Result<PullOutcome, GitError> {
|
||||||
self.pull_with_transport_controlled(
|
self.pull_with_transport_controlled(
|
||||||
@@ -1612,7 +1717,7 @@ impl GitRepository {
|
|||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
branch: Option<&str>,
|
branch: Option<&str>,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<PullOutcome, GitError> {
|
) -> Result<PullOutcome, GitError> {
|
||||||
@@ -1833,7 +1938,7 @@ impl GitRepository {
|
|||||||
pub fn sync(
|
pub fn sync(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
||||||
let pull = self.pull(configured, None, credentials)?;
|
let pull = self.pull(configured, None, credentials)?;
|
||||||
let push = self.push(configured, None, credentials)?;
|
let push = self.push(configured, None, credentials)?;
|
||||||
@@ -1843,7 +1948,7 @@ impl GitRepository {
|
|||||||
pub fn sync_controlled(
|
pub fn sync_controlled(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
) -> Result<(PullOutcome, PushOutcome), GitError> {
|
||||||
self.sync_with_transports_controlled(
|
self.sync_with_transports_controlled(
|
||||||
@@ -1858,7 +1963,7 @@ impl GitRepository {
|
|||||||
pub fn sync_with_transports_controlled(
|
pub fn sync_with_transports_controlled(
|
||||||
&self,
|
&self,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credentials: &impl GitCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
fetch: &impl GitFetchTransport,
|
fetch: &impl GitFetchTransport,
|
||||||
push: &impl GitSmartHttpTransport,
|
push: &impl GitSmartHttpTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
@@ -3784,6 +3889,9 @@ fn io(operation: &'static str, path: &Path) -> GitError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssh"))]
|
||||||
|
mod ssh_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{GitIdentity, GitRepository};
|
use super::{GitIdentity, GitRepository};
|
||||||
|
|||||||
784
crates/storage/src/git/ssh_tests.rs
Normal file
784
crates/storage/src/git/ssh_tests.rs
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
fs,
|
||||||
|
path::Path,
|
||||||
|
sync::{Arc, Mutex, mpsc},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use russh::{
|
||||||
|
Channel, ChannelId,
|
||||||
|
keys::{PrivateKey, PublicKey, ssh_key::Algorithm},
|
||||||
|
server,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||||
|
GitRepository, PullOutcome, SshPassphraseProvider, build_pack,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
|
||||||
|
repository::{Repository, SecretBytes},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Behavior {
|
||||||
|
Normal,
|
||||||
|
MalformedAdvertisement,
|
||||||
|
MalformedPack,
|
||||||
|
NonZero,
|
||||||
|
Disconnect,
|
||||||
|
Slow,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Fixture {
|
||||||
|
advertisement: Vec<u8>,
|
||||||
|
pack: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UploadServer {
|
||||||
|
user_key: PublicKey,
|
||||||
|
fixture: Arc<Mutex<Fixture>>,
|
||||||
|
commands: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||||
|
behavior: Behavior,
|
||||||
|
input: Vec<u8>,
|
||||||
|
saw_want: bool,
|
||||||
|
saw_have: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for UploadServer {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
user_key: self.user_key.clone(),
|
||||||
|
fixture: Arc::clone(&self.fixture),
|
||||||
|
commands: Arc::clone(&self.commands),
|
||||||
|
behavior: self.behavior,
|
||||||
|
input: Vec::new(),
|
||||||
|
saw_want: false,
|
||||||
|
saw_have: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl server::Handler for UploadServer {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn auth_publickey(
|
||||||
|
&mut self,
|
||||||
|
user: &str,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
) -> Result<server::Auth, Self::Error> {
|
||||||
|
Ok(if user == "git" && public_key == &self.user_key {
|
||||||
|
server::Auth::Accept
|
||||||
|
} else {
|
||||||
|
server::Auth::reject()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
_channel: Channel<server::Msg>,
|
||||||
|
reply: server::ChannelOpenHandle,
|
||||||
|
_session: &mut server::Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
reply.accept().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_request(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
command: &[u8],
|
||||||
|
session: &mut server::Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.commands
|
||||||
|
.lock()
|
||||||
|
.expect("commands")
|
||||||
|
.push(command.to_vec());
|
||||||
|
if !command.starts_with(b"git-upload-pack '") || !command.ends_with(b"'") {
|
||||||
|
session.channel_failure(channel)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
session.channel_success(channel)?;
|
||||||
|
match self.behavior {
|
||||||
|
Behavior::MalformedAdvertisement => session.data(channel, b"zzzz".to_vec())?,
|
||||||
|
Behavior::Disconnect => {
|
||||||
|
session.close(channel)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => session.data(
|
||||||
|
channel,
|
||||||
|
self.fixture.lock().expect("fixture").advertisement.clone(),
|
||||||
|
)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn data(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
data: &[u8],
|
||||||
|
session: &mut server::Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
if matches!(self.behavior, Behavior::Slow | Behavior::Disconnect) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.input.extend_from_slice(data);
|
||||||
|
while self.input.len() >= 4 {
|
||||||
|
let Some(length) = std::str::from_utf8(&self.input[..4])
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| usize::from_str_radix(value, 16).ok())
|
||||||
|
else {
|
||||||
|
session.close(channel)?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if length == 0 {
|
||||||
|
self.input.drain(..4);
|
||||||
|
if !self.saw_want {
|
||||||
|
self.finish_without_pack(channel, session)?;
|
||||||
|
} else if self.saw_have {
|
||||||
|
session.data(channel, packet(b"NAK\n"))?;
|
||||||
|
self.saw_have = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if length < 4 || self.input.len() < length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let line = self.input[4..length].to_vec();
|
||||||
|
self.input.drain(..length);
|
||||||
|
if line.starts_with(b"want ") {
|
||||||
|
self.saw_want = true;
|
||||||
|
} else if line.starts_with(b"have ") {
|
||||||
|
self.saw_have = true;
|
||||||
|
} else if line == b"done\n" || line == b"done" {
|
||||||
|
self.send_pack(channel, session)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadServer {
|
||||||
|
fn finish_without_pack(
|
||||||
|
&self,
|
||||||
|
channel: ChannelId,
|
||||||
|
session: &mut server::Session,
|
||||||
|
) -> Result<(), russh::Error> {
|
||||||
|
if matches!(self.behavior, Behavior::NonZero) {
|
||||||
|
session.extended_data(channel, 1, b"repository unavailable\n".to_vec())?;
|
||||||
|
session.exit_status_request(channel, 9)?;
|
||||||
|
} else {
|
||||||
|
session.exit_status_request(channel, 0)?;
|
||||||
|
}
|
||||||
|
session.eof(channel)?;
|
||||||
|
session.close(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_pack(
|
||||||
|
&self,
|
||||||
|
channel: ChannelId,
|
||||||
|
session: &mut server::Session,
|
||||||
|
) -> Result<(), russh::Error> {
|
||||||
|
session.data(channel, packet(b"NAK\n"))?;
|
||||||
|
let fixture = self.fixture.lock().expect("fixture").clone();
|
||||||
|
let pack = if matches!(self.behavior, Behavior::MalformedPack) {
|
||||||
|
b"PACK\0\0\0\x02\0\0\0\x01broken".as_slice()
|
||||||
|
} else {
|
||||||
|
fixture.pack.as_slice()
|
||||||
|
};
|
||||||
|
for chunk in pack.chunks(997) {
|
||||||
|
let mut sideband = Vec::with_capacity(chunk.len() + 1);
|
||||||
|
sideband.push(1);
|
||||||
|
sideband.extend_from_slice(chunk);
|
||||||
|
session.data(channel, packet(&sideband))?;
|
||||||
|
}
|
||||||
|
session.data(channel, b"0000".to_vec())?;
|
||||||
|
if matches!(self.behavior, Behavior::NonZero) {
|
||||||
|
session.extended_data(channel, 1, b"upload-pack failed\n".to_vec())?;
|
||||||
|
session.exit_status_request(channel, 7)?;
|
||||||
|
} else {
|
||||||
|
session.exit_status_request(channel, 0)?;
|
||||||
|
}
|
||||||
|
session.eof(channel)?;
|
||||||
|
session.close(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Server {
|
||||||
|
port: u16,
|
||||||
|
host_key: PrivateKey,
|
||||||
|
fixture: Arc<Mutex<Fixture>>,
|
||||||
|
commands: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||||
|
join: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_server(
|
||||||
|
user_key: PublicKey,
|
||||||
|
fixture: Fixture,
|
||||||
|
behavior: Behavior,
|
||||||
|
connections: usize,
|
||||||
|
) -> Server {
|
||||||
|
let host_key = key();
|
||||||
|
let shared_fixture = Arc::new(Mutex::new(fixture));
|
||||||
|
let commands = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let handler = UploadServer {
|
||||||
|
user_key,
|
||||||
|
fixture: Arc::clone(&shared_fixture),
|
||||||
|
commands: Arc::clone(&commands),
|
||||||
|
behavior,
|
||||||
|
input: Vec::new(),
|
||||||
|
saw_want: false,
|
||||||
|
saw_have: false,
|
||||||
|
};
|
||||||
|
let server_key = host_key.clone();
|
||||||
|
let (port_tx, port_rx) = mpsc::channel();
|
||||||
|
let join = thread::spawn(move || {
|
||||||
|
tokio::runtime::Runtime::new()
|
||||||
|
.expect("server runtime")
|
||||||
|
.block_on(async move {
|
||||||
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.expect("bind server");
|
||||||
|
port_tx
|
||||||
|
.send(listener.local_addr().expect("address").port())
|
||||||
|
.expect("send port");
|
||||||
|
let config = Arc::new(server::Config {
|
||||||
|
keys: vec![server_key],
|
||||||
|
auth_rejection_time: Duration::ZERO,
|
||||||
|
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||||
|
..server::Config::default()
|
||||||
|
});
|
||||||
|
for _ in 0..connections {
|
||||||
|
let (stream, _) = listener.accept().await.expect("accept client");
|
||||||
|
if let Ok(session) =
|
||||||
|
server::run_stream(Arc::clone(&config), stream, handler.clone()).await
|
||||||
|
{
|
||||||
|
let _ = session.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Server {
|
||||||
|
port: port_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("server port"),
|
||||||
|
host_key,
|
||||||
|
fixture: shared_fixture,
|
||||||
|
commands,
|
||||||
|
join,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Credentials;
|
||||||
|
|
||||||
|
impl GitCredentialProvider for Credentials {
|
||||||
|
fn credential(
|
||||||
|
&self,
|
||||||
|
_server: &crate::config::ServerId,
|
||||||
|
_application: &crate::config::ApplicationId,
|
||||||
|
) -> Result<GitCredential, GitError> {
|
||||||
|
Err(GitError::CredentialsUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshPassphraseProvider for Credentials {
|
||||||
|
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||||
|
Err(GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key() -> PrivateKey {
|
||||||
|
PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519).expect("generate key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packet(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
|
||||||
|
output.extend_from_slice(data);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity() -> GitIdentity {
|
||||||
|
GitIdentity::new("SSH Test", "ssh@ironstorage.invalid").expect("identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit(git: &GitRepository, path: &str, contents: &[u8], message: &str) -> String {
|
||||||
|
fs::write(git.root().join(path), contents).expect("write worktree");
|
||||||
|
git.stage(&[path.into()]).expect("stage");
|
||||||
|
git.commit(message).expect("commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populated_fixture(repository: &GitRepository) -> Fixture {
|
||||||
|
let head = repository.repository.head_commit().expect("head").id;
|
||||||
|
let capabilities = "multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag symref=HEAD:refs/heads/main";
|
||||||
|
let mut advertisement = packet(format!("{head} HEAD\0{capabilities}\n").as_bytes());
|
||||||
|
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/feature\n").as_bytes()));
|
||||||
|
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/main\n").as_bytes()));
|
||||||
|
advertisement.extend_from_slice(&packet(format!("{head} refs/tags/v1\n").as_bytes()));
|
||||||
|
advertisement.extend_from_slice(b"0000");
|
||||||
|
Fixture {
|
||||||
|
advertisement,
|
||||||
|
pack: build_pack(&repository.repository, head, None).expect("pack"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_fixture() -> Fixture {
|
||||||
|
let mut advertisement = packet(
|
||||||
|
b"0000000000000000000000000000000000000000 capabilities^{}\0multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag\n",
|
||||||
|
);
|
||||||
|
advertisement.extend_from_slice(b"0000");
|
||||||
|
Fixture {
|
||||||
|
advertisement,
|
||||||
|
pack: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote(root: &Path, server: &Server, identity: &PrivateKey, path: &str) -> GitRemote {
|
||||||
|
remote_with_url(
|
||||||
|
root,
|
||||||
|
server,
|
||||||
|
identity,
|
||||||
|
format!("ssh://git@127.0.0.1:{}{path}", server.port),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_with_url(root: &Path, server: &Server, identity: &PrivateKey, url: String) -> GitRemote {
|
||||||
|
let identity_file = root.join("identity");
|
||||||
|
fs::write(
|
||||||
|
&identity_file,
|
||||||
|
identity
|
||||||
|
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
|
||||||
|
.expect("identity"),
|
||||||
|
)
|
||||||
|
.expect("write identity");
|
||||||
|
let known_hosts = root.join("known_hosts");
|
||||||
|
fs::write(
|
||||||
|
&known_hosts,
|
||||||
|
format!(
|
||||||
|
"[127.0.0.1]:{} {}\n",
|
||||||
|
server.port,
|
||||||
|
server.host_key.public_key().to_openssh().expect("host key")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("known hosts");
|
||||||
|
let mut remote = GitRemote::ssh_with_authentication(
|
||||||
|
"origin",
|
||||||
|
url,
|
||||||
|
SshRemoteAuthentication::key_file(identity_file, known_hosts).expect("authentication"),
|
||||||
|
)
|
||||||
|
.expect("remote");
|
||||||
|
remote.set_ssh_test_port(server.port);
|
||||||
|
remote
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_pack_drives_discovery_clone_fetch_fast_forward_merge_and_conflict() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let source_root = temporary.path().join("source");
|
||||||
|
fs::create_dir(&source_root).expect("source directory");
|
||||||
|
let source_store = Repository::open(&source_root).expect("source store");
|
||||||
|
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||||
|
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
|
||||||
|
commit(&source, "shared.gpg", b"base", "Add shared entry");
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(
|
||||||
|
user_key.public_key().clone(),
|
||||||
|
populated_fixture(&source),
|
||||||
|
Behavior::Normal,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||||
|
|
||||||
|
let branches = GitRepository::discover_remote_branches(
|
||||||
|
temporary.path(),
|
||||||
|
identity(),
|
||||||
|
&remote,
|
||||||
|
&Credentials,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
)
|
||||||
|
.expect("discover branches");
|
||||||
|
assert_eq!(branches, ["feature", "main"]);
|
||||||
|
|
||||||
|
let destination = temporary.path().join("clone");
|
||||||
|
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
|
||||||
|
.expect("clone over SSH");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(destination.join("shared.gpg")).expect("entry"),
|
||||||
|
b"base"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
clone
|
||||||
|
.repository
|
||||||
|
.try_find_reference("refs/tags/v1")
|
||||||
|
.expect("tag lookup")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
commit(&source, "remote-one.gpg", b"remote", "Remote fast-forward");
|
||||||
|
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||||
|
assert_eq!(
|
||||||
|
clone
|
||||||
|
.pull(&remote, Some("main"), &Credentials)
|
||||||
|
.expect("fast-forward pull"),
|
||||||
|
PullOutcome::FastForward
|
||||||
|
);
|
||||||
|
|
||||||
|
commit(&clone, "local.gpg", b"local", "Local change");
|
||||||
|
commit(
|
||||||
|
&source,
|
||||||
|
"remote-two.gpg",
|
||||||
|
b"remote",
|
||||||
|
"Remote parallel change",
|
||||||
|
);
|
||||||
|
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||||
|
assert_eq!(
|
||||||
|
clone
|
||||||
|
.pull(&remote, Some("main"), &Credentials)
|
||||||
|
.expect("merge pull"),
|
||||||
|
PullOutcome::Merged
|
||||||
|
);
|
||||||
|
|
||||||
|
commit(&clone, "shared.gpg", b"local conflict", "Local conflict");
|
||||||
|
commit(&source, "shared.gpg", b"remote conflict", "Remote conflict");
|
||||||
|
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||||
|
let before = fs::read(destination.join("shared.gpg")).expect("local entry");
|
||||||
|
assert!(matches!(
|
||||||
|
clone.pull(&remote, Some("main"), &Credentials),
|
||||||
|
Err(GitError::MergeConflicts { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(destination.join("shared.gpg")).expect("entry"),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
|
||||||
|
server.join.join().expect("server");
|
||||||
|
let commands = server.commands.lock().expect("commands");
|
||||||
|
assert_eq!(commands.len(), 5);
|
||||||
|
assert!(
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.all(|command| command == b"git-upload-pack '/team/store.git'")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scp_remote_drives_discovery_clone_fetch_and_pull() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let source_root = temporary.path().join("source");
|
||||||
|
fs::create_dir(&source_root).expect("source directory");
|
||||||
|
let source_store = Repository::open(&source_root).expect("source store");
|
||||||
|
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||||
|
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(
|
||||||
|
user_key.public_key().clone(),
|
||||||
|
populated_fixture(&source),
|
||||||
|
Behavior::Normal,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
let remote = remote_with_url(
|
||||||
|
temporary.path(),
|
||||||
|
&server,
|
||||||
|
&user_key,
|
||||||
|
"git@127.0.0.1:team/store.git".to_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
GitRepository::discover_remote_branches(
|
||||||
|
temporary.path(),
|
||||||
|
identity(),
|
||||||
|
&remote,
|
||||||
|
&Credentials,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
)
|
||||||
|
.expect("discover branches"),
|
||||||
|
["feature", "main"]
|
||||||
|
);
|
||||||
|
let destination = temporary.path().join("clone");
|
||||||
|
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
|
||||||
|
.expect("clone over scp-like SSH remote");
|
||||||
|
commit(&source, "remote.gpg", b"remote", "Remote fast-forward");
|
||||||
|
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||||
|
assert!(
|
||||||
|
clone
|
||||||
|
.fetch(&remote, &Credentials)
|
||||||
|
.expect("fetch over scp-like SSH remote")
|
||||||
|
.received_pack()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clone
|
||||||
|
.pull(&remote, Some("main"), &Credentials)
|
||||||
|
.expect("pull over scp-like SSH remote"),
|
||||||
|
PullOutcome::FastForward
|
||||||
|
);
|
||||||
|
|
||||||
|
server.join.join().expect("server");
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.commands
|
||||||
|
.lock()
|
||||||
|
.expect("commands")
|
||||||
|
.iter()
|
||||||
|
.all(|command| command == b"git-upload-pack 'team/store.git'")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_upload_pack_discovers_no_branches_and_leaves_no_partial_clone() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(
|
||||||
|
user_key.public_key().clone(),
|
||||||
|
empty_fixture(),
|
||||||
|
Behavior::Normal,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let remote = remote(temporary.path(), &server, &user_key, "/empty.git");
|
||||||
|
assert!(matches!(
|
||||||
|
GitRepository::discover_remote_branches(
|
||||||
|
temporary.path(),
|
||||||
|
identity(),
|
||||||
|
&remote,
|
||||||
|
&Credentials,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
),
|
||||||
|
Err(GitError::InvalidRepository(message)) if message == "the remote has no branches"
|
||||||
|
));
|
||||||
|
let destination = temporary.path().join("empty-clone");
|
||||||
|
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(GitError::RemoteNotFound { name }) if name == "refs/remotes/origin/main"
|
||||||
|
));
|
||||||
|
assert!(!destination.exists());
|
||||||
|
server.join.join().expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_pack_does_not_change_existing_repository_state() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let source_root = temporary.path().join("source");
|
||||||
|
fs::create_dir(&source_root).expect("source directory");
|
||||||
|
let source_store = Repository::open(&source_root).expect("source store");
|
||||||
|
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||||
|
commit(&source, ".gpg-id", b"REMOTE\n", "Remote initialize");
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(
|
||||||
|
user_key.public_key().clone(),
|
||||||
|
populated_fixture(&source),
|
||||||
|
Behavior::MalformedPack,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||||
|
|
||||||
|
let local_root = temporary.path().join("local");
|
||||||
|
fs::create_dir(&local_root).expect("local directory");
|
||||||
|
let local_store = Repository::open(&local_root).expect("local store");
|
||||||
|
let mut local = GitRepository::init(&local_store, identity()).expect("local Git");
|
||||||
|
commit(&local, ".gpg-id", b"LOCAL\n", "Local initialize");
|
||||||
|
local.add_remote("origin", remote.url()).expect("remote");
|
||||||
|
let head = local.log(Some(1)).expect("log")[0].id().to_owned();
|
||||||
|
let status = local.status().expect("status");
|
||||||
|
let contents = fs::read(local_root.join(".gpg-id")).expect("contents");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
local
|
||||||
|
.fetch(&remote, &Credentials)
|
||||||
|
.expect_err("malformed pack"),
|
||||||
|
GitError::MalformedGitPack
|
||||||
|
);
|
||||||
|
assert_eq!(local.log(Some(1)).expect("log")[0].id(), head);
|
||||||
|
assert_eq!(local.status().expect("status"), status);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(local_root.join(".gpg-id")).expect("contents"),
|
||||||
|
contents
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
local
|
||||||
|
.repository
|
||||||
|
.try_find_reference("refs/remotes/origin/main")
|
||||||
|
.expect("tracking lookup")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
server.join.join().expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untrusted_and_unauthenticated_clients_never_request_upload_pack() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let source_root = temporary.path().join("source");
|
||||||
|
fs::create_dir(&source_root).expect("source directory");
|
||||||
|
let source_store = Repository::open(&source_root).expect("source store");
|
||||||
|
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||||
|
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
|
||||||
|
let accepted_identity = key();
|
||||||
|
let server = start_server(
|
||||||
|
accepted_identity.public_key().clone(),
|
||||||
|
populated_fixture(&source),
|
||||||
|
Behavior::Normal,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = remote(
|
||||||
|
temporary.path(),
|
||||||
|
&server,
|
||||||
|
&accepted_identity,
|
||||||
|
"/team/store.git",
|
||||||
|
);
|
||||||
|
fs::write(
|
||||||
|
temporary.path().join("known_hosts"),
|
||||||
|
format!(
|
||||||
|
"[127.0.0.1]:{} {}\n",
|
||||||
|
server.port,
|
||||||
|
key().public_key().to_openssh().expect("wrong host key")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("changed known hosts");
|
||||||
|
assert!(matches!(
|
||||||
|
GitRepository::clone_into(
|
||||||
|
&temporary.path().join("untrusted"),
|
||||||
|
identity(),
|
||||||
|
&changed,
|
||||||
|
&Credentials,
|
||||||
|
),
|
||||||
|
Err(GitError::ChangedSshHostKey { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let rejected = remote(temporary.path(), &server, &key(), "/team/store.git");
|
||||||
|
assert_eq!(
|
||||||
|
match GitRepository::clone_into(
|
||||||
|
&temporary.path().join("unauthenticated"),
|
||||||
|
identity(),
|
||||||
|
&rejected,
|
||||||
|
&Credentials,
|
||||||
|
) {
|
||||||
|
Ok(_) => panic!("authentication unexpectedly succeeded"),
|
||||||
|
Err(error) => error,
|
||||||
|
},
|
||||||
|
GitError::SshAuthenticationRejected
|
||||||
|
);
|
||||||
|
server.join.join().expect("server");
|
||||||
|
assert!(server.commands.lock().expect("commands").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_malformed_failed_and_cancelled_services_preserve_destinations() {
|
||||||
|
for (behavior, expected) in [
|
||||||
|
(
|
||||||
|
Behavior::MalformedAdvertisement,
|
||||||
|
GitError::GitProtocolFailed,
|
||||||
|
),
|
||||||
|
(Behavior::MalformedPack, GitError::MalformedGitPack),
|
||||||
|
(
|
||||||
|
Behavior::NonZero,
|
||||||
|
GitError::SshRemoteServiceFailed {
|
||||||
|
diagnostic: "repository unavailable".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Behavior::Disconnect,
|
||||||
|
GitError::SshRemoteServiceFailed {
|
||||||
|
diagnostic: String::new(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let source_root = temporary.path().join("source");
|
||||||
|
fs::create_dir(&source_root).expect("source directory");
|
||||||
|
let source_store = Repository::open(&source_root).expect("store");
|
||||||
|
let source = GitRepository::init(&source_store, identity()).expect("Git");
|
||||||
|
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
|
||||||
|
let fixture = if matches!(behavior, Behavior::NonZero | Behavior::Disconnect) {
|
||||||
|
empty_fixture()
|
||||||
|
} else {
|
||||||
|
populated_fixture(&source)
|
||||||
|
};
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(user_key.public_key().clone(), fixture, behavior, 1);
|
||||||
|
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||||
|
let destination = temporary.path().join("destination");
|
||||||
|
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => panic!("clone unexpectedly succeeded"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error, expected);
|
||||||
|
assert!(!destination.exists());
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(temporary.path())
|
||||||
|
.expect("temporary root")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.all(|entry| !entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(".ironstorage-clone-"))
|
||||||
|
);
|
||||||
|
server.join.join().expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let user_key = key();
|
||||||
|
let server = start_server(
|
||||||
|
user_key.public_key().clone(),
|
||||||
|
empty_fixture(),
|
||||||
|
Behavior::Slow,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||||
|
let destination = temporary.path().join("cancelled");
|
||||||
|
let control = GitOperationControl::default();
|
||||||
|
let cancel = control.clone();
|
||||||
|
let cancellation = thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
cancel.cancel();
|
||||||
|
});
|
||||||
|
let result = GitRepository::clone_into_with_transport_controlled(
|
||||||
|
&destination,
|
||||||
|
identity(),
|
||||||
|
&remote,
|
||||||
|
None,
|
||||||
|
&Credentials,
|
||||||
|
&super::EmbeddedFetchTransport,
|
||||||
|
&control,
|
||||||
|
);
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => panic!("clone unexpectedly succeeded"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error, GitError::Cancelled);
|
||||||
|
cancellation.join().expect("cancellation");
|
||||||
|
assert!(!destination.exists());
|
||||||
|
server.join.join().expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advertised_refs_are_sorted_and_unique() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let store = Repository::open(temporary.path()).expect("store");
|
||||||
|
let repository = GitRepository::init(&store, identity()).expect("Git");
|
||||||
|
commit(&repository, ".gpg-id", b"ALICE\n", "Initialize");
|
||||||
|
let fixture = populated_fixture(&repository);
|
||||||
|
let refs = super::decode_pkt_lines(&fixture.advertisement).expect("advertisement");
|
||||||
|
let names = refs
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|line| {
|
||||||
|
line.split(|byte| *byte == b' ').nth(1).map(|name| {
|
||||||
|
name.split(|byte| *byte == 0)
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_vec()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(names.first().map(Vec::as_slice), Some(b"HEAD".as_slice()));
|
||||||
|
assert_eq!(
|
||||||
|
names[1..].iter().cloned().collect::<BTreeSet<_>>().len(),
|
||||||
|
names.len() - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
git::{
|
git::{
|
||||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||||
GitProgressPhase, GitRepository,
|
GitProgressPhase, GitRepository, SshPassphraseProvider,
|
||||||
},
|
},
|
||||||
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
|
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
|
||||||
recipient::RecipientPolicyManager,
|
recipient::RecipientPolicyManager,
|
||||||
@@ -488,6 +488,17 @@ impl GitCredentialProvider for MobileOnboardingRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SshPassphraseProvider for MobileOnboardingRequest {
|
||||||
|
fn ssh_key_passphrase(
|
||||||
|
&self,
|
||||||
|
fingerprint: &crate::config::SshFingerprint,
|
||||||
|
) -> Result<crate::repository::SecretBytes, GitError> {
|
||||||
|
Err(GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct MobileOnboardingDiscovery {
|
pub struct MobileOnboardingDiscovery {
|
||||||
branches: Vec<String>,
|
branches: Vec<String>,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{
|
|||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
fs,
|
fs,
|
||||||
future::Future,
|
future::Future,
|
||||||
io::{Read, Write},
|
io::{self, Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
@@ -14,7 +14,7 @@ use cap_std::{ambient_authority, fs::Dir};
|
|||||||
use cap_tempfile::TempFile;
|
use cap_tempfile::TempFile;
|
||||||
use hmac::{Hmac, Mac as _};
|
use hmac::{Hmac, Mac as _};
|
||||||
use russh::{
|
use russh::{
|
||||||
Disconnect, client,
|
ChannelMsg, Disconnect, client,
|
||||||
keys::{
|
keys::{
|
||||||
HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg,
|
HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg,
|
||||||
ssh_key::Algorithm,
|
ssh_key::Algorithm,
|
||||||
@@ -23,7 +23,7 @@ use russh::{
|
|||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource},
|
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource, SshRepositoryPath},
|
||||||
git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider},
|
git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,9 +32,69 @@ const MAX_KNOWN_HOSTS_BYTES: u64 = 1024 * 1024;
|
|||||||
const MAX_KNOWN_HOST_LINE_BYTES: usize = 16 * 1024;
|
const MAX_KNOWN_HOST_LINE_BYTES: usize = 16 * 1024;
|
||||||
const MAX_AGENT_IDENTITIES: usize = 64;
|
const MAX_AGENT_IDENTITIES: usize = 64;
|
||||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const CANCELLATION_POLL: Duration = Duration::from_millis(25);
|
const CANCELLATION_POLL: Duration = Duration::from_millis(25);
|
||||||
|
const CHANNEL_QUEUE_DEPTH: usize = 8;
|
||||||
|
const MAX_CHANNEL_CHUNK: usize = 32 * 1024;
|
||||||
|
const MAX_SSH_DIAGNOSTIC_BYTES: usize = 8 * 1024;
|
||||||
static KNOWN_HOSTS_WRITE_LOCK: Mutex<()> = Mutex::new(());
|
static KNOWN_HOSTS_WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
pub(crate) type SshGitTransport =
|
||||||
|
gix::protocol::transport::client::git::blocking_io::Connection<SshStdout, SshStdin>;
|
||||||
|
|
||||||
|
pub(crate) struct SshGitCommand {
|
||||||
|
pub(crate) transport: SshGitTransport,
|
||||||
|
pub(crate) completion: SshCommandCompletion,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type SshCommandCompletion = tokio::sync::oneshot::Receiver<Result<(), GitError>>;
|
||||||
|
|
||||||
|
pub(crate) struct SshStdout {
|
||||||
|
receiver: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||||
|
current: Vec<u8>,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for SshStdout {
|
||||||
|
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
|
||||||
|
if output.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
while self.offset == self.current.len() {
|
||||||
|
let Some(next) = self.receiver.blocking_recv() else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
self.current = next;
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
let count = output.len().min(self.current.len() - self.offset);
|
||||||
|
output[..count].copy_from_slice(&self.current[self.offset..self.offset + count]);
|
||||||
|
self.offset += count;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SshStdin {
|
||||||
|
sender: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for SshStdin {
|
||||||
|
fn write(&mut self, input: &[u8]) -> io::Result<usize> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let count = input.len().min(MAX_CHANNEL_CHUNK);
|
||||||
|
self.sender
|
||||||
|
.blocking_send(input[..count].to_vec())
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH channel closed"))?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An authenticated SSH connection. Git protocol channels are opened by the
|
/// An authenticated SSH connection. Git protocol channels are opened by the
|
||||||
/// storage transport so callers cannot issue arbitrary commands.
|
/// storage transport so callers cannot issue arbitrary commands.
|
||||||
pub struct SshSession {
|
pub struct SshSession {
|
||||||
@@ -130,6 +190,72 @@ impl SshSession {
|
|||||||
Ok(Self { runtime, handle })
|
Ok(Self { runtime, handle })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_upload_pack(
|
||||||
|
&self,
|
||||||
|
remote: &GitRemote,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<SshGitCommand, GitError> {
|
||||||
|
let RemoteEndpoint::Ssh(endpoint) = remote.endpoint() else {
|
||||||
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
|
};
|
||||||
|
let command = upload_pack_command(endpoint.path())?;
|
||||||
|
let channel = self.runtime.block_on(async {
|
||||||
|
controlled(
|
||||||
|
tokio::time::timeout(
|
||||||
|
COMMAND_TIMEOUT,
|
||||||
|
open_command_channel(&self.handle, command, control),
|
||||||
|
),
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let channel = match channel {
|
||||||
|
Ok(Ok(channel)) => channel,
|
||||||
|
Ok(Err(error)) => return Err(error),
|
||||||
|
Err(_) => return Err(GitError::SshProtocolFailed),
|
||||||
|
};
|
||||||
|
let (input_tx, input_rx) = tokio::sync::mpsc::channel(CHANNEL_QUEUE_DEPTH);
|
||||||
|
let (output_tx, output_rx) = tokio::sync::mpsc::channel(CHANNEL_QUEUE_DEPTH);
|
||||||
|
let (completion_tx, completion_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let operation = control.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let result = pump_command(channel, input_rx, output_tx, &operation).await;
|
||||||
|
let _ = completion_tx.send(result);
|
||||||
|
});
|
||||||
|
let transport = gix::protocol::transport::client::git::blocking_io::Connection::new(
|
||||||
|
SshStdout {
|
||||||
|
receiver: output_rx,
|
||||||
|
current: Vec::new(),
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
SshStdin { sender: input_tx },
|
||||||
|
gix::protocol::transport::Protocol::V1,
|
||||||
|
endpoint.path().as_str().as_bytes().to_vec(),
|
||||||
|
None::<(String, Option<u16>)>,
|
||||||
|
gix::protocol::transport::client::git::ConnectMode::Process,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.custom_url(Some(remote.url().into()));
|
||||||
|
Ok(SshGitCommand {
|
||||||
|
transport,
|
||||||
|
completion: completion_rx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish_command(
|
||||||
|
&self,
|
||||||
|
completion: SshCommandCompletion,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<(), GitError> {
|
||||||
|
let result = self.runtime.block_on(async {
|
||||||
|
controlled(tokio::time::timeout(COMMAND_TIMEOUT, completion), control).await
|
||||||
|
})?;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(result)) => result,
|
||||||
|
Ok(Err(_)) | Err(_) => Err(GitError::SshProtocolFailed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn close(self) -> Result<(), GitError> {
|
pub fn close(self) -> Result<(), GitError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.handle.disconnect(Disconnect::ByApplication, "", ""))
|
.block_on(self.handle.disconnect(Disconnect::ByApplication, "", ""))
|
||||||
@@ -137,6 +263,147 @@ impl SshSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn open_command_channel(
|
||||||
|
handle: &client::Handle<HostVerifier>,
|
||||||
|
command: Vec<u8>,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<russh::Channel<client::Msg>, GitError> {
|
||||||
|
let mut channel = handle
|
||||||
|
.channel_open_session()
|
||||||
|
.await
|
||||||
|
.map_err(|_| GitError::SshProtocolFailed)?;
|
||||||
|
channel
|
||||||
|
.exec(true, command)
|
||||||
|
.await
|
||||||
|
.map_err(|_| GitError::SshProtocolFailed)?;
|
||||||
|
let mut diagnostic = Vec::new();
|
||||||
|
loop {
|
||||||
|
if control.is_cancelled() {
|
||||||
|
let _ = channel.close().await;
|
||||||
|
return Err(GitError::Cancelled);
|
||||||
|
}
|
||||||
|
match channel.wait().await {
|
||||||
|
Some(ChannelMsg::Success) => return Ok(channel),
|
||||||
|
Some(ChannelMsg::Failure) | Some(ChannelMsg::Close) | None => {
|
||||||
|
return Err(remote_service_error(&diagnostic));
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
||||||
|
extend_diagnostic(&mut diagnostic, &data);
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::ExitSignal { .. }) => {
|
||||||
|
return Err(remote_service_error(&diagnostic));
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::Data { .. }) => return Err(GitError::GitProtocolFailed),
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_command(
|
||||||
|
mut channel: russh::Channel<client::Msg>,
|
||||||
|
mut input: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||||
|
output: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<(), GitError> {
|
||||||
|
let mut diagnostic = Vec::new();
|
||||||
|
let mut exit_status = None;
|
||||||
|
let mut input_open = true;
|
||||||
|
let mut output_open = true;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
message = channel.wait() => match message {
|
||||||
|
Some(ChannelMsg::Data { data }) => {
|
||||||
|
if output_open && output.send(data.to_vec()).await.is_err() {
|
||||||
|
output_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
||||||
|
extend_diagnostic(&mut diagnostic, &data);
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::ExitStatus { exit_status: status }) => exit_status = Some(status),
|
||||||
|
Some(ChannelMsg::ExitSignal { error_message, .. }) => {
|
||||||
|
extend_diagnostic(&mut diagnostic, error_message.as_bytes());
|
||||||
|
exit_status = Some(u32::MAX);
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::Eof) => {}
|
||||||
|
Some(ChannelMsg::Close) | None => break,
|
||||||
|
Some(ChannelMsg::Failure) => {
|
||||||
|
exit_status = Some(u32::MAX);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
},
|
||||||
|
outbound = input.recv(), if input_open => match outbound {
|
||||||
|
Some(data) => channel
|
||||||
|
.data_bytes(data)
|
||||||
|
.await
|
||||||
|
.map_err(|_| GitError::SshProtocolFailed)?,
|
||||||
|
None => {
|
||||||
|
input_open = false;
|
||||||
|
channel.eof().await.map_err(|_| GitError::SshProtocolFailed)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() = tokio::time::sleep(CANCELLATION_POLL) => {
|
||||||
|
if control.is_cancelled() {
|
||||||
|
let _ = channel.close().await;
|
||||||
|
return Err(GitError::Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if control.is_cancelled() {
|
||||||
|
return Err(GitError::Cancelled);
|
||||||
|
}
|
||||||
|
match exit_status {
|
||||||
|
Some(0) => Ok(()),
|
||||||
|
_ => Err(remote_service_error(&diagnostic)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_pack_command(path: &SshRepositoryPath) -> Result<Vec<u8>, GitError> {
|
||||||
|
let path = shell_quote(path.as_str());
|
||||||
|
let command = format!("git-upload-pack {path}");
|
||||||
|
if command.len() > 64 * 1024 {
|
||||||
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
|
}
|
||||||
|
Ok(command.into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_quote(value: &str) -> String {
|
||||||
|
let mut quoted = String::with_capacity(value.len() + 2);
|
||||||
|
quoted.push('\'');
|
||||||
|
for character in value.chars() {
|
||||||
|
if character == '\'' {
|
||||||
|
quoted.push_str("'\\''");
|
||||||
|
} else {
|
||||||
|
quoted.push(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quoted.push('\'');
|
||||||
|
quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_diagnostic(output: &mut Vec<u8>, input: &[u8]) {
|
||||||
|
let remaining = MAX_SSH_DIAGNOSTIC_BYTES.saturating_sub(output.len());
|
||||||
|
output.extend_from_slice(&input[..input.len().min(remaining)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_service_error(diagnostic: &[u8]) -> GitError {
|
||||||
|
let diagnostic = String::from_utf8_lossy(diagnostic)
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_control() {
|
||||||
|
' '
|
||||||
|
} else {
|
||||||
|
character
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
GitError::SshRemoteServiceFailed { diagnostic }
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist a host key only after the caller has confirmed an unknown-key error.
|
/// Persist a host key only after the caller has confirmed an unknown-key error.
|
||||||
/// Existing changed keys are never replaced.
|
/// Existing changed keys are never replaced.
|
||||||
pub fn persist_confirmed_host(
|
pub fn persist_confirmed_host(
|
||||||
@@ -212,8 +479,21 @@ impl ClientError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl From<russh::Error> for ClientError {
|
impl From<russh::Error> for ClientError {
|
||||||
fn from(_: russh::Error) -> Self {
|
fn from(error: russh::Error) -> Self {
|
||||||
Self::Protocol
|
match error {
|
||||||
|
russh::Error::IO(_)
|
||||||
|
| russh::Error::Disconnect
|
||||||
|
| russh::Error::HUP
|
||||||
|
| russh::Error::ConnectionTimeout
|
||||||
|
| russh::Error::KeepaliveTimeout
|
||||||
|
| russh::Error::InactivityTimeout => Self::Git(GitError::NetworkUnavailable),
|
||||||
|
russh::Error::NoCommonAlgo { kind, .. } => {
|
||||||
|
Self::Git(GitError::SshUnsupportedAlgorithm {
|
||||||
|
algorithm: format!("SSH {kind:?}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Self::Protocol,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,12 +944,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
|
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshRemoteAuthentication},
|
||||||
git::{GitError, GitOperationControl, SshPassphraseProvider},
|
git::{GitError, GitOperationControl, SshPassphraseProvider},
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{SshSession, persist_confirmed_host, ssh_host_key};
|
use super::{SshSession, persist_confirmed_host, ssh_host_key, upload_pack_command};
|
||||||
|
|
||||||
struct Passphrase(Option<&'static [u8]>);
|
struct Passphrase(Option<&'static [u8]>);
|
||||||
|
|
||||||
@@ -1001,6 +1281,58 @@ mod tests {
|
|||||||
assert_eq!(fs::read(&known_hosts).expect("known hosts"), b"unchanged\n");
|
assert_eq!(fs::read(&known_hosts).expect("known hosts"), b"unchanged\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_network_is_distinct_from_ssh_protocol_failure() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let identity = key(Algorithm::Ed25519);
|
||||||
|
let identity_path = temporary.path().join("identity");
|
||||||
|
let known_hosts = temporary.path().join("known_hosts");
|
||||||
|
write_key(&identity_path, &identity);
|
||||||
|
fs::write(&known_hosts, b"").expect("known hosts");
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("unused port");
|
||||||
|
let port = listener.local_addr().expect("address").port();
|
||||||
|
drop(listener);
|
||||||
|
assert_eq!(
|
||||||
|
SshSession::connect(
|
||||||
|
&remote(port, &identity_path, &known_hosts),
|
||||||
|
&Passphrase(None),
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
)
|
||||||
|
.expect_err("unavailable network"),
|
||||||
|
GitError::NetworkUnavailable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_pack_command_preserves_path_semantics_without_injection() {
|
||||||
|
for (url, expected) in [
|
||||||
|
(
|
||||||
|
"ssh://git@example.test/team/repo%20with%20%27quote%27%3B%24%28touch%20x%29.git",
|
||||||
|
"/team/repo with 'quote';$(touch x).git",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"git@example.test:team/repo with 'quote';$(touch x).git",
|
||||||
|
"team/repo with 'quote';$(touch x).git",
|
||||||
|
),
|
||||||
|
("ssh://git@example.test/~alice/repo.git", "~alice/repo.git"),
|
||||||
|
(
|
||||||
|
"git@example.test:GIT_PROTOCOL=version=2/repo.git",
|
||||||
|
"GIT_PROTOCOL=version=2/repo.git",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let endpoint = RemoteEndpoint::parse(url).expect("valid SSH endpoint");
|
||||||
|
let command =
|
||||||
|
upload_pack_command(endpoint.as_ssh().expect("SSH").path()).expect("safe command");
|
||||||
|
let command = std::str::from_utf8(&command).expect("UTF-8 command");
|
||||||
|
assert_eq!(
|
||||||
|
shlex::split(command).expect("shell command"),
|
||||||
|
["git-upload-pack", expected]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(RemoteEndpoint::parse("git@example.test:-upload-pack").is_err());
|
||||||
|
assert!(RemoteEndpoint::parse("git@example.test:repo\nsecond").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_authentication_selects_one_configured_fingerprint() {
|
fn agent_authentication_selects_one_configured_fingerprint() {
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ use std::{
|
|||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
command::{InsertInput, InsertRequest},
|
command::{InsertInput, InsertRequest},
|
||||||
config::{Config, ConfigLoader, GitRemote},
|
config::{Config, ConfigLoader, GitRemote, SshFingerprint},
|
||||||
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
git::{
|
git::{
|
||||||
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
|
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
|
||||||
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
|
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
|
||||||
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome,
|
GitProgressPhase, GitRemoteCredentialProvider, GitRepository, GitSmartHttpTransport,
|
||||||
|
PullOutcome, SshPassphraseProvider,
|
||||||
},
|
},
|
||||||
repository::{Repository, SecretBytes},
|
repository::{Repository, SecretBytes},
|
||||||
write::{InsertContent, OverwriteDecision, VaultWriter},
|
write::{InsertContent, OverwriteDecision, VaultWriter},
|
||||||
@@ -230,6 +231,24 @@ impl GitCredentialProvider for Credentials {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SshPassphraseProvider for Credentials {
|
||||||
|
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||||
|
Err(GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_credential(
|
||||||
|
configured: &GitRemote,
|
||||||
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
|
) -> Result<GitCredential, GitError> {
|
||||||
|
let (server, application) = configured
|
||||||
|
.https_credentials()
|
||||||
|
.ok_or(GitError::CredentialsUnavailable)?;
|
||||||
|
credentials.credential(server, application)
|
||||||
|
}
|
||||||
|
|
||||||
struct SigningSecret(Vec<u8>);
|
struct SigningSecret(Vec<u8>);
|
||||||
|
|
||||||
impl SecretProvider for SigningSecret {
|
impl SecretProvider for SigningSecret {
|
||||||
@@ -249,9 +268,10 @@ impl GitFetchTransport for CloningFetch {
|
|||||||
fn fetch(
|
fn fetch(
|
||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
_configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
|
let credential = http_credential(configured, credentials)?;
|
||||||
assert_eq!(credential.password(), b"token");
|
assert_eq!(credential.password(), b"token");
|
||||||
fs::write(repository.root().join(".gpg-id"), b"ALICE\n")
|
fs::write(repository.root().join(".gpg-id"), b"ALICE\n")
|
||||||
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||||
@@ -272,9 +292,9 @@ impl GitFetchTransport for OccupyingFetch {
|
|||||||
&self,
|
&self,
|
||||||
repository: &GitRepository,
|
repository: &GitRepository,
|
||||||
configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
let fetched = CloningFetch.fetch(repository, configured, credential)?;
|
let fetched = CloningFetch.fetch(repository, configured, credentials)?;
|
||||||
fs::write(self.0.join("keep"), b"concurrent contents")
|
fs::write(self.0.join("keep"), b"concurrent contents")
|
||||||
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||||
Ok(fetched)
|
Ok(fetched)
|
||||||
@@ -287,9 +307,10 @@ impl GitFetchTransport for NoopFetch {
|
|||||||
fn fetch(
|
fn fetch(
|
||||||
&self,
|
&self,
|
||||||
_repository: &GitRepository,
|
_repository: &GitRepository,
|
||||||
_configured: &GitRemote,
|
configured: &GitRemote,
|
||||||
credential: &GitCredential,
|
credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
|
let credential = http_credential(configured, credentials)?;
|
||||||
assert_eq!(credential.username(), "alice");
|
assert_eq!(credential.username(), "alice");
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -302,7 +323,7 @@ impl GitFetchTransport for FailedFetch {
|
|||||||
&self,
|
&self,
|
||||||
_repository: &GitRepository,
|
_repository: &GitRepository,
|
||||||
_configured: &GitRemote,
|
_configured: &GitRemote,
|
||||||
_credential: &GitCredential,
|
_credentials: &dyn GitRemoteCredentialProvider,
|
||||||
) -> Result<bool, GitError> {
|
) -> Result<bool, GitError> {
|
||||||
Err(self.0.clone())
|
Err(self.0.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ Private-key passphrases are stored by fingerprint in the operating-system
|
|||||||
secret store and never appear in TOML. IronStorage does not read OpenSSH
|
secret store and never appear in TOML. IronStorage does not read OpenSSH
|
||||||
configuration, try additional keys, prompt for passwords or
|
configuration, try additional keys, prompt for passwords or
|
||||||
keyboard-interactive authentication, launch an agent, or invoke proxy/helper
|
keyboard-interactive authentication, launch an agent, or invoke proxy/helper
|
||||||
commands.
|
commands. In builds with the `ssh` feature, the same configuration drives
|
||||||
|
branch discovery, clone, fetch, and pull over the embedded upload-pack channel;
|
||||||
|
push support is enabled separately when receive-pack is available.
|
||||||
|
|
||||||
The typed endpoint model is always available so an SSH remote remains readable
|
The typed endpoint model is always available so an SSH remote remains readable
|
||||||
through the Rust API even when the binary was built without SSH. Such a build
|
through the Rust API even when the binary was built without SSH. Such a build
|
||||||
|
|||||||
@@ -60,6 +60,26 @@ commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded.
|
|||||||
Cancellation interrupts connection and authentication without changing Git,
|
Cancellation interrupts connection and authentication without changing Git,
|
||||||
known hosts, or secure storage.
|
known hosts, or secure storage.
|
||||||
|
|
||||||
|
## SSH upload-pack
|
||||||
|
|
||||||
|
With the same feature enabled, branch discovery, clone, fetch, and pull open a
|
||||||
|
session channel without a PTY and request only `git-upload-pack '<path>'`.
|
||||||
|
Storage single-quotes the repository as one shell argument, preserving the
|
||||||
|
absolute, account-relative, and tilde path semantics of the two supported SSH
|
||||||
|
URL forms while preventing whitespace, quotes, metacharacters, environment
|
||||||
|
assignments, or options from selecting another command. No local Git, SSH,
|
||||||
|
shell, transport helper, or fetch-pack process is involved.
|
||||||
|
|
||||||
|
The channel is a bounded adapter into gix's existing pack-protocol client.
|
||||||
|
Stdout carries only protocol bytes; stderr is retained only as a bounded,
|
||||||
|
single-line remote-service diagnostic. Storage requires a zero exit status and
|
||||||
|
clean channel completion, distinguishes malformed advertisements and packs from
|
||||||
|
network, trust, authentication, service, and cancellation failures, and closes
|
||||||
|
the channel promptly when cancellation is requested. Gix continues to own pack
|
||||||
|
verification, object limits, and atomic ref updates, while the existing
|
||||||
|
IronStorage code continues to own clone staging, checkout, merge, conflict, and
|
||||||
|
rollback behavior.
|
||||||
|
|
||||||
Pull refuses a dirty worktree. It fast-forwards when possible and otherwise
|
Pull refuses a dirty worktree. It fast-forwards when possible and otherwise
|
||||||
uses the embedded three-way tree merge. Unresolved paths are returned as typed
|
uses the embedded three-way tree merge. Unresolved paths are returned as typed
|
||||||
`MergeConflicts`; no conflict markers or partial checkout are written. Checkout
|
`MergeConflicts`; no conflict markers or partial checkout are written. Checkout
|
||||||
@@ -72,7 +92,9 @@ Commit signing is optional. The embedded OpenPGP key store signs the canonical
|
|||||||
unsigned commit bytes and adds an ASCII-armored `gpgsig` header compatible with
|
unsigned commit bytes and adds an ASCII-armored `gpgsig` header compatible with
|
||||||
Git/GPG without invoking `gpg`.
|
Git/GPG without invoking `gpg`.
|
||||||
|
|
||||||
The smart-HTTP boundary is injectable for deterministic compatibility tests.
|
The transport boundary is injectable for deterministic compatibility tests.
|
||||||
Tests can inspect credentials, advertisements, receive-pack commands, object
|
HTTPS tests inspect credentials, advertisements, receive-pack commands, object
|
||||||
counts, pack checksums, non-fast-forward behavior, and server status without a
|
counts, pack checksums, non-fast-forward behavior, and server status. SSH tests
|
||||||
runtime helper or external Git installation.
|
use a pure-Rust in-process Russh server and upload-pack fixture to exercise
|
||||||
|
chunked reference and pack streams, end-to-end clone/fetch/pull, command
|
||||||
|
quoting, cancellation, and rollback without an external Git or SSH executable.
|
||||||
|
|||||||
Reference in New Issue
Block a user