Skip to main content

fluidattacks_core_git/clone/
https.rs

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