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::HttpsAccess;
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>,
}
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 = HttpsAccess::new(url, kind)?;
ls_remote_https(&access, 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(
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([
"ls-remote".to_owned(),
"--".to_owned(),
access.url.clone(),
branch.to_owned(),
]);
let mut command = access.git();
command.args(&args);
run_ls_remote(&mut command, access.secret().as_slice()).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 args = codecommit_ls_remote_args(url, branch, follow_redirects);
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 codecommit_ls_remote_args(url: &str, branch: &str, follow_redirects: bool) -> Vec<String> {
let mut args = redirect_config(follow_redirects).to_vec();
args.extend([
"ls-remote".to_owned(),
"--".to_owned(),
url.to_owned(),
branch.to_owned(),
]);
args
}
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 std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use base64::Engine;
use super::{
codecommit_ls_remote_args, ls_remote, parse_ls_remote_output, redirect_config,
run_ls_remote,
};
use crate::clone::https::HttpsAccess;
use crate::types::{CredentialKind, Credentials};
fn read_request(stream: &TcpStream) -> (String, Option<String>) {
let mut path = String::new();
let mut authorization = None;
let mut reader = BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
let header = line.trim_end();
if header.is_empty() {
break;
}
if path.is_empty() {
header
.split(' ')
.nth(1)
.unwrap_or_default()
.clone_into(&mut path);
}
if let Some(value) = header.strip_prefix("Authorization: ") {
authorization = Some(value.to_owned());
}
line.clear();
}
(path, authorization)
}
fn answer(redirect_to: Option<&str>, path: &str) -> String {
redirect_to.map_or_else(
|| {
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
Content-Length: 0\r\nConnection: close\r\n\r\n"
.to_owned()
},
|host| {
format!(
"HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
Content-Length: 0\r\nConnection: close\r\n\r\n"
)
},
)
}
fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let seen = Arc::new(Mutex::new(Vec::new()));
let record = Arc::clone(&seen);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let (path, authorization) = read_request(&stream);
record.lock().expect("lock").push(authorization);
let answer = answer(redirect_to.as_deref(), &path);
let _ = (&stream).write_all(answer.as_bytes());
}
});
(format!("http://127.0.0.1:{port}"), seen)
}
fn token(token: &str) -> Credentials {
Credentials {
kind: CredentialKind::Token {
token: token.to_owned(),
is_pat: false,
oauth_type: String::new(),
},
}
}
fn pat(token: &str) -> Credentials {
Credentials {
kind: CredentialKind::Token {
token: token.to_owned(),
is_pat: true,
oauth_type: String::new(),
},
}
}
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"));
}
#[tokio::test]
async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
for (credentials, login) in [
(token("Zm9v+YmFy"), "Zm9v+YmFy:"),
(pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
] {
let (host, seen) = serve(None);
let result = ls_remote(
&format!("{host}/org/repo.git"),
"main",
Some(&credentials),
false,
)
.await
.unwrap();
let sent = base64::engine::general_purpose::STANDARD.encode(login);
assert!(seen
.lock()
.unwrap()
.contains(&Some(format!("Basic {sent}"))));
let error = result.error.unwrap();
assert!(error.contains("Authentication failed"), "{error}");
assert!(!error.contains("Zm9v"), "{error}");
}
}
#[tokio::test]
async fn a_redirect_to_another_host_carries_no_credential() {
for credentials in [token("tok123"), pat("tok123")] {
let (other, other_seen) = serve(None);
let other = other.replace("127.0.0.1", "localhost");
let (host, _) = serve(Some(other));
let result = ls_remote(
&format!("{host}/org/repo.git"),
"main",
Some(&credentials),
true,
)
.await
.unwrap();
assert!(result.error.is_some());
let other_seen = other_seen.lock().unwrap().clone();
assert!(!other_seen.is_empty());
assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
}
}
#[tokio::test]
async fn a_helper_the_caller_configured_never_sees_the_credential() {
let (host, _) = serve(None);
let calls = tempfile::NamedTempFile::new().unwrap();
let access =
HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
let mut command = access.git();
command
.env(
"GIT_CONFIG_PARAMETERS",
format!(
"'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
calls.path().display()
),
)
.args(["ls-remote", "--", &access.url, "main"]);
run_ls_remote(&mut command, access.secret().as_slice())
.await
.unwrap();
assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
}
#[test]
fn a_failure_hides_the_credential_a_server_echoes() {
let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
assert_eq!(
result.error.as_deref(),
Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
);
}
#[test]
fn a_codecommit_url_lets_git_check_the_certificate() {
let args = codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", false);
assert!(!args.contains(&"http.sslVerify=false".to_owned()));
}
}