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::{
crypto::{KeyInfo, SecretProvider, SecretProviderError},
git::{GitCredential, GitCredentialProvider, GitError},
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
repository::SecretBytes,
secret_store::{
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> {
fn operation(&self) -> Result<std::sync::MutexGuard<'_, ()>, AuthenticationError> {
self.operation

View File

@@ -1343,6 +1343,13 @@ impl GitRemote {
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.

View File

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

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},
git::{
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
GitProgressPhase, GitRepository,
GitProgressPhase, GitRepository, SshPassphraseProvider,
},
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
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)]
pub struct MobileOnboardingDiscovery {
branches: Vec<String>,

View File

@@ -4,7 +4,7 @@ use std::{
borrow::Cow,
fs,
future::Future,
io::{Read, Write},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
@@ -14,7 +14,7 @@ use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use hmac::{Hmac, Mac as _};
use russh::{
Disconnect, client,
ChannelMsg, Disconnect, client,
keys::{
HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg,
ssh_key::Algorithm,
@@ -23,7 +23,7 @@ use russh::{
use sha1::Sha1;
use crate::{
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource},
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshIdentitySource, SshRepositoryPath},
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_AGENT_IDENTITIES: usize = 64;
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 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(());
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
/// storage transport so callers cannot issue arbitrary commands.
pub struct SshSession {
@@ -130,6 +190,72 @@ impl SshSession {
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> {
self.runtime
.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.
/// Existing changed keys are never replaced.
pub fn persist_confirmed_host(
@@ -212,8 +479,21 @@ impl ClientError {
}
impl From<russh::Error> for ClientError {
fn from(_: russh::Error) -> Self {
Self::Protocol
fn from(error: russh::Error) -> Self {
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::{
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
config::{GitRemote, RemoteEndpoint, SshFingerprint, SshRemoteAuthentication},
git::{GitError, GitOperationControl, SshPassphraseProvider},
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]>);
@@ -1001,6 +1281,58 @@ mod tests {
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)]
#[test]
fn agent_authentication_selects_one_configured_fingerprint() {