Skip to main content

fluidattacks_core_git/
remote.rs

1use anyhow::{Context, Result};
2use std::process::Output;
3use std::time::Duration;
4use tokio::process::Command;
5
6use super::aws;
7use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
8use crate::clone::https::HttpsAccess;
9use crate::clone::reason::hide;
10use crate::clone::redirect_config;
11use crate::clone::ssh::{drop_scp_scheme, key_file, setup_ssh_env, ONLY_SSH};
12
13const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
14
15/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
16const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
17
18struct AwsRole {
19    arn: String,
20    external_id: String,
21    via: Option<String>,
22}
23
24/// What a `git ls-remote` answered, read from its output or from its running
25/// out of time. `SYMREF` is whether the output it reads is the one git prints
26/// with `--symref`; the answer, not its caller, decides it, so an output is
27/// never read by a parser meant for the other one.
28trait Answer {
29    const SYMREF: bool;
30
31    fn timed_out() -> Self;
32    fn read(output: &Output, secrets: &[&str]) -> Self;
33}
34
35impl Answer for LsRemoteResult {
36    const SYMREF: bool = false;
37
38    fn timed_out() -> Self {
39        Self {
40            commit: None,
41            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
42        }
43    }
44
45    fn read(output: &Output, secrets: &[&str]) -> Self {
46        parse_ls_remote_output(output, secrets)
47    }
48}
49
50impl Answer for RemoteHead {
51    const SYMREF: bool = true;
52
53    fn timed_out() -> Self {
54        Self {
55            branch: None,
56            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
57        }
58    }
59
60    fn read(output: &Output, secrets: &[&str]) -> Self {
61        parse_symref_output(output, secrets)
62    }
63}
64
65fn ls_remote_args(url: &str, pattern: &str, symref: bool) -> Vec<String> {
66    let mut args = vec!["ls-remote".to_owned()];
67    if symref {
68        args.push("--symref".to_owned());
69    }
70    args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
71    args
72}
73
74async fn run_ls_remote<A: Answer>(command: &mut Command, secrets: &[&str]) -> Result<A> {
75    let Ok(output) = tokio::time::timeout(
76        Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
77        command.kill_on_drop(true).output(),
78    )
79    .await
80    else {
81        return Ok(A::timed_out());
82    };
83
84    Ok(A::read(&output.context("running git ls-remote")?, secrets))
85}
86
87pub async fn ls_remote(
88    url: &str,
89    branch: &str,
90    credentials: Option<&Credentials>,
91    follow_redirects: bool,
92) -> Result<LsRemoteResult> {
93    ask(url, branch, credentials, follow_redirects).await
94}
95
96/// The branch the `HEAD` of the remote at `url` names, asked with the same
97/// `credentials` and redirect policy a clone of `url` takes.
98///
99/// `branch` is the bare name, without `refs/heads/`. Neither field is set
100/// when the remote names no branch: an empty repository, a detached `HEAD`,
101/// or a server that does not advertise symbolic refs. `error` is what git
102/// said, with the credential hidden, when the remote could not be asked or
103/// did not answer in time. An `Err` means git could not be run at all, or
104/// the credential could not be prepared.
105pub async fn remote_head(
106    url: &str,
107    credentials: Option<&Credentials>,
108    follow_redirects: bool,
109) -> Result<RemoteHead> {
110    ask(url, "HEAD", credentials, follow_redirects).await
111}
112
113async fn ask<A: Answer>(
114    url: &str,
115    pattern: &str,
116    credentials: Option<&Credentials>,
117    follow_redirects: bool,
118) -> Result<A> {
119    match credentials.map(|c| &c.kind) {
120        Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, pattern, key).await,
121        Some(CredentialKind::AwsRole {
122            arn,
123            external_id,
124            via,
125        }) => {
126            let role = AwsRole {
127                arn: arn.clone(),
128                external_id: external_id.clone(),
129                via: via.clone(),
130            };
131            ls_remote_codecommit(url, pattern, &role, follow_redirects).await
132        }
133        kind => {
134            let access = HttpsAccess::new(url, kind)?;
135            ls_remote_https(&access, pattern, follow_redirects).await
136        }
137    }
138}
139
140async fn ls_remote_ssh<A: Answer>(url: &str, pattern: &str, key_b64: &str) -> Result<A> {
141    let key_file = key_file(key_b64)?;
142    let git_ssh_command = setup_ssh_env(key_file.path()).context("quoting the SSH key path")?;
143    let args = ls_remote_args(drop_scp_scheme(url), pattern, A::SYMREF);
144
145    let mut command = Command::new("git");
146    command
147        .args(&args)
148        .env("GIT_SSH_COMMAND", &git_ssh_command)
149        .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
150    run_ls_remote(&mut command, &[]).await
151}
152
153async fn ls_remote_https<A: Answer>(
154    access: &HttpsAccess,
155    pattern: &str,
156    follow_redirects: bool,
157) -> Result<A> {
158    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
159    args.extend(redirect_config(follow_redirects));
160    args.extend(ls_remote_args(access.url(), pattern, A::SYMREF));
161
162    let mut command = access.git();
163    command.args(&args);
164    run_ls_remote(&mut command, access.secret().as_slice()).await
165}
166
167async fn ls_remote_codecommit<A: Answer>(
168    url: &str,
169    pattern: &str,
170    role: &AwsRole,
171    follow_redirects: bool,
172) -> Result<A> {
173    let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
174
175    let args = codecommit_ls_remote_args(url, pattern, A::SYMREF, follow_redirects);
176
177    let mut command = Command::new("git");
178    command
179        .args(&args)
180        .env("AWS_ACCESS_KEY_ID", &creds.access_key)
181        .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
182        .env("AWS_SESSION_TOKEN", &creds.session_token)
183        .env("AWS_DEFAULT_REGION", &creds.region);
184    run_ls_remote(&mut command, &[]).await
185}
186
187/// The `git ls-remote` arguments for a `codecommit::` url. git checks the
188/// certificate, since the helper always reaches AWS's own endpoint.
189fn codecommit_ls_remote_args(
190    url: &str,
191    pattern: &str,
192    symref: bool,
193    follow_redirects: bool,
194) -> Vec<String> {
195    let mut args = redirect_config(follow_redirects).to_vec();
196    args.extend(ls_remote_args(url, pattern, symref));
197    args
198}
199
200fn parse_ls_remote_output(output: &Output, secrets: &[&str]) -> LsRemoteResult {
201    if !output.status.success() {
202        let stderr = String::from_utf8_lossy(&output.stderr);
203        return LsRemoteResult {
204            commit: None,
205            error: Some(hide(&stderr, secrets)),
206        };
207    }
208
209    let stdout = String::from_utf8_lossy(&output.stdout);
210    let commit = stdout
211        .lines()
212        .next()
213        .and_then(|line| line.split('\t').next())
214        .unwrap_or_default()
215        .trim()
216        .to_owned();
217
218    LsRemoteResult {
219        commit: Some(commit),
220        error: None,
221    }
222}
223
224fn parse_symref_output(output: &Output, secrets: &[&str]) -> RemoteHead {
225    if !output.status.success() {
226        let stderr = String::from_utf8_lossy(&output.stderr);
227        return RemoteHead {
228            branch: None,
229            error: Some(hide(&stderr, secrets)),
230        };
231    }
232
233    let stdout = String::from_utf8_lossy(&output.stdout);
234    RemoteHead {
235        branch: stdout.lines().find_map(head_branch).map(str::to_owned),
236        error: None,
237    }
238}
239
240/// The branch a `ref: <target>\tHEAD` line points `HEAD` at; none for any
241/// other line, or for a target outside `refs/heads/`.
242fn head_branch(line: &str) -> Option<&str> {
243    let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
244    if name != "HEAD" {
245        return None;
246    }
247    target
248        .strip_prefix("refs/heads/")
249        .filter(|branch| !branch.is_empty())
250}
251
252#[cfg(test)]
253mod tests {
254    use std::io::{BufRead, BufReader, Write};
255    use std::net::{TcpListener, TcpStream};
256    use std::sync::{Arc, Mutex};
257
258    use base64::Engine;
259
260    use super::{
261        codecommit_ls_remote_args, ls_remote, ls_remote_args, parse_ls_remote_output,
262        parse_symref_output, redirect_config, remote_head, run_ls_remote,
263    };
264    use crate::clone::https::HttpsAccess;
265    use crate::types::{CredentialKind, Credentials, LsRemoteResult};
266
267    // The path asked for and the Authorization it carried.
268    fn read_request(stream: &TcpStream) -> (String, Option<String>) {
269        let mut path = String::new();
270        let mut authorization = None;
271        let mut reader = BufReader::new(stream);
272        let mut line = String::new();
273        while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
274            let header = line.trim_end();
275            if header.is_empty() {
276                break;
277            }
278            if path.is_empty() {
279                header
280                    .split(' ')
281                    .nth(1)
282                    .unwrap_or_default()
283                    .clone_into(&mut path);
284            }
285            if let Some(value) = header.strip_prefix("Authorization: ") {
286                authorization = Some(value.to_owned());
287            }
288            line.clear();
289        }
290        (path, authorization)
291    }
292
293    fn answer(redirect_to: Option<&str>, path: &str) -> String {
294        redirect_to.map_or_else(
295            || {
296                "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
297                 Content-Length: 0\r\nConnection: close\r\n\r\n"
298                    .to_owned()
299            },
300            |host| {
301                format!(
302                    "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
303                     Content-Length: 0\r\nConnection: close\r\n\r\n"
304                )
305            },
306        )
307    }
308
309    // A host that sends every request to `redirect_to` when given one, and
310    // otherwise turns it down; it keeps the Authorization each request carried.
311    fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
312        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
313        let port = listener.local_addr().expect("addr").port();
314        let seen = Arc::new(Mutex::new(Vec::new()));
315        let record = Arc::clone(&seen);
316        std::thread::spawn(move || {
317            for stream in listener.incoming().flatten() {
318                let (path, authorization) = read_request(&stream);
319                record.lock().expect("lock").push(authorization);
320                let answer = answer(redirect_to.as_deref(), &path);
321                let _ = (&stream).write_all(answer.as_bytes());
322            }
323        });
324        (format!("http://127.0.0.1:{port}"), seen)
325    }
326
327    fn token(token: &str) -> Credentials {
328        Credentials {
329            kind: CredentialKind::Token {
330                token: token.to_owned(),
331                is_pat: false,
332                oauth_type: String::new(),
333            },
334        }
335    }
336
337    fn pat(token: &str) -> Credentials {
338        Credentials {
339            kind: CredentialKind::Token {
340                token: token.to_owned(),
341                is_pat: true,
342                oauth_type: String::new(),
343            },
344        }
345    }
346
347    fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
348        use std::os::unix::process::ExitStatusExt;
349        std::process::Output {
350            status: std::process::ExitStatus::from_raw(code << 8),
351            stdout: stdout.as_bytes().to_vec(),
352            stderr: stderr.as_bytes().to_vec(),
353        }
354    }
355
356    #[test]
357    fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
358        assert_eq!(
359            redirect_config(false),
360            ["-c".to_owned(), "http.followRedirects=false".to_owned()]
361        );
362        assert_eq!(
363            redirect_config(true),
364            ["-c".to_owned(), "http.followRedirects=true".to_owned()]
365        );
366    }
367
368    #[test]
369    fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
370        let result = parse_ls_remote_output(&output(0, "", ""), &[]);
371        assert_eq!(result.commit, Some(String::new()));
372        assert!(result.error.is_none());
373    }
374
375    #[test]
376    fn the_first_ref_is_the_commit() {
377        let result = parse_ls_remote_output(
378            &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
379            &[],
380        );
381        assert_eq!(result.commit.as_deref(), Some("abc123"));
382    }
383
384    #[test]
385    fn a_failure_reports_the_stderr_and_no_commit() {
386        let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
387        assert!(result.commit.is_none());
388        assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
389    }
390
391    #[tokio::test]
392    async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
393        for (credentials, login) in [
394            (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
395            (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
396        ] {
397            let (host, seen) = serve(None);
398            let result = ls_remote(
399                &format!("{host}/org/repo.git"),
400                "main",
401                Some(&credentials),
402                false,
403            )
404            .await
405            .unwrap();
406
407            let sent = base64::engine::general_purpose::STANDARD.encode(login);
408            assert!(seen
409                .lock()
410                .unwrap()
411                .contains(&Some(format!("Basic {sent}"))));
412            let error = result.error.unwrap();
413            assert!(error.contains("Authentication failed"), "{error}");
414            assert!(!error.contains("Zm9v"), "{error}");
415        }
416    }
417
418    #[tokio::test]
419    async fn a_redirect_to_another_host_carries_no_credential() {
420        for credentials in [token("tok123"), pat("tok123")] {
421            let (other, other_seen) = serve(None);
422            let other = other.replace("127.0.0.1", "localhost");
423            let (host, _) = serve(Some(other));
424            let result = ls_remote(
425                &format!("{host}/org/repo.git"),
426                "main",
427                Some(&credentials),
428                true,
429            )
430            .await
431            .unwrap();
432
433            assert!(result.error.is_some());
434            let other_seen = other_seen.lock().unwrap().clone();
435            assert!(!other_seen.is_empty());
436            assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
437        }
438    }
439
440    #[tokio::test]
441    async fn a_helper_the_caller_configured_never_sees_the_credential() {
442        let (host, _) = serve(None);
443        let calls = tempfile::NamedTempFile::new().unwrap();
444        let access =
445            HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
446        let mut command = access.git();
447        command
448            .env(
449                "GIT_CONFIG_PARAMETERS",
450                format!(
451                    "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
452                    calls.path().display()
453                ),
454            )
455            .args(["ls-remote", "--", access.url(), "main"]);
456        run_ls_remote::<LsRemoteResult>(&mut command, access.secret().as_slice())
457            .await
458            .unwrap();
459
460        assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
461    }
462
463    #[test]
464    fn a_failure_hides_the_credential_a_server_echoes() {
465        let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
466        let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
467        assert_eq!(
468            result.error.as_deref(),
469            Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
470        );
471    }
472
473    #[test]
474    fn a_codecommit_url_lets_git_check_the_certificate() {
475        for symref in [false, true] {
476            let args =
477                codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", symref, false);
478            assert!(!args.contains(&"http.sslVerify=false".to_owned()));
479        }
480    }
481
482    #[test]
483    fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
484        assert_eq!(
485            ls_remote_args("https://h/r", "HEAD", true),
486            ["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
487        );
488        assert_eq!(
489            ls_remote_args("https://h/r", "main", false),
490            ["ls-remote", "--", "https://h/r", "main"]
491        );
492    }
493
494    // A repository at a local path whose HEAD names `branch`, with `commits`
495    // commits on it; git reaches a path the way it reaches a public remote.
496    fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
497        let dir = tempfile::tempdir().unwrap();
498        let git = |args: &[&str]| {
499            let status = std::process::Command::new("git")
500                .arg("-C")
501                .arg(dir.path())
502                .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
503                .args(args)
504                .output()
505                .unwrap()
506                .status;
507            assert!(status.success(), "git {args:?}");
508        };
509        git(&["init", "--quiet", "--initial-branch", branch]);
510        for _ in 0..commits {
511            git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
512        }
513        dir
514    }
515
516    #[test]
517    fn the_branch_head_names_is_read_without_its_prefix() {
518        let result = parse_symref_output(
519            &output(0, "ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n", ""),
520            &[],
521        );
522        assert_eq!(result.branch.as_deref(), Some("release/2.0"));
523        assert!(result.error.is_none());
524    }
525
526    #[test]
527    fn a_head_that_names_no_branch_is_no_branch_and_no_error() {
528        for said in [
529            "",
530            "abc123\tHEAD\n",
531            "ref: refs/tags/v1\tHEAD\n",
532            "ref: refs/heads/\tHEAD\n",
533            "ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
534        ] {
535            let result = parse_symref_output(&output(0, said, ""), &[]);
536            assert!(result.branch.is_none(), "{said:?}");
537            assert!(result.error.is_none(), "{said:?}");
538        }
539    }
540
541    #[test]
542    fn a_failed_head_read_reports_the_stderr_with_the_credential_hidden() {
543        let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
544        let result = parse_symref_output(&output(128, "", said), &["tok123"]);
545        assert!(result.branch.is_none());
546        assert_eq!(
547            result.error.as_deref(),
548            Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
549        );
550    }
551
552    #[tokio::test]
553    async fn a_remote_answers_the_branch_its_head_names() {
554        let remote = repository("trunk", 1);
555        let url = remote.path().to_str().unwrap();
556
557        let result = remote_head(url, None, false).await.unwrap();
558
559        assert_eq!(result.branch.as_deref(), Some("trunk"));
560        assert!(result.error.is_none());
561    }
562
563    #[tokio::test]
564    async fn an_empty_remote_names_no_branch() {
565        let remote = repository("trunk", 0);
566        let url = remote.path().to_str().unwrap();
567
568        let result = remote_head(url, None, false).await.unwrap();
569
570        assert!(result.branch.is_none());
571        assert!(result.error.is_none());
572    }
573
574    #[tokio::test]
575    async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
576        let remote = repository("trunk", 1);
577        let url = remote.path().to_str().unwrap();
578        let key = Credentials {
579            kind: CredentialKind::Ssh {
580                key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
581            },
582        };
583
584        let result = remote_head(url, Some(&key), false).await.unwrap();
585
586        assert!(result.branch.is_none());
587        assert!(result.error.is_some());
588    }
589
590    #[tokio::test]
591    async fn a_head_read_takes_the_token_to_its_host_and_out_of_the_error() {
592        for (credentials, login) in [
593            (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
594            (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
595        ] {
596            let (host, seen) = serve(None);
597            let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
598                .await
599                .unwrap();
600
601            let sent = base64::engine::general_purpose::STANDARD.encode(login);
602            assert!(seen
603                .lock()
604                .unwrap()
605                .contains(&Some(format!("Basic {sent}"))));
606            assert!(result.branch.is_none());
607            let error = result.error.unwrap();
608            assert!(error.contains("Authentication failed"), "{error}");
609            assert!(!error.contains("Zm9v"), "{error}");
610        }
611    }
612
613    #[tokio::test]
614    async fn a_head_read_redirected_to_another_host_carries_no_credential() {
615        for credentials in [token("tok123"), pat("tok123")] {
616            let (other, other_seen) = serve(None);
617            let other = other.replace("127.0.0.1", "localhost");
618            let (host, _) = serve(Some(other));
619            let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), true)
620                .await
621                .unwrap();
622
623            assert!(result.error.is_some());
624            let other_seen = other_seen.lock().unwrap().clone();
625            assert!(!other_seen.is_empty());
626            assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
627        }
628    }
629}