Skip to main content

shared/
git.rs

1use gix::protocol::transport::client::blocking_io::Transport;
2use std::sync::Arc;
3
4type BoxedTransport = Box<dyn Transport + Send>;
5type GitConnection<'a, 'b, 'c> = gix::remote::Connection<'a, 'b, 'c, BoxedTransport>;
6
7pub enum GitCredentials {
8    None,
9    Password {
10        username: compact_str::CompactString,
11        password: compact_str::CompactString,
12    },
13    PrivateKey {
14        username: compact_str::CompactString,
15        private_key: String,
16        passphrase: Option<compact_str::CompactString>,
17    },
18}
19
20struct AcceptAnyServerKey;
21
22impl russh::client::Handler for AcceptAnyServerKey {
23    type Error = russh::Error;
24
25    async fn check_server_key(
26        &mut self,
27        _server_public_key: &russh::keys::PublicKey,
28    ) -> Result<bool, Self::Error> {
29        Ok(true)
30    }
31}
32
33struct SessionBound<T> {
34    inner: T,
35    _session: russh::client::Handle<AcceptAnyServerKey>,
36}
37
38impl<T: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for SessionBound<T> {
39    fn poll_read(
40        mut self: std::pin::Pin<&mut Self>,
41        cx: &mut std::task::Context<'_>,
42        buf: &mut tokio::io::ReadBuf<'_>,
43    ) -> std::task::Poll<std::io::Result<()>> {
44        std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
45    }
46}
47
48pub async fn resolve_addresses(
49    env: &Arc<crate::env::Env>,
50    url: &gix::Url,
51) -> Result<Vec<std::net::SocketAddr>, anyhow::Error> {
52    let host = url
53        .host()
54        .ok_or_else(|| anyhow::anyhow!("repository url is missing a host"))?;
55    let port = url.port.unwrap_or(match url.scheme {
56        gix::url::Scheme::Ssh => 22,
57        gix::url::Scheme::Https => 443,
58        _ => 80,
59    });
60
61    crate::net::resolve_allowed_addresses(env, host, port, "git").await
62}
63
64pub fn parse_private_key(
65    private_key: &str,
66    passphrase: Option<&str>,
67) -> Result<russh::keys::PrivateKey, anyhow::Error> {
68    let key = russh::keys::PrivateKey::from_openssh(private_key)?;
69
70    if key.is_encrypted() {
71        let passphrase = passphrase.ok_or_else(|| {
72            anyhow::anyhow!("private key is encrypted, but no passphrase was set")
73        })?;
74
75        Ok(key.decrypt(passphrase)?)
76    } else {
77        Ok(key)
78    }
79}
80
81pub fn validate_private_key(private_key: &str, _context: &()) -> Result<(), garde::Error> {
82    match russh::keys::PrivateKey::from_openssh(private_key) {
83        Ok(_) => Ok(()),
84        Err(err) => Err(garde::Error::new(format!("Invalid private key: {err}"))),
85    }
86}
87
88enum SshAuth {
89    Password(compact_str::CompactString),
90    PrivateKey(Arc<russh::keys::PrivateKey>),
91}
92
93async fn ssh_upload_pack(
94    env: &Arc<crate::env::Env>,
95    url: &gix::Url,
96    username: &str,
97    auth: SshAuth,
98) -> Result<BoxedTransport, anyhow::Error> {
99    let path = String::from_utf8_lossy(&url.path).into_owned();
100    let addresses = resolve_addresses(env, url).await?;
101
102    let mut session = russh::client::connect(
103        Arc::new(russh::client::Config {
104            keepalive_interval: Some(std::time::Duration::from_secs(30)),
105            ..Default::default()
106        }),
107        addresses.as_slice(),
108        AcceptAnyServerKey,
109    )
110    .await?;
111
112    let authenticated = match auth {
113        SshAuth::Password(password) => {
114            session
115                .authenticate_password(username, password.as_str())
116                .await?
117        }
118        SshAuth::PrivateKey(key) => {
119            let hash_alg = session.best_supported_rsa_hash().await?.flatten();
120
121            session
122                .authenticate_publickey(
123                    username,
124                    russh::keys::PrivateKeyWithHashAlg::new(key, hash_alg),
125                )
126                .await?
127        }
128    };
129
130    if !authenticated.success() {
131        return Err(anyhow::anyhow!("ssh authentication was rejected"));
132    }
133
134    let channel = session.channel_open_session().await?;
135
136    let _ = channel
137        .set_env(false, "GIT_PROTOCOL", "version=2")
138        .await
139        .inspect_err(|err| tracing::debug!("failed to request GIT_PROTOCOL over ssh: {err:#}"));
140
141    channel
142        .exec(true, format!("git-upload-pack '{path}'"))
143        .await?;
144
145    let (read, write) = tokio::io::split(channel.into_stream());
146    let handle = tokio::runtime::Handle::current();
147
148    Ok(Box::new(
149        gix::protocol::transport::client::git::blocking_io::Connection::new(
150            tokio_util::io::SyncIoBridge::new_with_handle(
151                SessionBound {
152                    inner: read,
153                    _session: session,
154                },
155                handle.clone(),
156            ),
157            tokio_util::io::SyncIoBridge::new_with_handle(write, handle),
158            gix::protocol::transport::Protocol::V2,
159            path,
160            None::<(String, Option<u16>)>,
161            gix::protocol::transport::client::git::ConnectMode::Process,
162            false,
163        ),
164    ))
165}
166
167impl GitCredentials {
168    pub fn into_connection_configurator(
169        self,
170        env: Arc<crate::env::Env>,
171        url: gix::Url,
172    ) -> impl FnMut(
173        &mut GitConnection<'_, '_, '_>,
174    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
175    + 'static {
176        let over_ssh = url.scheme == gix::url::Scheme::Ssh;
177
178        move |connection| {
179            let (username, auth) = match &self {
180                GitCredentials::None => return Ok(()),
181                GitCredentials::Password { username, password } if !over_ssh => {
182                    connection
183                        .transport_mut()
184                        .set_identity(gix::sec::identity::Account {
185                            username: username.to_string(),
186                            password: password.to_string(),
187                            oauth_refresh_token: None,
188                        })?;
189
190                    return Ok(());
191                }
192                GitCredentials::Password { username, password } => {
193                    (username, SshAuth::Password(password.clone()))
194                }
195                GitCredentials::PrivateKey {
196                    username,
197                    private_key,
198                    passphrase,
199                } => (
200                    username,
201                    SshAuth::PrivateKey(Arc::new(parse_private_key(
202                        private_key,
203                        passphrase.as_deref(),
204                    )?)),
205                ),
206            };
207
208            let transport = tokio::runtime::Handle::current()
209                .block_on(ssh_upload_pack(&env, &url, username, auth))?;
210
211            *connection.transport_mut() = transport;
212
213            Ok(())
214        }
215    }
216}