Implement SSH upload-pack fetches

This commit is contained in:
2026-08-25 20:31:10 +02:00
parent 5dbda4bbd2
commit 051e14235f
9 changed files with 1367 additions and 53 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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};

View 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
);
}

View File

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

View File

@@ -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() {

View File

@@ -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())
} }

View File

@@ -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

View File

@@ -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.