Skip to main content

fluidattacks_core_git/
remote.rs

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