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