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