use anyhow::{Context, Result};
use base64::Engine;
use percent_encoding::{percent_decode_str, percent_encode, AsciiSet, NON_ALPHANUMERIC};
use std::path::Path;
use tokio::process::Command;
use url::Url;
use super::{build_clone_args, reason, CloneOpts};
use crate::types::CredentialKind;
pub struct AuthUrl {
pub url: String,
pub secrets: Vec<String>,
}
const GIT_USER_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
fn git_encoded(url_user: &str) -> String {
let raw: Vec<u8> = percent_decode_str(url_user).collect();
percent_encode(&raw, GIT_USER_ENCODE_SET).to_string()
}
pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
let CredentialKind::Https { user, password } = &opts
.credentials
.as_ref()
.context("missing credentials")?
.kind
else {
anyhow::bail!("expected HTTPS credentials");
};
let auth = embed_credentials_in_url(&opts.repo_url, user, password)?;
let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
let args = build_clone_args(opts, &auth.url, dest, &extra_config);
run_git_clone(&args, &auth.secrets).await
}
pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
let CredentialKind::Token {
token,
is_pat,
oauth_type,
} = &opts
.credentials
.as_ref()
.context("missing credentials")?
.kind
else {
anyhow::bail!("expected Token credentials");
};
if *is_pat {
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
let extra_config = vec![
"-c".to_owned(),
"http.sslVerify=false".to_owned(),
"-c".to_owned(),
format!("http.extraHeader=Authorization: Basic {encoded}"),
];
let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
return run_git_clone(&args, &[token.clone(), encoded]).await;
}
let auth = embed_token_in_url(&opts.repo_url, token, oauth_type)?;
let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
let args = build_clone_args(opts, &auth.url, dest, &extra_config);
run_git_clone(&args, &auth.secrets).await
}
pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
run_git_clone(&args, &[]).await
}
pub fn embed_token_in_url(url: &str, token: &str, provider: &str) -> Result<AuthUrl> {
if provider.eq_ignore_ascii_case("BITBUCKET") {
return embed_credentials_in_url(url, "x-token-auth", token);
}
match provider {
"" => {
let mut parsed = Url::parse(url).context("parsing URL")?;
parsed
.set_username(token)
.map_err(|()| anyhow::anyhow!("failed to set the token as the user"))?;
parsed
.set_password(None)
.map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
Ok(AuthUrl {
secrets: vec![
token.to_owned(),
parsed.username().to_owned(),
git_encoded(parsed.username()),
],
url: parsed.to_string(),
})
}
_ => embed_credentials_in_url(url, "oauth2", token),
}
}
pub fn embed_credentials_in_url(raw_url: &str, user: &str, password: &str) -> Result<AuthUrl> {
let mut url = Url::parse(raw_url).context("parsing URL")?;
url.set_username(user)
.map_err(|()| anyhow::anyhow!("failed to set username"))?;
url.set_password(Some(password))
.map_err(|()| anyhow::anyhow!("failed to set password"))?;
Ok(AuthUrl {
secrets: vec![
password.to_owned(),
url.password().unwrap_or_default().to_owned(),
],
url: url.to_string(),
})
}
#[must_use]
pub fn build_https_config_args(
follow_redirects: bool,
is_pat: bool,
token: Option<&str>,
disable_credential_prompt: bool,
) -> Vec<String> {
let mut args = vec![
"-c".to_owned(),
"http.sslVerify=false".to_owned(),
"-c".to_owned(),
format!("http.followRedirects={follow_redirects}"),
];
if is_pat {
if let Some(t) = token {
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
args.extend([
"-c".to_owned(),
format!("http.extraHeader=Authorization: Basic {encoded}"),
]);
}
}
if disable_credential_prompt {
args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
}
args
}
#[allow(clippy::too_many_arguments)]
pub fn format_https_url(
repo_url: &str,
user: Option<&str>,
password: Option<&str>,
token: Option<&str>,
provider: Option<&str>,
is_pat: bool,
) -> Result<String> {
let mut parsed = Url::parse(repo_url).context("parsing URL")?;
let path = parsed.path().to_owned();
parsed.set_path(&path);
if is_pat {
return Ok(parsed.to_string());
}
if let Some(t) = token {
let user_part = match provider {
Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
Some("") | None => "",
Some(_) => "oauth2",
};
if user_part.is_empty() {
parsed
.set_username(t)
.map_err(|()| anyhow::anyhow!("set username"))?;
parsed
.set_password(None)
.map_err(|()| anyhow::anyhow!("set password"))?;
} else {
parsed
.set_username(user_part)
.map_err(|()| anyhow::anyhow!("set username"))?;
parsed
.set_password(Some(t))
.map_err(|()| anyhow::anyhow!("set password"))?;
}
return Ok(parsed.to_string());
}
if let (Some(u), Some(p)) = (user, password) {
parsed
.set_username(u)
.map_err(|()| anyhow::anyhow!("set username"))?;
parsed
.set_password(Some(p))
.map_err(|()| anyhow::anyhow!("set password"))?;
return Ok(parsed.to_string());
}
Ok(parsed.to_string())
}
async fn run_git_clone(args: &[String], secrets: &[String]) -> Result<()> {
let secrets: Vec<&str> = secrets.iter().map(String::as_str).collect();
reason::run(Command::new("git").args(args), "HTTPS", &secrets).await
}
#[cfg(test)]
mod tests {
use super::{embed_token_in_url, format_https_url};
#[test]
fn a_credential_with_no_provider_carries_the_token_with_no_user() {
assert_eq!(
embed_token_in_url("https://gitlab.com/a/b.git", "tok", "")
.unwrap()
.url,
"https://tok@gitlab.com/a/b.git"
);
}
#[test]
fn the_provider_names_the_user_even_off_its_own_domain() {
assert_eq!(
embed_token_in_url("https://git.acme.com/a/b.git", "tok", "BITBUCKET")
.unwrap()
.url,
"https://x-token-auth:tok@git.acme.com/a/b.git"
);
assert_eq!(
embed_token_in_url("https://gitlab.com/a/b.git", "tok", "GITLAB")
.unwrap()
.url,
"https://oauth2:tok@gitlab.com/a/b.git"
);
}
#[test]
fn the_token_replaces_whatever_the_url_already_carried() {
assert_eq!(
embed_token_in_url("https://olduser:oldpass@host/a/b.git", "tok", "")
.unwrap()
.url,
"https://tok@host/a/b.git"
);
}
#[test]
fn the_provider_is_matched_ignoring_the_case() {
let url = "https://host/a/b.git";
assert_eq!(
format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
);
assert_eq!(
embed_token_in_url(url, "tok", "Bitbucket").unwrap().url,
embed_token_in_url(url, "tok", "BITBUCKET").unwrap().url
);
}
#[test]
fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
let url = "https://gitlab.com/a/b.git";
let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
assert_eq!(
format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
no_provider
);
}
}