use anyhow::Context;
use base64::Engine;
use std::ffi::OsString;
use std::io::Write;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::Path;
use tempfile::NamedTempFile;
use tokio::process::Command;
use super::{build_clone_args, reason, CloneOpts};
use crate::errors::{CloneError, SshKeyError};
pub(crate) const ONLY_SSH: &str = "ssh";
pub(crate) struct SshKey {
_file: NamedTempFile,
pub(crate) command: OsString,
}
impl SshKey {
pub(crate) fn write(key: &str) -> Result<Self, SshKeyError> {
let file = key_file(key)?;
let command = setup_ssh_env(file.path())?;
Ok(Self {
_file: file,
command,
})
}
}
pub async fn clone_ssh(opts: &CloneOpts, dest: &Path) -> anyhow::Result<()> {
let crate::types::CredentialKind::Ssh { key } = &opts
.credentials
.as_ref()
.context("missing credentials")?
.kind
else {
anyhow::bail!("expected SSH credentials");
};
Ok(run_git_clone(opts, dest, key).await?)
}
pub(super) async fn run_git_clone(
opts: &CloneOpts,
dest: &Path,
key: &str,
) -> Result<(), CloneError> {
let key = SshKey::write(key)?;
let mut command = Command::new("git");
command
.args(clone_args(opts, dest))
.env("GIT_SSH_COMMAND", &key.command)
.env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
Ok(reason::run(&mut command, &[]).await?)
}
fn clone_args(opts: &CloneOpts, dest: &Path) -> Vec<OsString> {
build_clone_args(opts, drop_scp_scheme(&opts.repo_url), dest, &[])
}
fn key_file(key: &str) -> Result<NamedTempFile, SshKeyError> {
let key = base64::engine::general_purpose::STANDARD.decode(key)?;
let mut file = tempfile::Builder::new()
.prefix("melts-ssh-key-")
.tempfile()
.map_err(SshKeyError::File)?;
file.write_all(&key).map_err(SshKeyError::File)?;
Ok(file)
}
pub fn setup_ssh_env(key_file_path: &Path) -> Result<OsString, shlex::QuoteError> {
let key = shlex::bytes::try_quote(key_file_path.as_os_str().as_bytes())?;
let mut command = b"ssh -i ".to_vec();
command.extend_from_slice(&key);
command.extend_from_slice(
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",
);
Ok(OsString::from_vec(command))
}
#[must_use]
pub(crate) fn drop_scp_scheme(url: &str) -> &str {
match url.strip_prefix("ssh://") {
Some(scp) if url::Url::parse(url).is_err() => scp,
_ => url,
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use base64::Engine;
use super::{clone_args, drop_scp_scheme, setup_ssh_env, SshKey};
use crate::clone::tests::{opts, source};
use crate::clone::{clone_to_temp, CloneShape};
use crate::errors::SshKeyError;
use crate::types::CredentialKind;
#[test]
fn a_key_that_is_not_base64_is_refused() {
let written = SshKey::write("a");
assert!(matches!(written, Err(SshKeyError::Decode(_))));
}
#[test]
fn a_server_that_never_connects_or_stops_answering_is_given_up_on() {
let command = setup_ssh_env(Path::new("/tmp/a_key")).unwrap();
let command = command.to_string_lossy();
assert!(command.contains("-o ConnectTimeout=30"));
assert!(command.contains("-o ServerAliveInterval=60"));
assert!(command.contains("-o ServerAliveCountMax=10"));
}
#[test]
fn a_server_that_answers_but_sends_nothing_is_given_up_on() {
let command = setup_ssh_env(Path::new("/tmp/a_key")).unwrap();
assert!(command
.to_string_lossy()
.contains("-o ChannelTimeout=global=10m"));
}
#[test]
fn no_http_config_goes_to_git() {
let args = clone_args(&opts(CloneShape::SingleBranch), Path::new("/tmp/a_root"));
assert!(!args
.iter()
.any(|arg| arg.to_string_lossy().starts_with("http.")));
}
#[test]
fn the_scp_form_with_a_scheme_in_front_loses_the_scheme() {
for (stored, read) in [
(
"ssh://git@gitlab.com:org/repo.git",
"git@gitlab.com:org/repo.git",
),
(
"ssh://git@ssh.dev.azure.com:v3/O/P/R",
"git@ssh.dev.azure.com:v3/O/P/R",
),
(
"ssh://site@git.wpengine.com:site.git",
"site@git.wpengine.com:site.git",
),
] {
assert_eq!(drop_scp_scheme(stored), read);
}
}
#[test]
fn a_url_git_reads_as_meant_is_left_alone() {
for url in [
"ssh://git@gitlab.com:2222/org/repo.git",
"ssh://git@gitlab.com/org/repo.git",
"git@gitlab.com:org/repo.git",
"git@gitlab.com:2222/org/repo.git",
] {
assert_eq!(drop_scp_scheme(url), url);
}
}
#[tokio::test]
async fn a_url_that_is_not_ssh_clones_nothing() {
let tmp = tempfile::tempdir().unwrap();
let url = source(tmp.path());
let key = CredentialKind::Ssh {
key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
};
let outcome = clone_to_temp(tmp.path(), &url, "main", Some(&key), false, false).await;
assert!(outcome.is_err(), "{outcome:?}");
}
}