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::reason::hide;
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 {
arn: String,
external_id: String,
via: Option<String>,
}
struct HttpsAccess {
url: String,
extra_config: Vec<String>,
secrets: Vec<String>,
}
fn redirect_config(follow_redirects: bool) -> [String; 2] {
[
"-c".to_owned(),
format!("http.followRedirects={follow_redirects}"),
]
}
async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> 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")?,
secrets,
))
}
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: arn.clone(),
external_id: external_id.clone(),
via: via.clone(),
};
ls_remote_codecommit(url, branch, &role, follow_redirects).await
}
kind => {
let access = https_access(url, kind)?;
ls_remote_https(&access, branch, follow_redirects).await
}
}
}
fn https_access(url: &str, kind: Option<&CredentialKind>) -> Result<HttpsAccess> {
match kind {
None => Ok(HttpsAccess {
url: url.to_owned(),
extra_config: Vec::new(),
secrets: Vec::new(),
}),
Some(CredentialKind::Https { user, password }) => {
let auth = embed_credentials_in_url(url, user, password)?;
Ok(HttpsAccess {
url: auth.url,
extra_config: Vec::new(),
secrets: auth.secrets,
})
}
Some(CredentialKind::Token {
token,
is_pat: true,
..
}) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
Ok(HttpsAccess {
url: url.to_owned(),
extra_config: vec![
"-c".to_owned(),
format!("http.extraHeader=Authorization: Basic {encoded}"),
],
secrets: vec![token.clone(), encoded],
})
}
Some(CredentialKind::Token {
token, oauth_type, ..
}) => {
let auth = embed_token_in_url(url, token, oauth_type)?;
Ok(HttpsAccess {
url: auth.url,
extra_config: Vec::new(),
secrets: auth.secrets,
})
}
Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
anyhow::bail!("expected an https credential")
}
}
}
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(
access: &HttpsAccess,
branch: &str,
follow_redirects: bool,
) -> Result<LsRemoteResult> {
let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
args.extend(redirect_config(follow_redirects));
args.extend(access.extra_config.iter().cloned());
args.extend([
"ls-remote".to_owned(),
"--".to_owned(),
access.url.clone(),
branch.to_owned(),
]);
let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
let mut command = Command::new("git");
command.args(&args);
run_ls_remote(&mut command, &secrets).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.as_deref()).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, secrets: &[&str]) -> LsRemoteResult {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return LsRemoteResult {
commit: None,
error: Some(hide(&stderr, secrets)),
};
}
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::{https_access, parse_ls_remote_output, redirect_config};
use crate::types::CredentialKind;
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"));
}
#[test]
fn a_failure_hides_the_credential_as_the_url_encoded_it() {
let kind = CredentialKind::Token {
token: "ab=c/d".to_owned(),
is_pat: false,
oauth_type: String::new(),
};
let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
let said = "fatal: could not read Password for 'https://ab%3Dc%2Fd@github.com': \
terminal prompts disabled\n";
let result = parse_ls_remote_output(&output(128, "", said), &secrets);
assert_eq!(
result.error.as_deref(),
Some(
"fatal: could not read Password for 'https://[REDACTED]@github.com': \
terminal prompts disabled\n"
)
);
}
#[test]
fn a_failure_hides_the_credential_as_git_encoded_it() {
let kind = CredentialKind::Token {
token: "Zm9v+YmFy".to_owned(),
is_pat: false,
oauth_type: String::new(),
};
let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
let said = "fatal: could not read Password for 'https://Zm9v%2BYmFy@github.com': \
terminal prompts disabled\n";
let result = parse_ls_remote_output(&output(128, "", said), &secrets);
assert_eq!(
result.error.as_deref(),
Some(
"fatal: could not read Password for 'https://[REDACTED]@github.com': \
terminal prompts disabled\n"
)
);
}
#[test]
fn a_failure_hides_the_credential_git_repeats() {
let said = "fatal: could not read Password for 'https://tok123@github.com': \
terminal prompts disabled\n";
let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
assert_eq!(
result.error.as_deref(),
Some(
"fatal: could not read Password for 'https://[REDACTED]@github.com': \
terminal prompts disabled\n"
)
);
}
}