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
10const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
11const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
12
13// Answers only `get`, so git has nowhere to store or erase the credential.
14fn env_helper() -> String {
15    format!(
16        "!f() {{ if [ \"$1\" = get ]; then \
17         printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
18    )
19}
20
21/// How git reaches a repo over https, with the credential kept off its command
22/// line and out of the url, so that git has none of it to print back.
23///
24/// `git` hands the credential to the repo's own host and to no other, and
25/// never lets git prompt. `secret` is what is still worth taking out of an
26/// error, because a server may echo what it was sent.
27pub(crate) struct HttpsAccess {
28    pub url: String,
29    secret: Option<String>,
30    config: Vec<(String, String)>,
31    env: Vec<(String, String)>,
32}
33
34impl HttpsAccess {
35    pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self> {
36        match credential {
37            None => Ok(Self::public(url)),
38            Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
39            Some(CredentialKind::Token {
40                token,
41                is_pat,
42                oauth_type,
43            }) => {
44                let (user, password) = token_login(token, oauth_type, *is_pat);
45                Ok(Self {
46                    secret: Some(token.clone()),
47                    ..Self::basic(url, user, password)?
48                })
49            }
50            Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
51                anyhow::bail!("expected an https credential")
52            }
53        }
54    }
55
56    fn public(url: &str) -> Self {
57        Self {
58            url: url.to_owned(),
59            secret: None,
60            config: Vec::new(),
61            env: Vec::new(),
62        }
63    }
64
65    fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self> {
66        if [user, password]
67            .iter()
68            .any(|value| value.contains(['\n', '\r', '\0']))
69        {
70            anyhow::bail!("Invalid credentials: a line break or a nul byte");
71        }
72        let url = bare_url(raw_url)?;
73        Ok(Self {
74            secret: Some(password.to_owned()),
75            config: vec![(
76                format!("credential.{}.helper", url.origin().ascii_serialization()),
77                env_helper(),
78            )],
79            env: vec![
80                (USER_VAR.to_owned(), user.to_owned()),
81                (PASSWORD_VAR.to_owned(), password.to_owned()),
82            ],
83            url: url.to_string(),
84        })
85    }
86
87    /// A git command for this repo, still waiting for its subcommand.
88    pub fn git(&self) -> Command {
89        let mut command = Command::new("git");
90        // The empty helper drops whatever helpers the machine has configured,
91        // so the credential is neither looked up in nor saved to them. Going
92        // on the command line, it comes after any config git inherits.
93        command.args(["-c", "credential.helper="]);
94        for (key, value) in &self.config {
95            command.arg("-c").arg(format!("{key}={value}"));
96        }
97        command
98            .env("GIT_TERMINAL_PROMPT", "0")
99            // Unset, git keeps credentials out of its own traces.
100            .env_remove("GIT_TRACE_REDACT")
101            // Its data dump prints what a server sends back, the Basic header included.
102            .env_remove("GIT_TRACE_CURL")
103            .envs(self.env.iter().cloned());
104        command
105    }
106
107    pub fn secret(&self) -> Option<&str> {
108        self.secret.as_deref()
109    }
110}
111
112fn bare_url(raw_url: &str) -> Result<Url> {
113    let mut url = Url::parse(raw_url).context("parsing URL")?;
114    if !matches!(url.scheme(), "http" | "https") {
115        anyhow::bail!("expected an http or https url");
116    }
117    url.set_username("")
118        .map_err(|()| anyhow::anyhow!("failed to drop the user"))?;
119    url.set_password(None)
120        .map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
121    Ok(url)
122}
123
124// A token with no provider is the user itself, with no password. A PAT takes
125// any user but an empty one, since git sends no credential without a user.
126const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
127    if is_pat {
128        ("pat", token)
129    } else if provider.eq_ignore_ascii_case("BITBUCKET") {
130        ("x-token-auth", token)
131    } else if provider.is_empty() {
132        (token, "")
133    } else {
134        ("oauth2", token)
135    }
136}
137
138pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
139    let kind = &opts
140        .credentials
141        .as_ref()
142        .context("missing credentials")?
143        .kind;
144    if !matches!(kind, CredentialKind::Https { .. }) {
145        anyhow::bail!("expected HTTPS credentials");
146    }
147    run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
148}
149
150pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
151    let kind = &opts
152        .credentials
153        .as_ref()
154        .context("missing credentials")?
155        .kind;
156    if !matches!(kind, CredentialKind::Token { .. }) {
157        anyhow::bail!("expected Token credentials");
158    }
159    run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
160}
161
162pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
163    run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await
164}
165
166/// Build git config args for HTTPS operations (equivalent to Python's `get_https_git_config_args`).
167#[must_use]
168pub fn build_https_config_args(
169    follow_redirects: bool,
170    is_pat: bool,
171    token: Option<&str>,
172    disable_credential_prompt: bool,
173) -> Vec<String> {
174    let mut args = vec![
175        "-c".to_owned(),
176        "http.sslVerify=false".to_owned(),
177        "-c".to_owned(),
178        format!("http.followRedirects={follow_redirects}"),
179    ];
180    if is_pat {
181        if let Some(t) = token {
182            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
183            args.extend([
184                "-c".to_owned(),
185                format!("http.extraHeader=Authorization: Basic {encoded}"),
186            ]);
187        }
188    }
189    if disable_credential_prompt {
190        args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
191    }
192    args
193}
194
195/// Format URL with credentials embedded (equivalent to Python's `format_url`).
196#[allow(clippy::too_many_arguments)]
197pub fn format_https_url(
198    repo_url: &str,
199    user: Option<&str>,
200    password: Option<&str>,
201    token: Option<&str>,
202    provider: Option<&str>,
203    is_pat: bool,
204) -> Result<String> {
205    let mut parsed = Url::parse(repo_url).context("parsing URL")?;
206    // Re-quote the path
207    let path = parsed.path().to_owned();
208    parsed.set_path(&path);
209
210    if is_pat {
211        return Ok(parsed.to_string());
212    }
213
214    if let Some(t) = token {
215        let user_part = match provider {
216            Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
217            Some("") | None => "",
218            Some(_) => "oauth2",
219        };
220        if user_part.is_empty() {
221            // No provider: use token as username, no password
222            parsed
223                .set_username(t)
224                .map_err(|()| anyhow::anyhow!("set username"))?;
225            parsed
226                .set_password(None)
227                .map_err(|()| anyhow::anyhow!("set password"))?;
228        } else {
229            // Use username/password separately to avoid percent-encoding the colon
230            parsed
231                .set_username(user_part)
232                .map_err(|()| anyhow::anyhow!("set username"))?;
233            parsed
234                .set_password(Some(t))
235                .map_err(|()| anyhow::anyhow!("set password"))?;
236        }
237        return Ok(parsed.to_string());
238    }
239
240    if let (Some(u), Some(p)) = (user, password) {
241        parsed
242            .set_username(u)
243            .map_err(|()| anyhow::anyhow!("set username"))?;
244        parsed
245            .set_password(Some(p))
246            .map_err(|()| anyhow::anyhow!("set password"))?;
247        return Ok(parsed.to_string());
248    }
249
250    Ok(parsed.to_string())
251}
252
253async fn run_git_clone(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Result<()> {
254    let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
255    let args = build_clone_args(opts, &access.url, dest, &extra_config);
256    let mut command = access.git();
257    command.args(&args);
258    reason::run(&mut command, "HTTPS", access.secret().as_slice()).await
259}
260
261#[cfg(test)]
262mod tests {
263    use super::{format_https_url, token_login, HttpsAccess};
264    use crate::types::CredentialKind;
265
266    fn token(token: &str, provider: &str) -> CredentialKind {
267        CredentialKind::Token {
268            token: token.to_owned(),
269            is_pat: false,
270            oauth_type: provider.to_owned(),
271        }
272    }
273
274    #[test]
275    fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
276        assert_eq!(token_login("tok", "", false), ("tok", ""));
277    }
278
279    #[test]
280    fn the_provider_names_the_user_even_off_its_own_domain() {
281        assert_eq!(
282            token_login("tok", "BITBUCKET", false),
283            ("x-token-auth", "tok")
284        );
285        assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
286    }
287
288    #[test]
289    fn the_provider_is_matched_ignoring_the_case() {
290        let url = "https://host/a/b.git";
291
292        assert_eq!(
293            format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
294            format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
295        );
296        assert_eq!(
297            token_login("tok", "Bitbucket", false),
298            token_login("tok", "BITBUCKET", false)
299        );
300    }
301
302    #[test]
303    fn the_url_drops_whatever_credential_it_carried() {
304        let access = HttpsAccess::new(
305            "https://olduser:oldpass@host/a/b.git",
306            Some(&token("tok", "")),
307        )
308        .unwrap();
309        assert_eq!(access.url, "https://host/a/b.git");
310    }
311
312    #[test]
313    fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
314        assert_eq!(token_login("tok", "", true), ("pat", "tok"));
315        assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
316    }
317
318    #[test]
319    fn a_curl_trace_the_caller_set_is_dropped() {
320        let access = HttpsAccess::new("https://host/a/b.git", Some(&token("tok", ""))).unwrap();
321        assert!(access
322            .git()
323            .as_std()
324            .get_envs()
325            .any(|(name, value)| name == "GIT_TRACE_CURL" && value.is_none()));
326    }
327
328    #[test]
329    fn a_credential_with_a_line_break_is_refused() {
330        for tok in ["tok\nhost=x", "tok\rhost=x"] {
331            assert!(HttpsAccess::new("https://host/a/b.git", Some(&token(tok, ""))).is_err());
332        }
333    }
334
335    #[test]
336    fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
337        let url = "https://gitlab.com/a/b.git";
338        let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
339        assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
340        assert_eq!(
341            format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
342            no_provider
343        );
344    }
345}