Skip to main content

fluidattacks_core_git/clone/
https.rs

1use anyhow::{Context, Result};
2use base64::Engine;
3use std::path::Path;
4use tokio::process::Command;
5use url::Url;
6
7use super::{build_clone_args, reason, CloneOpts};
8use crate::types::CredentialKind;
9
10pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
11    let CredentialKind::Https { user, password } = &opts
12        .credentials
13        .as_ref()
14        .context("missing credentials")?
15        .kind
16    else {
17        anyhow::bail!("expected HTTPS credentials");
18    };
19    let repo_url = embed_credentials_in_url(&opts.repo_url, user, password)?;
20
21    let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
22    let args = build_clone_args(opts, &repo_url, dest, &extra_config);
23    run_git_clone(&args, &[password.as_str()]).await
24}
25
26pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
27    let CredentialKind::Token {
28        token,
29        is_pat,
30        oauth_type,
31    } = &opts
32        .credentials
33        .as_ref()
34        .context("missing credentials")?
35        .kind
36    else {
37        anyhow::bail!("expected Token credentials");
38    };
39
40    if *is_pat {
41        let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
42        let extra_config = vec![
43            "-c".to_owned(),
44            "http.sslVerify=false".to_owned(),
45            "-c".to_owned(),
46            format!("http.extraHeader=Authorization: Basic {encoded}"),
47        ];
48        let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
49        return run_git_clone(&args, &[token.as_str(), &encoded]).await;
50    }
51
52    let repo_url = embed_token_in_url(&opts.repo_url, token, oauth_type)?;
53    let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
54    let args = build_clone_args(opts, &repo_url, dest, &extra_config);
55    run_git_clone(&args, &[token.as_str()]).await
56}
57
58pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
59    let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
60    let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
61    run_git_clone(&args, &[]).await
62}
63
64pub fn embed_token_in_url(url: &str, token: &str, provider: &str) -> Result<String> {
65    if provider.eq_ignore_ascii_case("BITBUCKET") {
66        return embed_credentials_in_url(url, "x-token-auth", token);
67    }
68
69    match provider {
70        "" => {
71            let mut parsed = Url::parse(url).context("parsing URL")?;
72            parsed
73                .set_username(token)
74                .map_err(|()| anyhow::anyhow!("failed to set the token as the user"))?;
75            parsed
76                .set_password(None)
77                .map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
78            Ok(parsed.to_string())
79        }
80        _ => embed_credentials_in_url(url, "oauth2", token),
81    }
82}
83
84pub fn embed_credentials_in_url(raw_url: &str, user: &str, password: &str) -> Result<String> {
85    let mut url = Url::parse(raw_url).context("parsing URL")?;
86    url.set_username(user)
87        .map_err(|()| anyhow::anyhow!("failed to set username"))?;
88    url.set_password(Some(password))
89        .map_err(|()| anyhow::anyhow!("failed to set password"))?;
90    Ok(url.to_string())
91}
92
93/// Build git config args for HTTPS operations (equivalent to Python's `get_https_git_config_args`).
94#[must_use]
95pub fn build_https_config_args(
96    follow_redirects: bool,
97    is_pat: bool,
98    token: Option<&str>,
99    disable_credential_prompt: bool,
100) -> Vec<String> {
101    let mut args = vec![
102        "-c".to_owned(),
103        "http.sslVerify=false".to_owned(),
104        "-c".to_owned(),
105        format!("http.followRedirects={follow_redirects}"),
106    ];
107    if is_pat {
108        if let Some(t) = token {
109            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
110            args.extend([
111                "-c".to_owned(),
112                format!("http.extraHeader=Authorization: Basic {encoded}"),
113            ]);
114        }
115    }
116    if disable_credential_prompt {
117        args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
118    }
119    args
120}
121
122/// Format URL with credentials embedded (equivalent to Python's `format_url`).
123#[allow(clippy::too_many_arguments)]
124pub fn format_https_url(
125    repo_url: &str,
126    user: Option<&str>,
127    password: Option<&str>,
128    token: Option<&str>,
129    provider: Option<&str>,
130    is_pat: bool,
131) -> Result<String> {
132    let mut parsed = Url::parse(repo_url).context("parsing URL")?;
133    // Re-quote the path
134    let path = parsed.path().to_owned();
135    parsed.set_path(&path);
136
137    if is_pat {
138        return Ok(parsed.to_string());
139    }
140
141    if let Some(t) = token {
142        let user_part = match provider {
143            Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
144            Some("") | None => "",
145            Some(_) => "oauth2",
146        };
147        if user_part.is_empty() {
148            // No provider: use token as username, no password
149            parsed
150                .set_username(t)
151                .map_err(|()| anyhow::anyhow!("set username"))?;
152            parsed
153                .set_password(None)
154                .map_err(|()| anyhow::anyhow!("set password"))?;
155        } else {
156            // Use username/password separately to avoid percent-encoding the colon
157            parsed
158                .set_username(user_part)
159                .map_err(|()| anyhow::anyhow!("set username"))?;
160            parsed
161                .set_password(Some(t))
162                .map_err(|()| anyhow::anyhow!("set password"))?;
163        }
164        return Ok(parsed.to_string());
165    }
166
167    if let (Some(u), Some(p)) = (user, password) {
168        parsed
169            .set_username(u)
170            .map_err(|()| anyhow::anyhow!("set username"))?;
171        parsed
172            .set_password(Some(p))
173            .map_err(|()| anyhow::anyhow!("set password"))?;
174        return Ok(parsed.to_string());
175    }
176
177    Ok(parsed.to_string())
178}
179
180async fn run_git_clone(args: &[String], secrets: &[&str]) -> Result<()> {
181    reason::run(Command::new("git").args(args), "HTTPS", secrets).await
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{embed_token_in_url, format_https_url};
187
188    #[test]
189    fn a_credential_with_no_provider_carries_the_token_with_no_user() {
190        assert_eq!(
191            embed_token_in_url("https://gitlab.com/a/b.git", "tok", "").unwrap(),
192            "https://tok@gitlab.com/a/b.git"
193        );
194    }
195
196    #[test]
197    fn the_provider_names_the_user_even_off_its_own_domain() {
198        assert_eq!(
199            embed_token_in_url("https://git.acme.com/a/b.git", "tok", "BITBUCKET").unwrap(),
200            "https://x-token-auth:tok@git.acme.com/a/b.git"
201        );
202        assert_eq!(
203            embed_token_in_url("https://gitlab.com/a/b.git", "tok", "GITLAB").unwrap(),
204            "https://oauth2:tok@gitlab.com/a/b.git"
205        );
206    }
207
208    #[test]
209    fn the_token_replaces_whatever_the_url_already_carried() {
210        assert_eq!(
211            embed_token_in_url("https://olduser:oldpass@host/a/b.git", "tok", "").unwrap(),
212            "https://tok@host/a/b.git"
213        );
214    }
215
216    #[test]
217    fn the_provider_is_matched_ignoring_the_case() {
218        let url = "https://host/a/b.git";
219
220        assert_eq!(
221            format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
222            format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
223        );
224        assert_eq!(
225            embed_token_in_url(url, "tok", "Bitbucket").unwrap(),
226            embed_token_in_url(url, "tok", "BITBUCKET").unwrap()
227        );
228    }
229
230    #[test]
231    fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
232        let url = "https://gitlab.com/a/b.git";
233        let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
234        assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
235        assert_eq!(
236            format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
237            no_provider
238        );
239    }
240}