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