Skip to main content

fluidattacks_core_git/clone/
ssh.rs

1use anyhow::Context;
2use base64::Engine;
3use std::ffi::OsString;
4use std::io::Write;
5use std::os::unix::ffi::{OsStrExt, OsStringExt};
6use std::path::Path;
7use tempfile::NamedTempFile;
8use tokio::process::Command;
9
10use super::{build_clone_args, reason, CloneOpts};
11use crate::errors::CloneError;
12
13// As GIT_ALLOW_PROTOCOL, keeps a `file://` or local path url from reaching a repository on this machine.
14pub(crate) const ONLY_SSH: &str = "ssh";
15
16pub async fn clone_ssh(opts: &CloneOpts, dest: &Path) -> anyhow::Result<()> {
17    let crate::types::CredentialKind::Ssh { key } = &opts
18        .credentials
19        .as_ref()
20        .context("missing credentials")?
21        .kind
22    else {
23        anyhow::bail!("expected SSH credentials");
24    };
25    Ok(run_git_clone(opts, dest, key).await?)
26}
27
28pub(super) async fn run_git_clone(
29    opts: &CloneOpts,
30    dest: &Path,
31    key: &str,
32) -> Result<(), CloneError> {
33    let key_file = key_file(key)?;
34    let ssh = setup_ssh_env(key_file.path())?;
35    let mut command = Command::new("git");
36    command
37        .args(clone_args(opts, dest))
38        .env("GIT_SSH_COMMAND", ssh)
39        .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
40    Ok(reason::run(&mut command, &[]).await?)
41}
42
43// git reads no http config over ssh.
44fn clone_args(opts: &CloneOpts, dest: &Path) -> Vec<OsString> {
45    build_clone_args(opts, drop_scp_scheme(&opts.repo_url), dest, &[])
46}
47
48/// tempfile creates it as 0600, which is what ssh demands of a private key.
49pub(crate) fn key_file(key: &str) -> Result<NamedTempFile, CloneError> {
50    let key = base64::engine::general_purpose::STANDARD.decode(key)?;
51    let mut file = tempfile::Builder::new()
52        .prefix("melts-ssh-key-")
53        .tempfile()
54        .map_err(CloneError::KeyFile)?;
55    file.write_all(&key).map_err(CloneError::KeyFile)?;
56    Ok(file)
57}
58
59/// Fails only for a path with a nul byte, which no shell can quote.
60pub fn setup_ssh_env(key_file_path: &Path) -> Result<OsString, shlex::QuoteError> {
61    // git hands the command to a shell, so a path with a space would split in two.
62    let key = shlex::bytes::try_quote(key_file_path.as_os_str().as_bytes())?;
63    let mut command = b"ssh -i ".to_vec();
64    command.extend_from_slice(&key);
65    command.extend_from_slice(
66        b" -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o HostkeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -o ConnectTimeout=30 -o ServerAliveInterval=60 -o ServerAliveCountMax=10",
67    );
68    Ok(OsString::from_vec(command))
69}
70
71/// integrates stores scp urls as `ssh://git@host:org/repo`, which git cannot read, so the `ssh://`
72/// is dropped.
73#[must_use]
74pub(crate) fn drop_scp_scheme(url: &str) -> &str {
75    match url.strip_prefix("ssh://") {
76        Some(scp) if url::Url::parse(url).is_err() => scp,
77        _ => url,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use std::path::Path;
84
85    use base64::Engine;
86
87    use super::{clone_args, drop_scp_scheme, setup_ssh_env};
88    use crate::clone::tests::{opts, source};
89    use crate::clone::{clone_to_temp, CloneShape};
90    use crate::types::CredentialKind;
91
92    #[test]
93    fn a_server_that_never_connects_or_stops_answering_is_given_up_on() {
94        let command = setup_ssh_env(Path::new("/tmp/a_key")).unwrap();
95        let command = command.to_string_lossy();
96
97        assert!(command.contains("-o ConnectTimeout=30"));
98        assert!(command.contains("-o ServerAliveInterval=60"));
99        assert!(command.contains("-o ServerAliveCountMax=10"));
100    }
101
102    #[test]
103    fn no_http_config_goes_to_git() {
104        let args = clone_args(&opts(CloneShape::SingleBranch), Path::new("/tmp/a_root"));
105
106        assert!(!args
107            .iter()
108            .any(|arg| arg.to_string_lossy().starts_with("http.")));
109    }
110
111    #[test]
112    fn the_scp_form_with_a_scheme_in_front_loses_the_scheme() {
113        for (stored, read) in [
114            (
115                "ssh://git@gitlab.com:org/repo.git",
116                "git@gitlab.com:org/repo.git",
117            ),
118            (
119                "ssh://git@ssh.dev.azure.com:v3/O/P/R",
120                "git@ssh.dev.azure.com:v3/O/P/R",
121            ),
122            (
123                "ssh://site@git.wpengine.com:site.git",
124                "site@git.wpengine.com:site.git",
125            ),
126        ] {
127            assert_eq!(drop_scp_scheme(stored), read);
128        }
129    }
130
131    #[test]
132    fn a_url_git_reads_as_meant_is_left_alone() {
133        for url in [
134            "ssh://git@gitlab.com:2222/org/repo.git",
135            "ssh://git@gitlab.com/org/repo.git",
136            "git@gitlab.com:org/repo.git",
137            "git@gitlab.com:2222/org/repo.git",
138        ] {
139            assert_eq!(drop_scp_scheme(url), url);
140        }
141    }
142
143    #[tokio::test]
144    async fn a_url_that_is_not_ssh_clones_nothing() {
145        let tmp = tempfile::tempdir().unwrap();
146        let url = source(tmp.path());
147        let key = CredentialKind::Ssh {
148            key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
149        };
150
151        let outcome = clone_to_temp(tmp.path(), &url, "main", Some(&key), false, false).await;
152
153        assert!(outcome.is_err(), "{outcome:?}");
154    }
155}