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, SshKeyError};
12
13pub(crate) const ONLY_SSH: &str = "ssh";
15
16pub(crate) struct SshKey {
17 _file: NamedTempFile,
19 pub(crate) command: OsString,
20}
21
22impl SshKey {
23 pub(crate) fn write(key: &str) -> Result<Self, SshKeyError> {
24 let file = key_file(key)?;
25 let command = setup_ssh_env(file.path())?;
26 Ok(Self {
27 _file: file,
28 command,
29 })
30 }
31}
32
33pub async fn clone_ssh(opts: &CloneOpts, dest: &Path) -> anyhow::Result<()> {
34 let crate::types::CredentialKind::Ssh { key } = &opts
35 .credentials
36 .as_ref()
37 .context("missing credentials")?
38 .kind
39 else {
40 anyhow::bail!("expected SSH credentials");
41 };
42 Ok(run_git_clone(opts, dest, key).await?)
43}
44
45pub(super) async fn run_git_clone(
46 opts: &CloneOpts,
47 dest: &Path,
48 key: &str,
49) -> Result<(), CloneError> {
50 let key = SshKey::write(key)?;
51 let mut command = Command::new("git");
52 command
53 .args(clone_args(opts, dest))
54 .env("GIT_SSH_COMMAND", &key.command)
55 .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
56 Ok(reason::run(&mut command, &[]).await?)
57}
58
59fn clone_args(opts: &CloneOpts, dest: &Path) -> Vec<OsString> {
61 build_clone_args(opts, drop_scp_scheme(&opts.repo_url), dest, &[])
62}
63
64fn key_file(key: &str) -> Result<NamedTempFile, SshKeyError> {
66 let key = base64::engine::general_purpose::STANDARD.decode(key)?;
67 if key.is_empty() {
68 return Err(SshKeyError::Empty);
69 }
70 let mut file = tempfile::Builder::new()
71 .prefix("melts-ssh-key-")
72 .tempfile()
73 .map_err(SshKeyError::File)?;
74 file.write_all(&key).map_err(SshKeyError::File)?;
75 Ok(file)
76}
77
78pub fn setup_ssh_env(key_file_path: &Path) -> Result<OsString, shlex::QuoteError> {
80 let key = shlex::bytes::try_quote(key_file_path.as_os_str().as_bytes())?;
82 let mut command = b"ssh -i ".to_vec();
83 command.extend_from_slice(&key);
84 command.extend_from_slice(
85 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 -o ChannelTimeout=global=10m",
86 );
87 Ok(OsString::from_vec(command))
88}
89
90#[must_use]
93pub(crate) fn drop_scp_scheme(url: &str) -> &str {
94 match url.strip_prefix("ssh://") {
95 Some(scp) if url::Url::parse(url).is_err() => scp,
96 _ => url,
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use std::path::Path;
103
104 use base64::Engine;
105
106 use super::{clone_args, drop_scp_scheme, setup_ssh_env, SshKey};
107 use crate::clone::tests::{opts, source};
108 use crate::clone::{clone_to_temp, CloneShape};
109 use crate::errors::SshKeyError;
110 use crate::types::CredentialKind;
111
112 #[test]
113 fn a_key_that_is_not_base64_is_refused() {
114 let written = SshKey::write("a");
115
116 assert!(matches!(written, Err(SshKeyError::Decode(_))));
117 }
118
119 #[test]
120 fn an_empty_key_is_refused() {
121 let written = SshKey::write("");
122
123 assert!(matches!(written, Err(SshKeyError::Empty)));
124 }
125
126 #[test]
127 fn a_server_that_never_connects_or_stops_answering_is_given_up_on() {
128 let command = setup_ssh_env(Path::new("/tmp/a_key")).unwrap();
129 let command = command.to_string_lossy();
130
131 assert!(command.contains("-o ConnectTimeout=30"));
132 assert!(command.contains("-o ServerAliveInterval=60"));
133 assert!(command.contains("-o ServerAliveCountMax=10"));
134 }
135
136 #[test]
137 fn a_server_that_answers_but_sends_nothing_is_given_up_on() {
138 let command = setup_ssh_env(Path::new("/tmp/a_key")).unwrap();
139
140 assert!(command
141 .to_string_lossy()
142 .contains("-o ChannelTimeout=global=10m"));
143 }
144
145 #[test]
146 fn no_http_config_goes_to_git() {
147 let args = clone_args(&opts(CloneShape::SingleBranch), Path::new("/tmp/a_root"));
148
149 assert!(!args
150 .iter()
151 .any(|arg| arg.to_string_lossy().starts_with("http.")));
152 }
153
154 #[test]
155 fn the_scp_form_with_a_scheme_in_front_loses_the_scheme() {
156 for (stored, read) in [
157 (
158 "ssh://git@gitlab.com:org/repo.git",
159 "git@gitlab.com:org/repo.git",
160 ),
161 (
162 "ssh://git@ssh.dev.azure.com:v3/O/P/R",
163 "git@ssh.dev.azure.com:v3/O/P/R",
164 ),
165 (
166 "ssh://site@git.wpengine.com:site.git",
167 "site@git.wpengine.com:site.git",
168 ),
169 ] {
170 assert_eq!(drop_scp_scheme(stored), read);
171 }
172 }
173
174 #[test]
175 fn a_url_git_reads_as_meant_is_left_alone() {
176 for url in [
177 "ssh://git@gitlab.com:2222/org/repo.git",
178 "ssh://git@gitlab.com/org/repo.git",
179 "git@gitlab.com:org/repo.git",
180 "git@gitlab.com:2222/org/repo.git",
181 ] {
182 assert_eq!(drop_scp_scheme(url), url);
183 }
184 }
185
186 #[tokio::test]
187 async fn a_url_that_is_not_ssh_clones_nothing() {
188 let tmp = tempfile::tempdir().unwrap();
189 let url = source(tmp.path());
190 let key = CredentialKind::Ssh {
191 key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
192 };
193
194 let outcome = clone_to_temp(tmp.path(), &url, "main", Some(&key), false, false).await;
195
196 assert!(outcome.is_err(), "{outcome:?}");
197 }
198}