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