diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs index fa23383..7d1722f 100644 --- a/crates/storage/src/authentication.rs +++ b/crates/storage/src/authentication.rs @@ -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 GitCredentialProvider } } +impl SshPassphraseProvider + for AuthenticationHandle +{ + fn ssh_key_passphrase( + &self, + fingerprint: &crate::config::SshFingerprint, + ) -> Result { + 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 Shared { fn operation(&self) -> Result, AuthenticationError> { self.operation diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 85607f4..e0f3d1d 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -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. diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 34a57e7..3ca8cba 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -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; } +pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProvider {} + +impl 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; fn fetch_controlled( &self, repository: &GitRepository, configured: &GitRemote, - credential: &GitCredential, + credentials: &dyn GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { 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 { - 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 { - 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, 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, 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::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::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::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 { 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 { 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 { 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 { @@ -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 { - 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 { + 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 { + 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 { 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 { 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 { @@ -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}; diff --git a/crates/storage/src/git/ssh_tests.rs b/crates/storage/src/git/ssh_tests.rs new file mode 100644 index 0000000..a57279d --- /dev/null +++ b/crates/storage/src/git/ssh_tests.rs @@ -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, + pack: Vec, +} + +struct UploadServer { + user_key: PublicKey, + fixture: Arc>, + commands: Arc>>>, + behavior: Behavior, + input: Vec, + 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 { + 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, + 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>, + commands: Arc>>>, + 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 { + Err(GitError::CredentialsUnavailable) + } +} + +impl SshPassphraseProvider for Credentials { + fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result { + 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 { + 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::>(); + assert_eq!(names.first().map(Vec::as_slice), Some(b"HEAD".as_slice())); + assert_eq!( + names[1..].iter().cloned().collect::>().len(), + names.len() - 1 + ); +} diff --git a/crates/storage/src/mobile_onboarding.rs b/crates/storage/src/mobile_onboarding.rs index e682ade..05d782b 100644 --- a/crates/storage/src/mobile_onboarding.rs +++ b/crates/storage/src/mobile_onboarding.rs @@ -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 { + Err(GitError::SshKeyPassphraseUnavailable { + fingerprint: fingerprint.clone(), + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileOnboardingDiscovery { branches: Vec, diff --git a/crates/storage/src/ssh.rs b/crates/storage/src/ssh.rs index 0f85acd..842b809 100644 --- a/crates/storage/src/ssh.rs +++ b/crates/storage/src/ssh.rs @@ -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; + +pub(crate) struct SshGitCommand { + pub(crate) transport: SshGitTransport, + pub(crate) completion: SshCommandCompletion, +} + +pub(crate) type SshCommandCompletion = tokio::sync::oneshot::Receiver>; + +pub(crate) struct SshStdout { + receiver: tokio::sync::mpsc::Receiver>, + current: Vec, + offset: usize, +} + +impl Read for SshStdout { + fn read(&mut self, output: &mut [u8]) -> io::Result { + 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>, +} + +impl Write for SshStdin { + fn write(&mut self, input: &[u8]) -> io::Result { + 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 { + 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)>, + 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, + command: Vec, + control: &GitOperationControl, +) -> Result, 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, + mut input: tokio::sync::mpsc::Receiver>, + output: tokio::sync::mpsc::Sender>, + 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, 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, 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::() + .split_whitespace() + .collect::>() + .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 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() { diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 92a6f95..29de1bb 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -12,12 +12,13 @@ use std::{ use ironstorage::{ command::{InsertInput, InsertRequest}, - config::{Config, ConfigLoader, GitRemote}, + config::{Config, ConfigLoader, GitRemote, SshFingerprint}, crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, git::{ GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential, GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl, - GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome, + GitProgressPhase, GitRemoteCredentialProvider, GitRepository, GitSmartHttpTransport, + PullOutcome, SshPassphraseProvider, }, repository::{Repository, SecretBytes}, write::{InsertContent, OverwriteDecision, VaultWriter}, @@ -230,6 +231,24 @@ impl GitCredentialProvider for Credentials { } } +impl SshPassphraseProvider for Credentials { + fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result { + Err(GitError::SshKeyPassphraseUnavailable { + fingerprint: fingerprint.clone(), + }) + } +} + +fn http_credential( + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, +) -> Result { + let (server, application) = configured + .https_credentials() + .ok_or(GitError::CredentialsUnavailable)?; + credentials.credential(server, application) +} + struct SigningSecret(Vec); impl SecretProvider for SigningSecret { @@ -249,9 +268,10 @@ impl GitFetchTransport for CloningFetch { fn fetch( &self, repository: &GitRepository, - _configured: &GitRemote, - credential: &GitCredential, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, ) -> Result { + let credential = http_credential(configured, credentials)?; assert_eq!(credential.password(), b"token"); fs::write(repository.root().join(".gpg-id"), b"ALICE\n") .map_err(|error| GitError::InvalidRepository(error.to_string()))?; @@ -272,9 +292,9 @@ impl GitFetchTransport for OccupyingFetch { &self, repository: &GitRepository, configured: &GitRemote, - credential: &GitCredential, + credentials: &dyn GitRemoteCredentialProvider, ) -> Result { - let fetched = CloningFetch.fetch(repository, configured, credential)?; + let fetched = CloningFetch.fetch(repository, configured, credentials)?; fs::write(self.0.join("keep"), b"concurrent contents") .map_err(|error| GitError::InvalidRepository(error.to_string()))?; Ok(fetched) @@ -287,9 +307,10 @@ impl GitFetchTransport for NoopFetch { fn fetch( &self, _repository: &GitRepository, - _configured: &GitRemote, - credential: &GitCredential, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, ) -> Result { + let credential = http_credential(configured, credentials)?; assert_eq!(credential.username(), "alice"); Ok(false) } @@ -302,7 +323,7 @@ impl GitFetchTransport for FailedFetch { &self, _repository: &GitRepository, _configured: &GitRemote, - _credential: &GitCredential, + _credentials: &dyn GitRemoteCredentialProvider, ) -> Result { Err(self.0.clone()) } diff --git a/docs/configuration.md b/docs/configuration.md index d39a332..7e42c62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 configuration, try additional keys, prompt for passwords or 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 through the Rust API even when the binary was built without SSH. Such a build diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md index 84ab2a9..5767262 100644 --- a/docs/git-synchronization.md +++ b/docs/git-synchronization.md @@ -60,6 +60,26 @@ commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded. Cancellation interrupts connection and authentication without changing Git, 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 ''`. +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 uses the embedded three-way tree merge. Unresolved paths are returned as typed `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 Git/GPG without invoking `gpg`. -The smart-HTTP boundary is injectable for deterministic compatibility tests. -Tests can inspect credentials, advertisements, receive-pack commands, object -counts, pack checksums, non-fast-forward behavior, and server status without a -runtime helper or external Git installation. +The transport boundary is injectable for deterministic compatibility tests. +HTTPS tests inspect credentials, advertisements, receive-pack commands, object +counts, pack checksums, non-fast-forward behavior, and server status. SSH tests +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.