fluidattacks_core_git/clone/
ssh.rs1use anyhow::Context;
2use base64::Engine;
3use std::path::Path;
4use tokio::process::Command;
5
6use super::{build_clone_args, reason, CloneOpts};
7
8pub async fn clone_ssh(opts: &CloneOpts, dest: &Path) -> anyhow::Result<()> {
9 let crate::types::CredentialKind::Ssh { key } = &opts
10 .credentials
11 .as_ref()
12 .context("missing credentials")?
13 .kind
14 else {
15 anyhow::bail!("expected SSH credentials");
16 };
17
18 let key_data = base64::engine::general_purpose::STANDARD
19 .decode(key)
20 .context("decoding SSH key")?;
21
22 let key_file = tempfile::Builder::new()
23 .prefix("melts-ssh-key-")
24 .tempfile()
25 .context("creating SSH key file")?;
26
27 std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
28
29 #[cfg(unix)]
30 {
31 use std::os::unix::fs::PermissionsExt;
32 std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
33 .context("setting SSH key permissions")?;
34 }
35
36 let repo_url = normalize_ssh_url(&opts.repo_url);
37 let args = build_clone_args(opts, &repo_url, dest, &[]);
38
39 let git_ssh_command = setup_ssh_env(key_file.path());
40
41 reason::run(
42 Command::new("git")
43 .args(&args)
44 .env("GIT_SSH_COMMAND", git_ssh_command),
45 &[],
46 )
47 .await?;
48
49 Ok(())
50}
51
52#[must_use]
53pub fn setup_ssh_env(key_file_path: &Path) -> String {
54 format!(
55 "ssh -i {} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o HostkeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa",
56 key_file_path.display()
57 )
58}
59
60#[must_use]
61pub fn normalize_ssh_url(raw_url: &str) -> String {
62 if raw_url.contains("source.developers.google") || raw_url.starts_with("ssh://FLUID") {
64 return raw_url.to_owned();
65 }
66
67 let with_scheme = if raw_url.starts_with("ssh://") {
69 raw_url.to_owned()
70 } else {
71 format!("ssh://{raw_url}")
72 };
73
74 if with_scheme.starts_with("ssh://") && !url_has_port(&with_scheme) {
78 if let [scheme, host, path] = with_scheme.splitn(3, ':').collect::<Vec<_>>().as_slice() {
79 if !path.starts_with('/') {
80 return format!("{scheme}:{host}:/{path}");
81 }
82 }
83 }
84
85 with_scheme
86}
87
88#[must_use]
89pub fn url_has_port(url: &str) -> bool {
90 url::Url::parse(url).ok().and_then(|u| u.port()).is_some()
92}
93
94#[cfg(test)]
95mod tests {
96 use super::normalize_ssh_url;
97
98 #[test]
99 fn a_url_that_already_names_a_scheme_is_still_forced_through_ssh() {
100 assert_eq!(
101 normalize_ssh_url("https://gitlab.com/a/b.git"),
102 "ssh://https://gitlab.com/a/b.git"
103 );
104 }
105
106 #[test]
107 fn the_scp_form_gets_the_default_port_placeholder() {
108 assert_eq!(
109 normalize_ssh_url("git@gitlab.com:a/b.git"),
110 "ssh://git@gitlab.com:/a/b.git"
111 );
112 }
113
114 #[test]
115 fn an_explicit_port_and_the_two_exemptions_are_left_alone() {
116 for url in [
117 "ssh://git@gitlab.com:2222/a/b.git",
118 "ssh://FLUID@example.com/a/b.git",
119 "https://source.developers.google.com/a/b",
120 ] {
121 assert_eq!(normalize_ssh_url(url), url);
122 }
123 }
124}