fluidattacks_core_git/clone/
ssh.rs1use 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
13pub(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
43fn clone_args(opts: &CloneOpts, dest: &Path) -> Vec<OsString> {
45 build_clone_args(opts, drop_scp_scheme(&opts.repo_url), dest, &[])
46}
47
48pub(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
59pub fn setup_ssh_env(key_file_path: &Path) -> Result<OsString, shlex::QuoteError> {
61 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",
67 );
68 Ok(OsString::from_vec(command))
69}
70
71#[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};
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 no_http_config_goes_to_git() {
94 let args = clone_args(&opts(CloneShape::SingleBranch), Path::new("/tmp/a_root"));
95
96 assert!(!args
97 .iter()
98 .any(|arg| arg.to_string_lossy().starts_with("http.")));
99 }
100
101 #[test]
102 fn the_scp_form_with_a_scheme_in_front_loses_the_scheme() {
103 for (stored, read) in [
104 (
105 "ssh://git@gitlab.com:org/repo.git",
106 "git@gitlab.com:org/repo.git",
107 ),
108 (
109 "ssh://git@ssh.dev.azure.com:v3/O/P/R",
110 "git@ssh.dev.azure.com:v3/O/P/R",
111 ),
112 (
113 "ssh://site@git.wpengine.com:site.git",
114 "site@git.wpengine.com:site.git",
115 ),
116 ] {
117 assert_eq!(drop_scp_scheme(stored), read);
118 }
119 }
120
121 #[test]
122 fn a_url_git_reads_as_meant_is_left_alone() {
123 for url in [
124 "ssh://git@gitlab.com:2222/org/repo.git",
125 "ssh://git@gitlab.com/org/repo.git",
126 "git@gitlab.com:org/repo.git",
127 "git@gitlab.com:2222/org/repo.git",
128 ] {
129 assert_eq!(drop_scp_scheme(url), url);
130 }
131 }
132
133 #[tokio::test]
134 async fn a_url_that_is_not_ssh_clones_nothing() {
135 let tmp = tempfile::tempdir().unwrap();
136 let url = source(tmp.path());
137 let key = CredentialKind::Ssh {
138 key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
139 };
140
141 let outcome = clone_to_temp(tmp.path(), &url, "main", Some(&key), false, false).await;
142
143 assert!(outcome.is_err(), "{outcome:?}");
144 }
145}