use anyhow::{Context, Result};
use base64::Engine;
use std::time::Duration;
use tokio::process::Command;
use super::aws;
use super::types::{CredentialKind, Credentials, LsRemoteResult};
use crate::clone::https::{embed_credentials_in_url, embed_token_in_url};
use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};
const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
struct AwsRole<'a> {
arn: &'a str,
external_id: &'a str,
via: Option<&'a str>,
}
fn redirect_config(follow_redirects: bool) -> [String; 2] {
[
"-c".to_owned(),
format!("http.followRedirects={follow_redirects}"),
]
}
async fn run_ls_remote(command: &mut Command) -> Result<LsRemoteResult> {
let Ok(output) = tokio::time::timeout(
Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
command.kill_on_drop(true).output(),
)
.await
else {
return Ok(LsRemoteResult {
commit: None,
error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
});
};
Ok(parse_ls_remote_output(
&output.context("running git ls-remote")?,
))
}
pub async fn ls_remote(
url: &str,
branch: &str,
credentials: Option<&Credentials>,
follow_redirects: bool,
) -> Result<LsRemoteResult> {
match credentials.map(|c| &c.kind) {
Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
Some(CredentialKind::AwsRole {
arn,
external_id,
via,
}) => {
let role = AwsRole {
arn,
external_id,
via: via.as_deref(),
};
ls_remote_codecommit(url, branch, &role, follow_redirects).await
}
Some(CredentialKind::Https { user, password }) => {
let repo_url = embed_credentials_in_url(url, user, password)?;
ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
}
Some(CredentialKind::Token {
token,
is_pat,
oauth_type,
}) => {
if *is_pat {
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
let extra_config = vec![
"-c".to_owned(),
format!("http.extraHeader=Authorization: Basic {encoded}"),
];
ls_remote_https(url, branch, follow_redirects, &extra_config).await
} else {
let repo_url = embed_token_in_url(url, token, oauth_type)?;
ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
}
}
None => ls_remote_https(url, branch, follow_redirects, &[]).await,
}
}
async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
let key_data = base64::engine::general_purpose::STANDARD
.decode(key_b64)
.context("decoding SSH key")?;
let key_file = tempfile::Builder::new()
.prefix("melts-ssh-key-")
.tempfile()
.context("creating SSH key file")?;
std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
.context("setting SSH key permissions")?;
}
let git_ssh_command = setup_ssh_env(key_file.path());
let normalized_url = normalize_ssh_url(url);
let args = [
"ls-remote".to_owned(),
"--".to_owned(),
normalized_url,
branch.to_owned(),
];
let mut command = Command::new("git");
command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
run_ls_remote(&mut command).await
}
async fn ls_remote_https(
url: &str,
branch: &str,
follow_redirects: bool,
extra_config: &[String],
) -> Result<LsRemoteResult> {
let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
args.extend(redirect_config(follow_redirects));
args.extend(extra_config.iter().cloned());
args.extend([
"ls-remote".to_owned(),
"--".to_owned(),
url.to_owned(),
branch.to_owned(),
]);
let mut command = Command::new("git");
command.args(&args);
run_ls_remote(&mut command).await
}
async fn ls_remote_codecommit(
url: &str,
branch: &str,
role: &AwsRole<'_>,
follow_redirects: bool,
) -> Result<LsRemoteResult> {
let creds = aws::assume_role(role.arn, role.external_id, url, role.via).await?;
let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
args.extend(redirect_config(follow_redirects));
args.extend([
"ls-remote".to_owned(),
"--".to_owned(),
url.to_owned(),
branch.to_owned(),
]);
let mut command = Command::new("git");
command
.args(&args)
.env("AWS_ACCESS_KEY_ID", &creds.access_key)
.env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
.env("AWS_SESSION_TOKEN", &creds.session_token)
.env("AWS_DEFAULT_REGION", &creds.region);
run_ls_remote(&mut command).await
}
fn parse_ls_remote_output(output: &std::process::Output) -> LsRemoteResult {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return LsRemoteResult {
commit: None,
error: Some(stderr),
};
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commit = stdout
.lines()
.next()
.and_then(|line| line.split('\t').next())
.unwrap_or_default()
.trim()
.to_owned();
LsRemoteResult {
commit: Some(commit),
error: None,
}
}
#[cfg(test)]
mod tests {
use super::{parse_ls_remote_output, redirect_config};
fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
use std::os::unix::process::ExitStatusExt;
std::process::Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
assert_eq!(
redirect_config(false),
["-c".to_owned(), "http.followRedirects=false".to_owned()]
);
assert_eq!(
redirect_config(true),
["-c".to_owned(), "http.followRedirects=true".to_owned()]
);
}
#[test]
fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
let result = parse_ls_remote_output(&output(0, "", ""));
assert_eq!(result.commit, Some(String::new()));
assert!(result.error.is_none());
}
#[test]
fn the_first_ref_is_the_commit() {
let result = parse_ls_remote_output(&output(
0,
"abc123\trefs/heads/main\ndef456\trefs/heads/other\n",
"",
));
assert_eq!(result.commit.as_deref(), Some("abc123"));
}
#[test]
fn a_failure_reports_the_stderr_and_no_commit() {
let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"));
assert!(result.commit.is_none());
assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
}
}