Skip to main content

fluidattacks_core_git/clone/
https.rs

1use anyhow::{Context, Result};
2use base64::Engine;
3use http::uri::{PathAndQuery, Uri};
4use std::fmt;
5use std::path::Path;
6use tokio::process::Command;
7use url::Url;
8
9use super::{build_clone_args, reason, redirect_config, stall_config, CloneOpts};
10use crate::errors::{GitCallError, HttpsAccessError};
11use crate::types::CredentialKind;
12
13const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
14const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
15
16// Answers only `get`, so git has nowhere to store or erase the credential.
17fn env_helper() -> String {
18    format!(
19        "!f() {{ if [ \"$1\" = get ]; then \
20         printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
21    )
22}
23
24// Read as git and curl read it (RFC 3986), host and path left as written: git
25// normalizes the url and the helper key alike when it matches one to the other.
26struct BareUrl {
27    url: String,
28    origin: String,
29}
30
31/// How git reaches a repo over https, with the credential kept off its command
32/// line and out of the url, so that git has none of it to print back.
33///
34/// `git` hands the credential to the repo's own host and to no other, and
35/// never lets git prompt. `secret` is what is still worth taking out of an
36/// error, because a server may echo what it was sent.
37pub struct HttpsAccess {
38    url: String,
39    secret: Option<String>,
40    config: Vec<(String, String)>,
41    env: Vec<(String, String)>,
42}
43
44// By hand, so that printing it, in a log or a failed unwrap, leaves the login out.
45impl fmt::Debug for HttpsAccess {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.debug_struct("HttpsAccess")
48            .field("url", &self.url)
49            .finish_non_exhaustive()
50    }
51}
52
53impl HttpsAccess {
54    pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self, HttpsAccessError> {
55        match credential {
56            None => Ok(Self::public(url)),
57            Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
58            Some(CredentialKind::Token {
59                token,
60                is_pat,
61                oauth_type,
62            }) => {
63                let (user, password) = token_login(token, oauth_type, *is_pat);
64                Ok(Self {
65                    secret: Some(token.clone()),
66                    ..Self::basic(url, user, password)?
67                })
68            }
69            Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
70                Err(HttpsAccessError::NotHttpsCredential)
71            }
72        }
73    }
74
75    fn public(url: &str) -> Self {
76        Self {
77            url: url.to_owned(),
78            secret: None,
79            config: Vec::new(),
80            env: Vec::new(),
81        }
82    }
83
84    fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self, HttpsAccessError> {
85        if [user, password]
86            .iter()
87            .any(|value| value.contains(['\n', '\r', '\0']))
88        {
89            return Err(HttpsAccessError::CredentialLineBreak);
90        }
91        let url = bare_url(raw_url)?;
92        Ok(Self {
93            secret: Some(password.to_owned()),
94            config: vec![(format!("credential.{}.helper", url.origin), env_helper())],
95            env: vec![
96                (USER_VAR.to_owned(), user.to_owned()),
97                (PASSWORD_VAR.to_owned(), password.to_owned()),
98            ],
99            url: url.url,
100        })
101    }
102
103    #[must_use]
104    pub fn url(&self) -> &str {
105        &self.url
106    }
107
108    /// A git command for this repo, still waiting for its subcommand.
109    #[must_use]
110    pub fn git(&self) -> Command {
111        let mut command = Command::new("git");
112        // The empty helper drops whatever helpers the machine has configured,
113        // so the credential is neither looked up in nor saved to them. Going
114        // on the command line, it comes after any config git inherits.
115        command.args(["-c", "credential.helper="]);
116        for (key, value) in &self.config {
117            command.arg("-c").arg(format!("{key}={value}"));
118        }
119        command
120            .env("GIT_TERMINAL_PROMPT", "0")
121            // Unset, git keeps credentials out of its own traces.
122            .env_remove("GIT_TRACE_REDACT")
123            // Its data dump prints what a server sends back, the Basic header included.
124            .env_remove("GIT_TRACE_CURL")
125            .envs(self.env.iter().cloned());
126        command
127    }
128
129    #[must_use]
130    pub fn secret(&self) -> Option<&str> {
131        self.secret.as_deref()
132    }
133}
134
135fn bare_url(raw_url: &str) -> Result<BareUrl, HttpsAccessError> {
136    let uri: Uri = raw_url.parse().map_err(|_| HttpsAccessError::Unparsable)?;
137    let scheme = uri
138        .scheme_str()
139        .filter(|scheme| matches!(*scheme, "http" | "https"))
140        .ok_or(HttpsAccessError::NotHttp)?;
141    let authority = uri.authority().ok_or(HttpsAccessError::Unparsable)?;
142    let port = authority
143        .port()
144        .map_or_else(String::new, |port| format!(":{port}"));
145    let host = format!("{}{port}", authority.host());
146    // An empty host, or a port the parser could not read and left out.
147    if authority.host().is_empty() || !authority.as_str().ends_with(&host) {
148        return Err(HttpsAccessError::Unparsable);
149    }
150    let origin = format!("{scheme}://{host}");
151    let path = uri.path_and_query().map_or("", PathAndQuery::as_str);
152    Ok(BareUrl {
153        url: format!("{origin}{path}"),
154        origin,
155    })
156}
157
158// A token with no provider is the user itself, with no password. A PAT takes
159// any user but an empty one, since git sends no credential without a user.
160const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
161    if is_pat {
162        ("pat", token)
163    } else if provider.eq_ignore_ascii_case("BITBUCKET") {
164        ("x-token-auth", token)
165    } else if provider.is_empty() {
166        (token, "")
167    } else {
168        ("oauth2", token)
169    }
170}
171
172pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
173    let kind = &opts
174        .credentials
175        .as_ref()
176        .context("missing credentials")?
177        .kind;
178    if !matches!(kind, CredentialKind::Https { .. }) {
179        anyhow::bail!("expected HTTPS credentials");
180    }
181    Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
182}
183
184pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
185    let kind = &opts
186        .credentials
187        .as_ref()
188        .context("missing credentials")?
189        .kind;
190    if !matches!(kind, CredentialKind::Token { .. }) {
191        anyhow::bail!("expected Token credentials");
192    }
193    Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
194}
195
196pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
197    Ok(run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await?)
198}
199
200#[must_use]
201pub fn build_https_config_args(
202    follow_redirects: bool,
203    is_pat: bool,
204    token: Option<&str>,
205    disable_credential_prompt: bool,
206) -> Vec<String> {
207    let mut args = vec![
208        "-c".to_owned(),
209        "http.sslVerify=false".to_owned(),
210        "-c".to_owned(),
211        format!("http.followRedirects={follow_redirects}"),
212    ];
213    if is_pat {
214        if let Some(t) = token {
215            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
216            args.extend([
217                "-c".to_owned(),
218                format!("http.extraHeader=Authorization: Basic {encoded}"),
219            ]);
220        }
221    }
222    if disable_credential_prompt {
223        args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
224    }
225    args
226}
227
228#[allow(clippy::too_many_arguments)]
229pub fn format_https_url(
230    repo_url: &str,
231    user: Option<&str>,
232    password: Option<&str>,
233    token: Option<&str>,
234    provider: Option<&str>,
235    is_pat: bool,
236) -> Result<String> {
237    let mut parsed = Url::parse(repo_url).context("parsing URL")?;
238    // Re-quote the path
239    let path = parsed.path().to_owned();
240    parsed.set_path(&path);
241
242    if is_pat {
243        return Ok(parsed.to_string());
244    }
245
246    if let Some(t) = token {
247        let user_part = match provider {
248            Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
249            Some("") | None => "",
250            Some(_) => "oauth2",
251        };
252        if user_part.is_empty() {
253            // No provider: use token as username, no password
254            parsed
255                .set_username(t)
256                .map_err(|()| anyhow::anyhow!("set username"))?;
257            parsed
258                .set_password(None)
259                .map_err(|()| anyhow::anyhow!("set password"))?;
260        } else {
261            // Use username/password separately to avoid percent-encoding the colon
262            parsed
263                .set_username(user_part)
264                .map_err(|()| anyhow::anyhow!("set username"))?;
265            parsed
266                .set_password(Some(t))
267                .map_err(|()| anyhow::anyhow!("set password"))?;
268        }
269        return Ok(parsed.to_string());
270    }
271
272    if let (Some(u), Some(p)) = (user, password) {
273        parsed
274            .set_username(u)
275            .map_err(|()| anyhow::anyhow!("set username"))?;
276        parsed
277            .set_password(Some(p))
278            .map_err(|()| anyhow::anyhow!("set password"))?;
279        return Ok(parsed.to_string());
280    }
281
282    Ok(parsed.to_string())
283}
284
285pub(super) async fn run_git_clone(
286    opts: &CloneOpts,
287    dest: &Path,
288    access: &HttpsAccess,
289) -> Result<(), GitCallError> {
290    let mut command = access.git();
291    command.args(clone_args(opts, dest, access));
292    reason::run(&mut command, access.secret().as_slice()).await
293}
294
295fn clone_args(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Vec<std::ffi::OsString> {
296    let mut extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
297    extra_config.extend(redirect_config(opts.follow_redirects));
298    extra_config.extend(stall_config());
299    build_clone_args(opts, access.url(), dest, &extra_config)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::{clone_args, format_https_url, token_login, HttpsAccess};
305    use crate::clone::tests::opts;
306    use crate::clone::{CloneOpts, CloneShape};
307    use crate::errors::HttpsAccessError;
308    use crate::types::CredentialKind;
309    use std::ffi::OsString;
310
311    fn args(access: &HttpsAccess) -> Vec<String> {
312        access
313            .git()
314            .as_std()
315            .get_args()
316            .map(|arg| arg.to_string_lossy().into_owned())
317            .collect()
318    }
319
320    fn token(token: &str, provider: &str) -> CredentialKind {
321        CredentialKind::Token {
322            token: token.to_owned(),
323            is_pat: false,
324            oauth_type: provider.to_owned(),
325        }
326    }
327
328    #[test]
329    fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
330        assert_eq!(token_login("tok", "", false), ("tok", ""));
331    }
332
333    #[test]
334    fn the_provider_names_the_user_even_off_its_own_domain() {
335        assert_eq!(
336            token_login("tok", "BITBUCKET", false),
337            ("x-token-auth", "tok")
338        );
339        assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
340    }
341
342    #[test]
343    fn the_provider_is_matched_ignoring_the_case() {
344        let url = "https://host/a/b.git";
345
346        assert_eq!(
347            format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
348            format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
349        );
350        assert_eq!(
351            token_login("tok", "Bitbucket", false),
352            token_login("tok", "BITBUCKET", false)
353        );
354    }
355
356    fn basic(user: &str, password: &str) -> CredentialKind {
357        CredentialKind::Https {
358            user: user.to_owned(),
359            password: password.to_owned(),
360        }
361    }
362
363    #[test]
364    fn the_url_drops_whatever_credential_it_carried() {
365        let access = HttpsAccess::new(
366            "https://olduser:oldpass@host/a/b.git",
367            Some(&token("tok", "")),
368        )
369        .unwrap();
370        assert_eq!(access.url(), "https://host/a/b.git");
371    }
372
373    #[test]
374    fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
375        assert_eq!(token_login("tok", "", true), ("pat", "tok"));
376        assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
377    }
378
379    #[test]
380    fn the_host_is_left_as_written_for_git_to_read() {
381        for (url, expected) in [
382            ("https://Host:443/a.git", "https://Host:443/a.git"),
383            ("https://[::1]:8443/a.git", "https://[::1]:8443/a.git"),
384            ("https://u:p@w@host/a.git", "https://host/a.git"),
385        ] {
386            let access = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap();
387            let origin = expected.trim_end_matches("/a.git");
388            assert_eq!(access.url(), expected, "{url}");
389            let helper = format!("credential.{origin}.helper=");
390            let host = args(&access).get(3).cloned().unwrap_or_default();
391            assert!(host.starts_with(&helper), "{url}");
392        }
393    }
394
395    #[test]
396    fn each_failure_says_what_the_python_said_and_never_the_url() {
397        for (url, expected) in [
398            ("ftp://u:hunter2@host/a.git", HttpsAccessError::NotHttp),
399            ("host/a.git", HttpsAccessError::Unparsable),
400            ("https:u:hunter2@host/a.git", HttpsAccessError::Unparsable),
401            ("https:///host/a.git", HttpsAccessError::Unparsable),
402            ("https://u:hunter2@/a.git", HttpsAccessError::Unparsable),
403            ("https://u:hunter2@:443/a.git", HttpsAccessError::Unparsable),
404            (
405                "https://u:hunter2@host:99999/a.git",
406                HttpsAccessError::Unparsable,
407            ),
408            (
409                "https://u:hunter2@faß.de/a.git",
410                HttpsAccessError::Unparsable,
411            ),
412            (
413                "https://tok\\hunter2@host/a.git",
414                HttpsAccessError::Unparsable,
415            ),
416        ] {
417            let error = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap_err();
418            assert_eq!(error, expected, "{url}");
419            assert!(!error.to_string().contains("hunter2"), "{url}");
420        }
421        assert_eq!(
422            HttpsAccessError::NotHttp.to_string(),
423            "expected an http or https url"
424        );
425        assert_eq!(
426            HttpsAccessError::Unparsable.to_string(),
427            "Invalid url: its host or port could not be parsed"
428        );
429    }
430
431    #[test]
432    fn no_credential_leaves_the_url_unchecked() {
433        let access = HttpsAccess::new("ftp://host:99999/a.git", None).unwrap();
434        assert_eq!(access.url(), "ftp://host:99999/a.git");
435        assert_eq!(args(&access), ["-c", "credential.helper="]);
436    }
437
438    #[test]
439    fn the_debug_shows_neither_the_login_nor_the_environment() {
440        let access =
441            HttpsAccess::new("https://host/a.git", Some(&basic("someone", "s3cr3t"))).unwrap();
442        let shown = format!("{access:?}");
443        assert!(shown.contains("https://host/a.git"), "{shown}");
444        assert!(!shown.contains("s3cr3t"), "{shown}");
445        assert!(!shown.contains("someone"), "{shown}");
446    }
447
448    #[test]
449    fn the_machine_helpers_are_dropped_before_the_host_helper() {
450        let access = HttpsAccess::new("https://host/a.git", Some(&token("tok", ""))).unwrap();
451        let args = args(&access);
452        let [flag, machine, host_flag, host] = args.as_slice() else {
453            panic!("{args:?}");
454        };
455        assert_eq!(
456            [flag, machine, host_flag],
457            ["-c", "credential.helper=", "-c"]
458        );
459        assert!(
460            host.starts_with("credential.https://host.helper=!f()"),
461            "{host}"
462        );
463    }
464
465    #[test]
466    fn the_login_goes_through_the_environment() {
467        let access = HttpsAccess::new("https://host/a.git", Some(&basic("u", "p"))).unwrap();
468        let envs: Vec<_> = access
469            .git()
470            .as_std()
471            .get_envs()
472            .map(|(name, value)| (name.to_owned(), value.map(ToOwned::to_owned)))
473            .collect();
474        let set = |value: &str| Some(OsString::from(value));
475        assert_eq!(
476            envs,
477            [
478                ("FLUIDATTACKS_GIT_PASSWORD".into(), set("p")),
479                ("FLUIDATTACKS_GIT_USER".into(), set("u")),
480                ("GIT_TERMINAL_PROMPT".into(), set("0")),
481                ("GIT_TRACE_CURL".into(), None),
482                ("GIT_TRACE_REDACT".into(), None),
483            ]
484        );
485    }
486
487    #[test]
488    fn a_credential_with_a_line_break_or_a_nul_is_refused() {
489        for credential in [
490            token("tok\nhost=x", ""),
491            token("tok\0x", "GITLAB"),
492            basic("u\rx", "p"),
493            basic("u", "p\nx"),
494        ] {
495            let error = HttpsAccess::new("https://host/a/b.git", Some(&credential)).unwrap_err();
496            assert_eq!(error, HttpsAccessError::CredentialLineBreak);
497            assert_eq!(
498                error.to_string(),
499                "Invalid credentials: a line break or a nul byte"
500            );
501        }
502    }
503
504    #[test]
505    fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
506        let url = "https://gitlab.com/a/b.git";
507        let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
508        assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
509        assert_eq!(
510            format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
511            no_provider
512        );
513    }
514
515    #[test]
516    fn a_redirect_is_followed_only_when_the_root_asks_for_one() {
517        for follow_redirects in [false, true] {
518            let opts = CloneOpts {
519                follow_redirects,
520                ..opts(CloneShape::SingleBranch)
521            };
522            let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
523
524            let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
525
526            let config = format!("http.followRedirects={follow_redirects}");
527            assert!(args.windows(2).any(|pair| pair == ["-c", config.as_str()]));
528        }
529    }
530
531    #[test]
532    fn a_clone_that_stops_receiving_data_is_cut_after_ten_minutes() {
533        let opts = opts(CloneShape::SingleBranch);
534        let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
535
536        let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
537
538        assert!(args
539            .windows(2)
540            .any(|pair| pair == ["-c", "http.lowSpeedLimit=1000"]));
541        assert!(args
542            .windows(2)
543            .any(|pair| pair == ["-c", "http.lowSpeedTime=600"]));
544    }
545}