Skip to main content

fluidattacks_core_git/
remote.rs

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