Skip to main content

fluidattacks_core_git/
remote.rs

1use std::time::Duration;
2use tokio::process::Command;
3
4use super::aws::{self, AwsTempCreds};
5use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
6use crate::call::not_run;
7use crate::clone::https::HttpsAccess;
8use crate::clone::reason::hide;
9use crate::clone::ssh::{drop_scp_scheme, SshKey, ONLY_SSH};
10use crate::clone::{has_http_scheme, redirect_config};
11use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
12
13const LS_REMOTE_TIMEOUT: Duration = Duration::from_secs(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/// The refs a branch names, in the order `git clone --branch` tries them; git
25/// matches any ref ending in `/<branch>`. `HEAD` is the line git lists as `HEAD`.
26fn branch_refs(branch: &str) -> Vec<String> {
27    if branch == "HEAD" {
28        return vec!["HEAD".to_owned()];
29    }
30    vec![
31        format!("refs/heads/{branch}"),
32        format!("refs/tags/{branch}"),
33    ]
34}
35
36fn ls_remote_args(url: &str, flags: &[&str], pattern: &str) -> Vec<String> {
37    let mut args = vec!["ls-remote".to_owned()];
38    args.extend(flags.iter().map(|&flag| flag.to_owned()));
39    args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
40    args
41}
42
43/// What git listed; a refusal keeps the whole stderr, with `secrets` hidden.
44async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<String, GitCallError> {
45    let Ok(output) =
46        tokio::time::timeout(LS_REMOTE_TIMEOUT, command.kill_on_drop(true).output()).await
47    else {
48        return Err(GitCallError::TimedOut {
49            command: "ls-remote",
50            after: LS_REMOTE_TIMEOUT,
51        });
52    };
53
54    let output = output.map_err(|source| not_run("ls-remote", source))?;
55    if !output.status.success() {
56        return Err(GitCallError::Refused {
57            command: "ls-remote",
58            status: output.status,
59            stderr: hide(&String::from_utf8_lossy(&output.stderr), secrets),
60        });
61    }
62    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
63}
64
65/// The commit `branch` names at `url`, empty when the remote has no such
66/// branch or tag. `error` is what git said, with the credential hidden, when
67/// the remote refused or did not answer in time.
68pub async fn ls_remote(
69    url: &str,
70    branch: &str,
71    credentials: Option<&Credentials>,
72    follow_redirects: bool,
73) -> Result<LsRemoteResult, LsRemoteError> {
74    match ask(url, &[], branch, credentials, follow_redirects).await {
75        Ok(listing) => Ok(LsRemoteResult {
76            commit: Some(commit_of(&listing, branch)),
77            error: None,
78        }),
79        Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(LsRemoteResult {
80            commit: None,
81            error: Some(stderr),
82        }),
83        Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(LsRemoteResult {
84            commit: None,
85            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
86        }),
87        Err(error) => Err(error),
88    }
89}
90
91/// The branch the `HEAD` of the remote at `url` names, asked with the same
92/// `credentials` and redirect policy a clone of `url` takes.
93///
94/// `branch` is the bare name, without `refs/heads/`. Neither field is set
95/// when the remote names no branch: an empty repository, a detached `HEAD`,
96/// or a server that does not advertise symbolic refs. `error` is what git
97/// said, with the credential hidden, when the remote could not be asked or
98/// did not answer in time. An `Err` means git could not be run at all, the
99/// credential could not be prepared, or a url with no credential is not http.
100pub async fn remote_head(
101    url: &str,
102    credentials: Option<&Credentials>,
103    follow_redirects: bool,
104) -> Result<RemoteHead, LsRemoteError> {
105    match ask(url, &["--symref"], "HEAD", credentials, follow_redirects).await {
106        Ok(listing) => Ok(RemoteHead {
107            branch: head_of(&listing),
108            error: None,
109        }),
110        Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(RemoteHead {
111            branch: None,
112            error: Some(stderr),
113        }),
114        Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(RemoteHead {
115            branch: None,
116            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
117        }),
118        Err(error) => Err(error),
119    }
120}
121
122async fn ask(
123    url: &str,
124    flags: &[&str],
125    pattern: &str,
126    credentials: Option<&Credentials>,
127    follow_redirects: bool,
128) -> Result<String, LsRemoteError> {
129    match credentials.map(|c| &c.kind) {
130        Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, flags, pattern, key).await,
131        Some(CredentialKind::AwsRole {
132            arn,
133            external_id,
134            via,
135        }) => {
136            let role = AwsRole {
137                arn: arn.clone(),
138                external_id: external_id.clone(),
139                via: via.clone(),
140            };
141            ls_remote_codecommit(url, flags, pattern, &role, follow_redirects).await
142        }
143        kind => ls_remote_https(url, flags, pattern, kind, follow_redirects).await,
144    }
145}
146
147async fn ls_remote_ssh(
148    url: &str,
149    flags: &[&str],
150    pattern: &str,
151    key_b64: &str,
152) -> Result<String, LsRemoteError> {
153    let key = SshKey::write(key_b64)?;
154    let args = ls_remote_args(drop_scp_scheme(url), flags, pattern);
155
156    let mut command = Command::new("git");
157    command
158        .args(&args)
159        .env("GIT_SSH_COMMAND", &key.command)
160        .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
161    Ok(run_ls_remote(&mut command, &[]).await?)
162}
163
164async fn ls_remote_https(
165    url: &str,
166    flags: &[&str],
167    pattern: &str,
168    credential: Option<&CredentialKind>,
169    follow_redirects: bool,
170) -> Result<String, LsRemoteError> {
171    // git takes any url, an scp-like one over ssh with whatever keys this machine has.
172    if credential.is_none() && !has_http_scheme(url) {
173        return Err(HttpsAccessError::NotHttp.into());
174    }
175    let access = HttpsAccess::new(url, credential)?;
176    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
177    args.extend(redirect_config(follow_redirects));
178    args.extend(ls_remote_args(access.url(), flags, pattern));
179
180    let mut command = access.git();
181    command.args(&args);
182    Ok(run_ls_remote(&mut command, access.secret().as_slice()).await?)
183}
184
185async fn ls_remote_codecommit(
186    url: &str,
187    flags: &[&str],
188    pattern: &str,
189    role: &AwsRole,
190    follow_redirects: bool,
191) -> Result<String, LsRemoteError> {
192    let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
193    Ok(ls_remote_with(url, flags, pattern, &creds, follow_redirects).await?)
194}
195
196async fn ls_remote_with(
197    url: &str,
198    flags: &[&str],
199    pattern: &str,
200    creds: &AwsTempCreds,
201    follow_redirects: bool,
202) -> Result<String, GitCallError> {
203    let args = codecommit_ls_remote_args(url, flags, pattern, follow_redirects);
204
205    let mut command = Command::new("git");
206    command
207        .args(&args)
208        .env("AWS_ACCESS_KEY_ID", &creds.access_key)
209        .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
210        .env("AWS_SESSION_TOKEN", &creds.session_token)
211        .env("AWS_DEFAULT_REGION", &creds.region);
212    let secrets = [creds.secret_key.as_str(), creds.session_token.as_str()];
213    run_ls_remote(&mut command, &secrets).await
214}
215
216/// The `git ls-remote` arguments for a `codecommit::` url. git checks the
217/// certificate, since the helper always reaches AWS's own endpoint.
218fn codecommit_ls_remote_args(
219    url: &str,
220    flags: &[&str],
221    pattern: &str,
222    follow_redirects: bool,
223) -> Vec<String> {
224    let mut args = redirect_config(follow_redirects).to_vec();
225    args.extend(ls_remote_args(url, flags, pattern));
226    args
227}
228
229/// The commit a `git ls-remote` listing gives `branch`; empty when it lists none.
230fn commit_of(listing: &str, branch: &str) -> String {
231    let listed = |wanted: &str| {
232        listing.lines().find_map(|line| {
233            let (commit, name) = line.split_once('\t')?;
234            (name == wanted).then_some(commit)
235        })
236    };
237    branch_refs(branch)
238        .iter()
239        .find_map(|wanted| listed(wanted))
240        .unwrap_or_default()
241        .to_owned()
242}
243
244/// The branch a `git ls-remote --symref` listing points `HEAD` at.
245fn head_of(listing: &str) -> Option<String> {
246    listing.lines().find_map(head_branch).map(str::to_owned)
247}
248
249/// The branch a `ref: <target>\tHEAD` line points `HEAD` at; none for any
250/// other line, or for a target outside `refs/heads/`.
251fn head_branch(line: &str) -> Option<&str> {
252    let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
253    if name != "HEAD" {
254        return None;
255    }
256    target
257        .strip_prefix("refs/heads/")
258        .filter(|branch| !branch.is_empty())
259}
260
261#[cfg(test)]
262mod tests {
263    use std::io::{BufRead, BufReader, Write};
264    use std::net::{TcpListener, TcpStream};
265    use std::sync::{Arc, Mutex};
266
267    use base64::Engine;
268
269    use super::{
270        codecommit_ls_remote_args, commit_of, head_of, ls_remote, ls_remote_args, ls_remote_with,
271        redirect_config, remote_head, run_ls_remote, AwsTempCreds,
272    };
273    use crate::clone::https::HttpsAccess;
274    use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
275    use crate::types::{CredentialKind, Credentials};
276    use tokio::process::Command;
277
278    // The path asked for and the Authorization it carried.
279    fn read_request(stream: &TcpStream) -> (String, Option<String>) {
280        let mut path = String::new();
281        let mut authorization = None;
282        let mut reader = BufReader::new(stream);
283        let mut line = String::new();
284        while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
285            let header = line.trim_end();
286            if header.is_empty() {
287                break;
288            }
289            if path.is_empty() {
290                header
291                    .split(' ')
292                    .nth(1)
293                    .unwrap_or_default()
294                    .clone_into(&mut path);
295            }
296            if let Some(value) = header.strip_prefix("Authorization: ") {
297                authorization = Some(value.to_owned());
298            }
299            line.clear();
300        }
301        (path, authorization)
302    }
303
304    fn answer(redirect_to: Option<&str>, path: &str) -> String {
305        redirect_to.map_or_else(
306            || {
307                "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
308                 Content-Length: 0\r\nConnection: close\r\n\r\n"
309                    .to_owned()
310            },
311            |host| {
312                format!(
313                    "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
314                     Content-Length: 0\r\nConnection: close\r\n\r\n"
315                )
316            },
317        )
318    }
319
320    // A host that sends every request to `redirect_to` when given one, and
321    // otherwise turns it down; it keeps the Authorization each request carried.
322    fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
323        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
324        let port = listener.local_addr().expect("addr").port();
325        let seen = Arc::new(Mutex::new(Vec::new()));
326        let record = Arc::clone(&seen);
327        std::thread::spawn(move || {
328            for stream in listener.incoming().flatten() {
329                let (path, authorization) = read_request(&stream);
330                record.lock().expect("lock").push(authorization);
331                let answer = answer(redirect_to.as_deref(), &path);
332                let _ = (&stream).write_all(answer.as_bytes());
333            }
334        });
335        (format!("http://127.0.0.1:{port}"), seen)
336    }
337
338    fn token(token: &str) -> Credentials {
339        Credentials {
340            kind: CredentialKind::Token {
341                token: token.to_owned(),
342                is_pat: false,
343                oauth_type: String::new(),
344            },
345        }
346    }
347
348    fn pat(token: &str) -> Credentials {
349        Credentials {
350            kind: CredentialKind::Token {
351                token: token.to_owned(),
352                is_pat: true,
353                oauth_type: String::new(),
354            },
355        }
356    }
357
358    // A git that writes `stderr` and exits with `code`.
359    fn refusing(stderr: &str, code: i32) -> Command {
360        let mut command = Command::new("sh");
361        command.args([
362            "-c",
363            &format!("printf '%s' \"$0\" >&2; exit {code}"),
364            stderr,
365        ]);
366        command
367    }
368
369    #[test]
370    fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
371        assert_eq!(
372            redirect_config(false),
373            ["-c".to_owned(), "http.followRedirects=false".to_owned()]
374        );
375        assert_eq!(
376            redirect_config(true),
377            ["-c".to_owned(), "http.followRedirects=true".to_owned()]
378        );
379    }
380
381    #[test]
382    fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
383        assert_eq!(commit_of("", "main"), "");
384    }
385
386    #[test]
387    fn a_branch_is_read_by_its_full_name_and_before_a_tag_of_the_same_name() {
388        for (said, commit) in [
389            (
390                "aaa\trefs/heads/feature/main\nbbb\trefs/heads/main\n",
391                "bbb",
392            ),
393            ("ttt\trefs/tags/main\nbbb\trefs/heads/main\n", "bbb"),
394            ("ttt\trefs/tags/main\n", "ttt"),
395            ("aaa\trefs/heads/feature/main\n", ""),
396        ] {
397            assert_eq!(commit_of(said, "main"), commit, "{said:?}");
398        }
399    }
400
401    #[test]
402    fn head_is_read_only_from_the_line_git_lists_as_head() {
403        for (said, commit) in [
404            ("aaa\tHEAD\nbbb\trefs/heads/HEAD\n", "aaa"),
405            ("bbb\trefs/heads/HEAD\nccc\trefs/remotes/origin/HEAD\n", ""),
406        ] {
407            assert_eq!(commit_of(said, "HEAD"), commit, "{said:?}");
408        }
409    }
410
411    #[tokio::test]
412    async fn a_refusal_keeps_the_whole_stderr_with_the_credential_hidden() {
413        let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
414
415        let refused = run_ls_remote(&mut refusing(said, 128), &["tok123"]).await;
416
417        let Err(GitCallError::Refused { stderr, .. }) = refused else {
418            panic!("{refused:?}");
419        };
420        assert_eq!(
421            stderr,
422            "remote: token [REDACTED] is not valid\nfatal: Authentication failed\n"
423        );
424    }
425
426    #[tokio::test]
427    async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
428        for (credentials, login) in [
429            (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
430            (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
431        ] {
432            let (host, seen) = serve(None);
433            let result = ls_remote(
434                &format!("{host}/org/repo.git"),
435                "main",
436                Some(&credentials),
437                false,
438            )
439            .await
440            .unwrap();
441
442            let sent = base64::engine::general_purpose::STANDARD.encode(login);
443            assert!(seen
444                .lock()
445                .unwrap()
446                .contains(&Some(format!("Basic {sent}"))));
447            let error = result.error.unwrap();
448            assert!(error.contains("Authentication failed"), "{error}");
449            assert!(!error.contains("Zm9v"), "{error}");
450        }
451    }
452
453    #[tokio::test]
454    async fn a_redirect_to_another_host_carries_no_credential() {
455        for credentials in [token("tok123"), pat("tok123")] {
456            let (other, other_seen) = serve(None);
457            let other = other.replace("127.0.0.1", "localhost");
458            let (host, _) = serve(Some(other));
459            let result = ls_remote(
460                &format!("{host}/org/repo.git"),
461                "main",
462                Some(&credentials),
463                true,
464            )
465            .await
466            .unwrap();
467
468            assert!(result.error.is_some());
469            let other_seen = other_seen.lock().unwrap().clone();
470            assert!(!other_seen.is_empty());
471            assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
472        }
473    }
474
475    #[tokio::test]
476    async fn a_helper_the_caller_configured_never_sees_the_credential() {
477        let (host, _) = serve(None);
478        let calls = tempfile::NamedTempFile::new().unwrap();
479        let access =
480            HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
481        let mut command = access.git();
482        command
483            .env(
484                "GIT_CONFIG_PARAMETERS",
485                format!(
486                    "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
487                    calls.path().display()
488                ),
489            )
490            .args(["ls-remote", "--", access.url(), "main"]);
491        let refused = run_ls_remote(&mut command, access.secret().as_slice()).await;
492
493        assert!(
494            matches!(refused, Err(GitCallError::Refused { .. })),
495            "{refused:?}"
496        );
497
498        assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
499    }
500
501    #[test]
502    fn a_codecommit_url_lets_git_check_the_certificate() {
503        for flags in [&[][..], &["--symref"]] {
504            let args =
505                codecommit_ls_remote_args("codecommit::us-east-1://repo", flags, "main", false);
506            assert!(!args.contains(&"http.sslVerify=false".to_owned()));
507        }
508    }
509
510    #[tokio::test]
511    async fn a_codecommit_ls_remote_keeps_the_session_out_of_what_git_says() {
512        let tmp = tempfile::tempdir().unwrap();
513        // git names the repository it could not find, so the url carries the session into stderr.
514        let url = format!("{}/a-secret-key/a-token", tmp.path().display());
515        let creds = AwsTempCreds {
516            access_key: "an-access-key".to_owned(),
517            secret_key: "a-secret-key".to_owned(),
518            session_token: "a-token".to_owned(),
519            region: "us-east-1".to_owned(),
520        };
521
522        let answer = ls_remote_with(&url, &[], "main", &creds, false).await;
523
524        let Err(GitCallError::Refused { stderr, .. }) = answer else {
525            panic!("{answer:?}");
526        };
527        assert!(stderr.contains("[REDACTED]/[REDACTED]"), "{stderr}");
528    }
529
530    #[test]
531    fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
532        assert_eq!(
533            ls_remote_args("https://h/r", &["--symref"], "HEAD"),
534            ["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
535        );
536        assert_eq!(
537            ls_remote_args("https://h/r", &[], "main"),
538            ["ls-remote", "--", "https://h/r", "main"]
539        );
540    }
541
542    // A repository at a local path whose HEAD names `branch`, with `commits`
543    // commits on it; git reaches a path the way it reaches a public remote.
544    fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
545        let dir = tempfile::tempdir().unwrap();
546        let git = |args: &[&str]| {
547            let status = std::process::Command::new("git")
548                .arg("-C")
549                .arg(dir.path())
550                .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
551                .args(args)
552                .output()
553                .unwrap()
554                .status;
555            assert!(status.success(), "git {args:?}");
556        };
557        git(&["init", "--quiet", "--initial-branch", branch]);
558        for _ in 0..commits {
559            git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
560        }
561        dir
562    }
563
564    // The file `path` names under `git_dir`, as an http answer; a 404 when there is none.
565    fn file_answer(git_dir: &std::path::Path, path: &str) -> Vec<u8> {
566        let file = path.split('?').next().unwrap_or_default();
567        let Ok(body) = std::fs::read(git_dir.join(file.trim_start_matches('/'))) else {
568            return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
569                .to_vec();
570        };
571        let head = format!(
572            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
573            body.len()
574        );
575        [head.into_bytes(), body].concat()
576    }
577
578    // The files of `repository`'s .git, the way git's dumb http reads them; refs
579    // made after this call stay unseen.
580    fn serve_repository(repository: &std::path::Path) -> String {
581        let git_dir = repository.join(".git");
582        let updated = std::process::Command::new("git")
583            .arg("-C")
584            .arg(&git_dir)
585            .arg("update-server-info")
586            .status()
587            .unwrap();
588        assert!(updated.success());
589        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
590        let port = listener.local_addr().expect("addr").port();
591        std::thread::spawn(move || {
592            for stream in listener.incoming().flatten() {
593                let (path, _) = read_request(&stream);
594                let _ = (&stream).write_all(&file_answer(&git_dir, &path));
595            }
596        });
597        format!("http://127.0.0.1:{port}/")
598    }
599
600    #[test]
601    fn the_branch_head_names_is_read_without_its_prefix() {
602        assert_eq!(
603            head_of("ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n").as_deref(),
604            Some("release/2.0")
605        );
606    }
607
608    #[test]
609    fn a_head_that_names_no_branch_is_no_branch() {
610        for said in [
611            "",
612            "abc123\tHEAD\n",
613            "ref: refs/tags/v1\tHEAD\n",
614            "ref: refs/heads/\tHEAD\n",
615            "ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
616        ] {
617            assert!(head_of(said).is_none(), "{said:?}");
618        }
619    }
620
621    #[tokio::test]
622    async fn a_remote_answers_the_branch_its_head_names() {
623        let remote = repository("trunk", 1);
624        let url = serve_repository(remote.path());
625
626        let result = remote_head(&url, None, false).await.unwrap();
627
628        assert_eq!(result.branch.as_deref(), Some("trunk"));
629        assert!(result.error.is_none());
630    }
631
632    #[tokio::test]
633    async fn an_empty_remote_names_no_branch() {
634        let remote = repository("trunk", 0);
635        let url = serve_repository(remote.path());
636
637        let result = remote_head(&url, None, false).await.unwrap();
638
639        assert!(result.branch.is_none());
640        assert!(result.error.is_none());
641    }
642
643    #[tokio::test]
644    async fn a_branch_is_not_found_by_a_ref_that_only_ends_in_its_name() {
645        let remote = repository("trunk", 1);
646        let moved = std::process::Command::new("git")
647            .arg("-C")
648            .arg(remote.path())
649            .args(["update-ref", "refs/heads/feature/dev", "trunk"])
650            .status()
651            .unwrap();
652        assert!(moved.success());
653        let url = serve_repository(remote.path());
654
655        let found = ls_remote(&url, "trunk", None, false).await.unwrap();
656        let missing = ls_remote(&url, "dev", None, false).await.unwrap();
657
658        assert_eq!(found.commit.unwrap().len(), 40);
659        assert_eq!(missing.commit.as_deref(), Some(""));
660    }
661
662    #[tokio::test]
663    async fn without_a_credential_a_url_that_is_not_http_never_runs_git() {
664        let remote = repository("trunk", 1);
665        let path = remote.path().to_str().unwrap();
666
667        for url in [
668            path,
669            "httpx://127.0.0.1/repo.git",
670            "http:127.0.0.1/repo.git",
671        ] {
672            let answer = ls_remote(url, "trunk", None, false).await;
673
674            assert!(
675                matches!(
676                    answer,
677                    Err(LsRemoteError::Access(HttpsAccessError::NotHttp))
678                ),
679                "{url}: {answer:?}"
680            );
681        }
682    }
683
684    #[tokio::test]
685    async fn a_head_read_that_cannot_ask_says_why_as_before() {
686        // clones logs this text when it cannot read the default branch.
687        let error = remote_head("git@example.com:org/repo.git", None, false)
688            .await
689            .unwrap_err();
690
691        assert_eq!(error.to_string(), "expected an http or https url");
692    }
693
694    #[tokio::test]
695    async fn an_ssh_ls_remote_with_a_nul_in_the_branch_never_runs_git() {
696        let key = Credentials {
697            kind: CredentialKind::Ssh {
698                key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
699            },
700        };
701
702        let nul = ls_remote("git@example.com:org/repo.git", "ma\0in", Some(&key), false).await;
703
704        assert!(matches!(
705            nul,
706            Err(LsRemoteError::Git(GitCallError::NullByte {
707                command: "ls-remote"
708            }))
709        ));
710    }
711
712    #[tokio::test]
713    async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
714        let remote = repository("trunk", 1);
715        let url = remote.path().to_str().unwrap();
716        let key = Credentials {
717            kind: CredentialKind::Ssh {
718                key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
719            },
720        };
721
722        let result = remote_head(url, Some(&key), false).await.unwrap();
723
724        assert!(result.branch.is_none());
725        assert!(result.error.is_some());
726    }
727
728    #[tokio::test]
729    async fn a_head_read_takes_the_token_to_its_host_and_out_of_the_error() {
730        for (credentials, login) in [
731            (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
732            (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
733        ] {
734            let (host, seen) = serve(None);
735            let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
736                .await
737                .unwrap();
738
739            let sent = base64::engine::general_purpose::STANDARD.encode(login);
740            assert!(seen
741                .lock()
742                .unwrap()
743                .contains(&Some(format!("Basic {sent}"))));
744            assert!(result.branch.is_none());
745            let error = result.error.unwrap();
746            assert!(error.contains("Authentication failed"), "{error}");
747            assert!(!error.contains("Zm9v"), "{error}");
748        }
749    }
750
751    #[tokio::test]
752    async fn a_head_read_redirected_to_another_host_carries_no_credential() {
753        for credentials in [token("tok123"), pat("tok123")] {
754            let (other, other_seen) = serve(None);
755            let other = other.replace("127.0.0.1", "localhost");
756            let (host, _) = serve(Some(other));
757            let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), true)
758                .await
759                .unwrap();
760
761            assert!(result.error.is_some());
762            let other_seen = other_seen.lock().unwrap().clone();
763            assert!(!other_seen.is_empty());
764            assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
765        }
766    }
767}