use std::time::Duration;
use tokio::process::Command;
use super::aws::{self, AwsTempCreds};
use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
use crate::call::not_run;
use crate::clone::https::HttpsAccess;
use crate::clone::reason::hide;
use crate::clone::ssh::{drop_scp_scheme, SshKey, ONLY_SSH};
use crate::clone::{has_http_scheme, redirect_config};
use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
const LS_REMOTE_TIMEOUT: Duration = Duration::from_secs(20);
const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
struct AwsRole {
arn: String,
external_id: String,
via: Option<String>,
}
fn branch_refs(branch: &str) -> Vec<String> {
if branch == "HEAD" {
return vec!["HEAD".to_owned()];
}
vec![
format!("refs/heads/{branch}"),
format!("refs/tags/{branch}"),
]
}
fn ls_remote_args(url: &str, flags: &[&str], pattern: &str) -> Vec<String> {
let mut args = vec!["ls-remote".to_owned()];
args.extend(flags.iter().map(|&flag| flag.to_owned()));
args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
args
}
async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<String, GitCallError> {
let Ok(output) =
tokio::time::timeout(LS_REMOTE_TIMEOUT, command.kill_on_drop(true).output()).await
else {
return Err(GitCallError::TimedOut {
command: "ls-remote",
after: LS_REMOTE_TIMEOUT,
});
};
let output = output.map_err(|source| not_run("ls-remote", source))?;
if !output.status.success() {
return Err(GitCallError::Refused {
command: "ls-remote",
status: output.status,
stderr: hide(&String::from_utf8_lossy(&output.stderr), secrets),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub async fn ls_remote(
url: &str,
branch: &str,
credentials: Option<&Credentials>,
follow_redirects: bool,
) -> Result<LsRemoteResult, LsRemoteError> {
match ask(url, &[], branch, credentials, follow_redirects).await {
Ok(listing) => Ok(LsRemoteResult {
commit: Some(commit_of(&listing, branch)),
error: None,
}),
Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(LsRemoteResult {
commit: None,
error: Some(stderr),
}),
Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(LsRemoteResult {
commit: None,
error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
}),
Err(error) => Err(error),
}
}
pub async fn remote_head(
url: &str,
credentials: Option<&Credentials>,
follow_redirects: bool,
) -> Result<RemoteHead, LsRemoteError> {
match ask(url, &["--symref"], "HEAD", credentials, follow_redirects).await {
Ok(listing) => Ok(RemoteHead {
branch: head_of(&listing),
error: None,
}),
Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(RemoteHead {
branch: None,
error: Some(stderr),
}),
Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(RemoteHead {
branch: None,
error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
}),
Err(error) => Err(error),
}
}
async fn ask(
url: &str,
flags: &[&str],
pattern: &str,
credentials: Option<&Credentials>,
follow_redirects: bool,
) -> Result<String, LsRemoteError> {
match credentials.map(|c| &c.kind) {
Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, flags, pattern, 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, flags, pattern, &role, follow_redirects).await
}
kind => ls_remote_https(url, flags, pattern, kind, follow_redirects).await,
}
}
async fn ls_remote_ssh(
url: &str,
flags: &[&str],
pattern: &str,
key_b64: &str,
) -> Result<String, LsRemoteError> {
let key = SshKey::write(key_b64)?;
let args = ls_remote_args(drop_scp_scheme(url), flags, pattern);
let mut command = Command::new("git");
command
.args(&args)
.env("GIT_SSH_COMMAND", &key.command)
.env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
Ok(run_ls_remote(&mut command, &[]).await?)
}
async fn ls_remote_https(
url: &str,
flags: &[&str],
pattern: &str,
credential: Option<&CredentialKind>,
follow_redirects: bool,
) -> Result<String, LsRemoteError> {
if credential.is_none() && !has_http_scheme(url) {
return Err(HttpsAccessError::NotHttp.into());
}
let access = HttpsAccess::new(url, credential)?;
let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
args.extend(redirect_config(follow_redirects));
args.extend(ls_remote_args(access.url(), flags, pattern));
let mut command = access.git();
command.args(&args);
Ok(run_ls_remote(&mut command, access.secret().as_slice()).await?)
}
async fn ls_remote_codecommit(
url: &str,
flags: &[&str],
pattern: &str,
role: &AwsRole,
follow_redirects: bool,
) -> Result<String, LsRemoteError> {
let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
Ok(ls_remote_with(url, flags, pattern, &creds, follow_redirects).await?)
}
async fn ls_remote_with(
url: &str,
flags: &[&str],
pattern: &str,
creds: &AwsTempCreds,
follow_redirects: bool,
) -> Result<String, GitCallError> {
let args = codecommit_ls_remote_args(url, flags, pattern, 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);
let secrets = [creds.secret_key.as_str(), creds.session_token.as_str()];
run_ls_remote(&mut command, &secrets).await
}
fn codecommit_ls_remote_args(
url: &str,
flags: &[&str],
pattern: &str,
follow_redirects: bool,
) -> Vec<String> {
let mut args = redirect_config(follow_redirects).to_vec();
args.extend(ls_remote_args(url, flags, pattern));
args
}
fn commit_of(listing: &str, branch: &str) -> String {
let listed = |wanted: &str| {
listing.lines().find_map(|line| {
let (commit, name) = line.split_once('\t')?;
(name == wanted).then_some(commit)
})
};
branch_refs(branch)
.iter()
.find_map(|wanted| listed(wanted))
.unwrap_or_default()
.to_owned()
}
fn head_of(listing: &str) -> Option<String> {
listing.lines().find_map(head_branch).map(str::to_owned)
}
fn head_branch(line: &str) -> Option<&str> {
let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
if name != "HEAD" {
return None;
}
target
.strip_prefix("refs/heads/")
.filter(|branch| !branch.is_empty())
}
#[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, commit_of, head_of, ls_remote, ls_remote_args, ls_remote_with,
redirect_config, remote_head, run_ls_remote, AwsTempCreds,
};
use crate::clone::https::HttpsAccess;
use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
use crate::types::{CredentialKind, Credentials};
use tokio::process::Command;
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 refusing(stderr: &str, code: i32) -> Command {
let mut command = Command::new("sh");
command.args([
"-c",
&format!("printf '%s' \"$0\" >&2; exit {code}"),
stderr,
]);
command
}
#[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() {
assert_eq!(commit_of("", "main"), "");
}
#[test]
fn a_branch_is_read_by_its_full_name_and_before_a_tag_of_the_same_name() {
for (said, commit) in [
(
"aaa\trefs/heads/feature/main\nbbb\trefs/heads/main\n",
"bbb",
),
("ttt\trefs/tags/main\nbbb\trefs/heads/main\n", "bbb"),
("ttt\trefs/tags/main\n", "ttt"),
("aaa\trefs/heads/feature/main\n", ""),
] {
assert_eq!(commit_of(said, "main"), commit, "{said:?}");
}
}
#[test]
fn head_is_read_only_from_the_line_git_lists_as_head() {
for (said, commit) in [
("aaa\tHEAD\nbbb\trefs/heads/HEAD\n", "aaa"),
("bbb\trefs/heads/HEAD\nccc\trefs/remotes/origin/HEAD\n", ""),
] {
assert_eq!(commit_of(said, "HEAD"), commit, "{said:?}");
}
}
#[tokio::test]
async fn a_refusal_keeps_the_whole_stderr_with_the_credential_hidden() {
let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
let refused = run_ls_remote(&mut refusing(said, 128), &["tok123"]).await;
let Err(GitCallError::Refused { stderr, .. }) = refused else {
panic!("{refused:?}");
};
assert_eq!(
stderr,
"remote: token [REDACTED] is not valid\nfatal: Authentication failed\n"
);
}
#[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"]);
let refused = run_ls_remote(&mut command, access.secret().as_slice()).await;
assert!(
matches!(refused, Err(GitCallError::Refused { .. })),
"{refused:?}"
);
assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
}
#[test]
fn a_codecommit_url_lets_git_check_the_certificate() {
for flags in [&[][..], &["--symref"]] {
let args =
codecommit_ls_remote_args("codecommit::us-east-1://repo", flags, "main", false);
assert!(!args.contains(&"http.sslVerify=false".to_owned()));
}
}
#[tokio::test]
async fn a_codecommit_ls_remote_keeps_the_session_out_of_what_git_says() {
let tmp = tempfile::tempdir().unwrap();
let url = format!("{}/a-secret-key/a-token", tmp.path().display());
let creds = AwsTempCreds {
access_key: "an-access-key".to_owned(),
secret_key: "a-secret-key".to_owned(),
session_token: "a-token".to_owned(),
region: "us-east-1".to_owned(),
};
let answer = ls_remote_with(&url, &[], "main", &creds, false).await;
let Err(GitCallError::Refused { stderr, .. }) = answer else {
panic!("{answer:?}");
};
assert!(stderr.contains("[REDACTED]/[REDACTED]"), "{stderr}");
}
#[test]
fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
assert_eq!(
ls_remote_args("https://h/r", &["--symref"], "HEAD"),
["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
);
assert_eq!(
ls_remote_args("https://h/r", &[], "main"),
["ls-remote", "--", "https://h/r", "main"]
);
}
fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
.args(args)
.output()
.unwrap()
.status;
assert!(status.success(), "git {args:?}");
};
git(&["init", "--quiet", "--initial-branch", branch]);
for _ in 0..commits {
git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
}
dir
}
fn file_answer(git_dir: &std::path::Path, path: &str) -> Vec<u8> {
let file = path.split('?').next().unwrap_or_default();
let Ok(body) = std::fs::read(git_dir.join(file.trim_start_matches('/'))) else {
return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_vec();
};
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
[head.into_bytes(), body].concat()
}
fn serve_repository(repository: &std::path::Path) -> String {
let git_dir = repository.join(".git");
let updated = std::process::Command::new("git")
.arg("-C")
.arg(&git_dir)
.arg("update-server-info")
.status()
.unwrap();
assert!(updated.success());
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let (path, _) = read_request(&stream);
let _ = (&stream).write_all(&file_answer(&git_dir, &path));
}
});
format!("http://127.0.0.1:{port}/")
}
#[test]
fn the_branch_head_names_is_read_without_its_prefix() {
assert_eq!(
head_of("ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n").as_deref(),
Some("release/2.0")
);
}
#[test]
fn a_head_that_names_no_branch_is_no_branch() {
for said in [
"",
"abc123\tHEAD\n",
"ref: refs/tags/v1\tHEAD\n",
"ref: refs/heads/\tHEAD\n",
"ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
] {
assert!(head_of(said).is_none(), "{said:?}");
}
}
#[tokio::test]
async fn a_remote_answers_the_branch_its_head_names() {
let remote = repository("trunk", 1);
let url = serve_repository(remote.path());
let result = remote_head(&url, None, false).await.unwrap();
assert_eq!(result.branch.as_deref(), Some("trunk"));
assert!(result.error.is_none());
}
#[tokio::test]
async fn an_empty_remote_names_no_branch() {
let remote = repository("trunk", 0);
let url = serve_repository(remote.path());
let result = remote_head(&url, None, false).await.unwrap();
assert!(result.branch.is_none());
assert!(result.error.is_none());
}
#[tokio::test]
async fn a_branch_is_not_found_by_a_ref_that_only_ends_in_its_name() {
let remote = repository("trunk", 1);
let moved = std::process::Command::new("git")
.arg("-C")
.arg(remote.path())
.args(["update-ref", "refs/heads/feature/dev", "trunk"])
.status()
.unwrap();
assert!(moved.success());
let url = serve_repository(remote.path());
let found = ls_remote(&url, "trunk", None, false).await.unwrap();
let missing = ls_remote(&url, "dev", None, false).await.unwrap();
assert_eq!(found.commit.unwrap().len(), 40);
assert_eq!(missing.commit.as_deref(), Some(""));
}
#[tokio::test]
async fn without_a_credential_a_url_that_is_not_http_never_runs_git() {
let remote = repository("trunk", 1);
let path = remote.path().to_str().unwrap();
for url in [
path,
"httpx://127.0.0.1/repo.git",
"http:127.0.0.1/repo.git",
] {
let answer = ls_remote(url, "trunk", None, false).await;
assert!(
matches!(
answer,
Err(LsRemoteError::Access(HttpsAccessError::NotHttp))
),
"{url}: {answer:?}"
);
}
}
#[tokio::test]
async fn a_head_read_that_cannot_ask_says_why_as_before() {
let error = remote_head("git@example.com:org/repo.git", None, false)
.await
.unwrap_err();
assert_eq!(error.to_string(), "expected an http or https url");
}
#[tokio::test]
async fn an_ssh_ls_remote_with_a_nul_in_the_branch_never_runs_git() {
let key = Credentials {
kind: CredentialKind::Ssh {
key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
},
};
let nul = ls_remote("git@example.com:org/repo.git", "ma\0in", Some(&key), false).await;
assert!(matches!(
nul,
Err(LsRemoteError::Git(GitCallError::NullByte {
command: "ls-remote"
}))
));
}
#[tokio::test]
async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
let remote = repository("trunk", 1);
let url = remote.path().to_str().unwrap();
let key = Credentials {
kind: CredentialKind::Ssh {
key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
},
};
let result = remote_head(url, Some(&key), false).await.unwrap();
assert!(result.branch.is_none());
assert!(result.error.is_some());
}
#[tokio::test]
async fn a_head_read_takes_the_token_to_its_host_and_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 = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
.await
.unwrap();
let sent = base64::engine::general_purpose::STANDARD.encode(login);
assert!(seen
.lock()
.unwrap()
.contains(&Some(format!("Basic {sent}"))));
assert!(result.branch.is_none());
let error = result.error.unwrap();
assert!(error.contains("Authentication failed"), "{error}");
assert!(!error.contains("Zm9v"), "{error}");
}
}
#[tokio::test]
async fn a_head_read_redirected_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 = remote_head(&format!("{host}/org/repo.git"), 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:?}");
}
}
}