use anyhow::{Context, Result};
use base64::Engine;
use http::uri::{PathAndQuery, Uri};
use std::fmt;
use std::path::Path;
use tokio::process::Command;
use url::Url;
use super::{build_clone_args, reason, redirect_config, CloneOpts};
use crate::errors::{GitCallError, HttpsAccessError};
use crate::types::CredentialKind;
const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
fn env_helper() -> String {
format!(
"!f() {{ if [ \"$1\" = get ]; then \
printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
)
}
struct BareUrl {
url: String,
origin: String,
}
pub struct HttpsAccess {
url: String,
secret: Option<String>,
config: Vec<(String, String)>,
env: Vec<(String, String)>,
}
impl fmt::Debug for HttpsAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpsAccess")
.field("url", &self.url)
.finish_non_exhaustive()
}
}
impl HttpsAccess {
pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self, HttpsAccessError> {
match credential {
None => Ok(Self::public(url)),
Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
Some(CredentialKind::Token {
token,
is_pat,
oauth_type,
}) => {
let (user, password) = token_login(token, oauth_type, *is_pat);
Ok(Self {
secret: Some(token.clone()),
..Self::basic(url, user, password)?
})
}
Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
Err(HttpsAccessError::NotHttpsCredential)
}
}
}
fn public(url: &str) -> Self {
Self {
url: url.to_owned(),
secret: None,
config: Vec::new(),
env: Vec::new(),
}
}
fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self, HttpsAccessError> {
if [user, password]
.iter()
.any(|value| value.contains(['\n', '\r', '\0']))
{
return Err(HttpsAccessError::CredentialLineBreak);
}
let url = bare_url(raw_url)?;
Ok(Self {
secret: Some(password.to_owned()),
config: vec![(format!("credential.{}.helper", url.origin), env_helper())],
env: vec![
(USER_VAR.to_owned(), user.to_owned()),
(PASSWORD_VAR.to_owned(), password.to_owned()),
],
url: url.url,
})
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
#[must_use]
pub fn git(&self) -> Command {
let mut command = Command::new("git");
command.args(["-c", "credential.helper="]);
for (key, value) in &self.config {
command.arg("-c").arg(format!("{key}={value}"));
}
command
.env("GIT_TERMINAL_PROMPT", "0")
.env_remove("GIT_TRACE_REDACT")
.env_remove("GIT_TRACE_CURL")
.envs(self.env.iter().cloned());
command
}
#[must_use]
pub fn secret(&self) -> Option<&str> {
self.secret.as_deref()
}
}
fn bare_url(raw_url: &str) -> Result<BareUrl, HttpsAccessError> {
let uri: Uri = raw_url.parse().map_err(|_| HttpsAccessError::Unparsable)?;
let scheme = uri
.scheme_str()
.filter(|scheme| matches!(*scheme, "http" | "https"))
.ok_or(HttpsAccessError::NotHttp)?;
let authority = uri.authority().ok_or(HttpsAccessError::Unparsable)?;
let port = authority
.port()
.map_or_else(String::new, |port| format!(":{port}"));
let host = format!("{}{port}", authority.host());
if authority.host().is_empty() || !authority.as_str().ends_with(&host) {
return Err(HttpsAccessError::Unparsable);
}
let origin = format!("{scheme}://{host}");
let path = uri.path_and_query().map_or("", PathAndQuery::as_str);
Ok(BareUrl {
url: format!("{origin}{path}"),
origin,
})
}
const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
if is_pat {
("pat", token)
} else if provider.eq_ignore_ascii_case("BITBUCKET") {
("x-token-auth", token)
} else if provider.is_empty() {
(token, "")
} else {
("oauth2", token)
}
}
pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
let kind = &opts
.credentials
.as_ref()
.context("missing credentials")?
.kind;
if !matches!(kind, CredentialKind::Https { .. }) {
anyhow::bail!("expected HTTPS credentials");
}
Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
}
pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
let kind = &opts
.credentials
.as_ref()
.context("missing credentials")?
.kind;
if !matches!(kind, CredentialKind::Token { .. }) {
anyhow::bail!("expected Token credentials");
}
Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
}
pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
Ok(run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await?)
}
#[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())
}
pub(super) async fn run_git_clone(
opts: &CloneOpts,
dest: &Path,
access: &HttpsAccess,
) -> Result<(), GitCallError> {
let mut command = access.git();
command.args(clone_args(opts, dest, access));
reason::run(&mut command, access.secret().as_slice()).await
}
fn clone_args(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Vec<std::ffi::OsString> {
let mut extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
extra_config.extend(redirect_config(opts.follow_redirects));
build_clone_args(opts, access.url(), dest, &extra_config)
}
#[cfg(test)]
mod tests {
use super::{clone_args, format_https_url, token_login, HttpsAccess};
use crate::clone::tests::opts;
use crate::clone::{CloneOpts, CloneShape};
use crate::errors::HttpsAccessError;
use crate::types::CredentialKind;
use std::ffi::OsString;
fn args(access: &HttpsAccess) -> Vec<String> {
access
.git()
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn token(token: &str, provider: &str) -> CredentialKind {
CredentialKind::Token {
token: token.to_owned(),
is_pat: false,
oauth_type: provider.to_owned(),
}
}
#[test]
fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
assert_eq!(token_login("tok", "", false), ("tok", ""));
}
#[test]
fn the_provider_names_the_user_even_off_its_own_domain() {
assert_eq!(
token_login("tok", "BITBUCKET", false),
("x-token-auth", "tok")
);
assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
}
#[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!(
token_login("tok", "Bitbucket", false),
token_login("tok", "BITBUCKET", false)
);
}
fn basic(user: &str, password: &str) -> CredentialKind {
CredentialKind::Https {
user: user.to_owned(),
password: password.to_owned(),
}
}
#[test]
fn the_url_drops_whatever_credential_it_carried() {
let access = HttpsAccess::new(
"https://olduser:oldpass@host/a/b.git",
Some(&token("tok", "")),
)
.unwrap();
assert_eq!(access.url(), "https://host/a/b.git");
}
#[test]
fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
assert_eq!(token_login("tok", "", true), ("pat", "tok"));
assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
}
#[test]
fn the_host_is_left_as_written_for_git_to_read() {
for (url, expected) in [
("https://Host:443/a.git", "https://Host:443/a.git"),
("https://[::1]:8443/a.git", "https://[::1]:8443/a.git"),
("https://u:p@w@host/a.git", "https://host/a.git"),
] {
let access = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap();
let origin = expected.trim_end_matches("/a.git");
assert_eq!(access.url(), expected, "{url}");
let helper = format!("credential.{origin}.helper=");
let host = args(&access).get(3).cloned().unwrap_or_default();
assert!(host.starts_with(&helper), "{url}");
}
}
#[test]
fn each_failure_says_what_the_python_said_and_never_the_url() {
for (url, expected) in [
("ftp://u:hunter2@host/a.git", HttpsAccessError::NotHttp),
("host/a.git", HttpsAccessError::Unparsable),
("https:u:hunter2@host/a.git", HttpsAccessError::Unparsable),
("https:///host/a.git", HttpsAccessError::Unparsable),
("https://u:hunter2@/a.git", HttpsAccessError::Unparsable),
("https://u:hunter2@:443/a.git", HttpsAccessError::Unparsable),
(
"https://u:hunter2@host:99999/a.git",
HttpsAccessError::Unparsable,
),
(
"https://u:hunter2@faß.de/a.git",
HttpsAccessError::Unparsable,
),
(
"https://tok\\hunter2@host/a.git",
HttpsAccessError::Unparsable,
),
] {
let error = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap_err();
assert_eq!(error, expected, "{url}");
assert!(!error.to_string().contains("hunter2"), "{url}");
}
assert_eq!(
HttpsAccessError::NotHttp.to_string(),
"expected an http or https url"
);
assert_eq!(
HttpsAccessError::Unparsable.to_string(),
"Invalid url: its host or port could not be parsed"
);
}
#[test]
fn no_credential_leaves_the_url_unchecked() {
let access = HttpsAccess::new("ftp://host:99999/a.git", None).unwrap();
assert_eq!(access.url(), "ftp://host:99999/a.git");
assert_eq!(args(&access), ["-c", "credential.helper="]);
}
#[test]
fn the_debug_shows_neither_the_login_nor_the_environment() {
let access =
HttpsAccess::new("https://host/a.git", Some(&basic("someone", "s3cr3t"))).unwrap();
let shown = format!("{access:?}");
assert!(shown.contains("https://host/a.git"), "{shown}");
assert!(!shown.contains("s3cr3t"), "{shown}");
assert!(!shown.contains("someone"), "{shown}");
}
#[test]
fn the_machine_helpers_are_dropped_before_the_host_helper() {
let access = HttpsAccess::new("https://host/a.git", Some(&token("tok", ""))).unwrap();
let args = args(&access);
let [flag, machine, host_flag, host] = args.as_slice() else {
panic!("{args:?}");
};
assert_eq!(
[flag, machine, host_flag],
["-c", "credential.helper=", "-c"]
);
assert!(
host.starts_with("credential.https://host.helper=!f()"),
"{host}"
);
}
#[test]
fn the_login_goes_through_the_environment() {
let access = HttpsAccess::new("https://host/a.git", Some(&basic("u", "p"))).unwrap();
let envs: Vec<_> = access
.git()
.as_std()
.get_envs()
.map(|(name, value)| (name.to_owned(), value.map(ToOwned::to_owned)))
.collect();
let set = |value: &str| Some(OsString::from(value));
assert_eq!(
envs,
[
("FLUIDATTACKS_GIT_PASSWORD".into(), set("p")),
("FLUIDATTACKS_GIT_USER".into(), set("u")),
("GIT_TERMINAL_PROMPT".into(), set("0")),
("GIT_TRACE_CURL".into(), None),
("GIT_TRACE_REDACT".into(), None),
]
);
}
#[test]
fn a_credential_with_a_line_break_or_a_nul_is_refused() {
for credential in [
token("tok\nhost=x", ""),
token("tok\0x", "GITLAB"),
basic("u\rx", "p"),
basic("u", "p\nx"),
] {
let error = HttpsAccess::new("https://host/a/b.git", Some(&credential)).unwrap_err();
assert_eq!(error, HttpsAccessError::CredentialLineBreak);
assert_eq!(
error.to_string(),
"Invalid credentials: a line break or a nul byte"
);
}
}
#[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
);
}
#[test]
fn a_redirect_is_followed_only_when_the_root_asks_for_one() {
for follow_redirects in [false, true] {
let opts = CloneOpts {
follow_redirects,
..opts(CloneShape::SingleBranch)
};
let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
let config = format!("http.followRedirects={follow_redirects}");
assert!(args.windows(2).any(|pair| pair == ["-c", config.as_str()]));
}
}
}