Skip to main content

fluidattacks_core_git/clone/
ssh.rs

1use 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        "SSH",
46        &[],
47    )
48    .await
49}
50
51#[must_use]
52pub fn setup_ssh_env(key_file_path: &Path) -> String {
53    format!(
54        "ssh -i {} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o HostkeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa",
55        key_file_path.display()
56    )
57}
58
59#[must_use]
60pub fn normalize_ssh_url(raw_url: &str) -> String {
61    // Special cases that should not be transformed
62    if raw_url.contains("source.developers.google") || raw_url.starts_with("ssh://FLUID") {
63        return raw_url.to_owned();
64    }
65
66    // Add ssh:// scheme if missing
67    let with_scheme = if raw_url.starts_with("ssh://") {
68        raw_url.to_owned()
69    } else if let Some(colon_idx) = raw_url.find(':') {
70        let before_colon = &raw_url[..colon_idx];
71        if !before_colon.contains('/') && !raw_url.contains("://") {
72            format!(
73                "ssh://{}/{}",
74                before_colon,
75                &raw_url[colon_idx.saturating_add(1)..]
76            )
77        } else {
78            raw_url.to_owned()
79        }
80    } else {
81        raw_url.to_owned()
82    };
83
84    // If URL has ssh:// scheme but no explicit port, ensure the path
85    // starts with '/' so the part after the host isn't parsed as a port.
86    // e.g. ssh://git@host:v3/org/repo → ssh://git@host:/v3/org/repo
87    if with_scheme.starts_with("ssh://") && !url_has_port(&with_scheme) {
88        if let [scheme, host, path] = with_scheme.splitn(3, ':').collect::<Vec<_>>().as_slice() {
89            if !path.starts_with('/') {
90                return format!("{scheme}:{host}:/{path}");
91            }
92        }
93    }
94
95    with_scheme
96}
97
98#[must_use]
99pub fn url_has_port(url: &str) -> bool {
100    // Parse as URL and check if port is a valid integer
101    url::Url::parse(url).ok().and_then(|u| u.port()).is_some()
102}