Skip to main content

isb_apps/app/
git.rs

1//! Fetching an app's git source on the host, hardened for an untrusted
2//! repository.
3//!
4//! The daemon runs git itself (the checkout is what a build reads), so git
5//! is held to what a fetch and a checkout need:
6//! - no hooks (`core.hooksPath=/dev/null`), no `file://` or `ext::`
7//!   transports, no local paths, no submodules unless the app asks;
8//! - no system or global config, no terminal prompts, a bounded run time,
9//!   and an environment cleared down to what git and ssh need;
10//! - credentials never in argv: a token travels as an `http.extraHeader`
11//!   scoped to the repository's origin, set through `GIT_CONFIG_*` in the
12//!   environment; an SSH key is a 0600 file that exists for one git call,
13//!   with a per-app `known_hosts`.
14
15use std::io::Read;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::time::{Duration, Instant};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, Result};
23
24/// How long one git command may run.
25pub const GIT_TIMEOUT: Duration = Duration::from_secs(600);
26
27/// A git source.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct GitSource {
31    /// `https://host/owner/repo(.git)`, `ssh://git@host/owner/repo`,
32    /// `git@host:owner/repo`, or `git://`/`http://` (unauthenticated only).
33    pub url: String,
34    /// A branch, a tag or a full commit SHA. Default `main`.
35    #[serde(default = "default_ref", rename = "ref")]
36    pub reference: String,
37    /// Build from this subdirectory of the repository.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub subdir: Option<String>,
40    #[serde(default, skip_serializing_if = "GitAuth::is_none")]
41    pub auth: GitAuth,
42    /// Check out submodules too (off by default: they name more remotes).
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub submodules: bool,
45}
46
47fn default_ref() -> String {
48    "main".into()
49}
50
51/// Credentials for a git source, by the name of an org secret holding them.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(untagged)]
54pub enum GitAuth {
55    #[default]
56    None,
57    /// HTTPS with a token (GitHub, GitLab, Gitea personal or deploy
58    /// tokens), sent as basic auth with `username` (default
59    /// `x-access-token`).
60    Token {
61        token_secret: String,
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        username: Option<String>,
64    },
65    /// SSH with a deploy key (an OpenSSH private key).
66    SshKey { ssh_key_secret: String },
67}
68
69impl GitAuth {
70    pub fn is_none(&self) -> bool {
71        matches!(self, GitAuth::None)
72    }
73
74    pub fn secret(&self) -> Option<&str> {
75        match self {
76            GitAuth::None => None,
77            GitAuth::Token { token_secret, .. } => Some(token_secret),
78            GitAuth::SshKey { ssh_key_secret } => Some(ssh_key_secret),
79        }
80    }
81}
82
83/// How a URL reaches its repository.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Transport {
86    Https,
87    /// Plain `http://`: refused with credentials.
88    Http,
89    Ssh,
90    /// `git://`: unauthenticated.
91    Git,
92}
93
94impl GitSource {
95    pub fn validate(&self) -> Result<Transport> {
96        let t = transport(&self.url)?;
97        validate_ref(&self.reference)?;
98        if let Some(s) = &self.subdir {
99            validate_subdir(s)?;
100        }
101        match (&self.auth, t) {
102            (GitAuth::Token { .. }, Transport::Https) => {}
103            (GitAuth::Token { .. }, _) => {
104                return Err(Error::invalid(
105                    "token auth needs an https:// URL (a token is never sent in clear)",
106                ));
107            }
108            (GitAuth::SshKey { .. }, Transport::Ssh) => {}
109            (GitAuth::SshKey { .. }, _) => {
110                return Err(Error::invalid(
111                    "a deploy key needs an SSH URL (ssh://... or git@host:owner/repo)",
112                ));
113            }
114            (GitAuth::None, _) => {}
115        }
116        if let GitAuth::Token {
117            token_secret,
118            username,
119        } = &self.auth
120        {
121            crate::secrets::validate_name(token_secret)?;
122            if let Some(u) = username {
123                if u.is_empty() || u.contains([':', '\n', '\r']) {
124                    return Err(Error::invalid(format!("git username {u:?}")));
125                }
126            }
127        }
128        if let GitAuth::SshKey { ssh_key_secret } = &self.auth {
129            crate::secrets::validate_name(ssh_key_secret)?;
130        }
131        Ok(t)
132    }
133
134    /// Whether a push to `pushed` deploys this source: `refs/heads/<ref>`
135    /// or `refs/tags/<ref>`, a full `refs/...` ref only itself, and never
136    /// for a commit SHA (pinned: pushes never move it).
137    pub fn matches_push(&self, pushed: &str) -> bool {
138        let r = self.reference.as_str();
139        if is_sha(r) {
140            return false;
141        }
142        if r.starts_with("refs/") {
143            return pushed == r;
144        }
145        pushed == format!("refs/heads/{r}") || pushed == format!("refs/tags/{r}")
146    }
147}
148
149pub fn is_sha(r: &str) -> bool {
150    (r.len() == 40 || r.len() == 64) && r.bytes().all(|b| b.is_ascii_hexdigit())
151}
152
153/// The transport of an allowed URL. Local paths, `file://`, `ext::` and
154/// anything that could read as an option are refused.
155pub fn transport(url: &str) -> Result<Transport> {
156    let bad = |why: &str| Err(Error::invalid(format!("git URL {url:?}: {why}")));
157    if url.is_empty() || url.len() > 2048 {
158        return bad("empty or too long");
159    }
160    if url.starts_with('-') || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
161        return bad("must not start with '-' or contain whitespace");
162    }
163    if let Some((scheme, rest)) = url.split_once("://") {
164        let host = rest.split(['/', '?', '#']).next().unwrap_or("");
165        let host = host.rsplit('@').next().unwrap_or("");
166        if host.is_empty() || host.starts_with('-') {
167            return bad("no host");
168        }
169        if rest
170            .split(['/', '?', '#'])
171            .next()
172            .unwrap_or("")
173            .contains('@')
174            && matches!(scheme, "https" | "http")
175        {
176            // Credentials in the URL would land in argv, logs and config.
177            return bad("credentials in the URL; store a token as an org secret instead");
178        }
179        return match scheme {
180            "https" => Ok(Transport::Https),
181            "http" => Ok(Transport::Http),
182            "ssh" | "git+ssh" | "ssh+git" => Ok(Transport::Ssh),
183            "git" => Ok(Transport::Git),
184            _ => bad("only https, http, ssh and git URLs"),
185        };
186    }
187    if url.contains("::") {
188        return bad("transport helpers (ext::, fd::) are not allowed");
189    }
190    // scp-like `user@host:path`: a colon before any slash.
191    match url.split_once(':') {
192        Some((host, path)) if !host.contains('/') && !host.is_empty() && !path.is_empty() => {
193            let h = host.rsplit('@').next().unwrap_or("");
194            if h.is_empty() || h.starts_with('-') {
195                return bad("no host");
196            }
197            Ok(Transport::Ssh)
198        }
199        _ => bad("a local path; only remote repositories can be fetched"),
200    }
201}
202
203/// A branch, tag or SHA, as git's check-ref-format would take it, minus
204/// anything that could read as an option.
205pub fn validate_ref(r: &str) -> Result<()> {
206    let ok = !r.is_empty()
207        && r.len() <= 255
208        && !r.starts_with(['-', '/', '.'])
209        && !r.ends_with(['/', '.'])
210        && !r.ends_with(".lock")
211        && !r.contains("..")
212        && !r.contains("@{")
213        && !r.contains("//")
214        && r.bytes().all(|b| {
215            b > 0x20 && b != 0x7f && !matches!(b, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
216        });
217    if ok {
218        Ok(())
219    } else {
220        Err(Error::invalid(format!("git ref {r:?}")))
221    }
222}
223
224/// A relative path inside the checkout, never escaping it.
225pub fn validate_subdir(s: &str) -> Result<()> {
226    let p = Path::new(s);
227    let ok = !s.is_empty()
228        && !p.is_absolute()
229        && p.components()
230            .all(|c| matches!(c, std::path::Component::Normal(_)));
231    if ok {
232        Ok(())
233    } else {
234        Err(Error::invalid(format!(
235            "subdir {s:?}: a relative path inside the repository"
236        )))
237    }
238}
239
240/// `https://host[:port]/`, the scope a token's header is sent to.
241fn origin(url: &str) -> Option<String> {
242    let rest = url.strip_prefix("https://")?;
243    let host = rest.split(['/', '?', '#']).next()?;
244    Some(format!("https://{host}/"))
245}
246
247/// What a fetch checked out.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct Checkout {
250    pub sha: String,
251    /// The commit's subject line.
252    pub message: String,
253    /// The checked-out tree (the repository's root; the build takes the
254    /// subdir separately).
255    pub dir: PathBuf,
256}
257
258/// Credentials resolved for one fetch.
259pub enum Credentials {
260    None,
261    Token { username: String, token: String },
262    SshKey { private_key: Vec<u8> },
263}
264
265impl Credentials {
266    /// The environment that carries them: `GIT_CONFIG_*` for a token, a
267    /// `GIT_SSH_COMMAND` with a key file under `tmp` for SSH.
268    fn env(&self, url: &str, tmp: &Path, known_hosts: &Path) -> Result<Vec<(String, String)>> {
269        let mut env = Vec::new();
270        match self {
271            Credentials::None => {}
272            Credentials::Token { username, token } => {
273                use base64::Engine;
274                let origin = origin(url)
275                    .ok_or_else(|| Error::invalid("token auth needs an https:// URL"))?;
276                if token.contains(['\n', '\r']) {
277                    return Err(Error::invalid("the git token holds a line break"));
278                }
279                let basic =
280                    base64::engine::general_purpose::STANDARD.encode(format!("{username}:{token}"));
281                env.push(("GIT_CONFIG_COUNT".into(), "1".into()));
282                env.push((
283                    "GIT_CONFIG_KEY_0".into(),
284                    format!("http.{origin}.extraHeader"),
285                ));
286                env.push((
287                    "GIT_CONFIG_VALUE_0".into(),
288                    format!("Authorization: Basic {basic}"),
289                ));
290            }
291            Credentials::SshKey { private_key } => {
292                let key = tmp.join("key");
293                write_private(&key, private_key)?;
294                env.push(("GIT_SSH_COMMAND".into(), ssh_command(&key, known_hosts)));
295            }
296        }
297        Ok(env)
298    }
299}
300
301/// `ssh` with exactly this key and this known_hosts, never prompting.
302pub fn ssh_command(key: &Path, known_hosts: &Path) -> String {
303    format!(
304        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes \
305         -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={} -o ConnectTimeout=30",
306        shell_quote(&key.to_string_lossy()),
307        shell_quote(&known_hosts.to_string_lossy())
308    )
309}
310
311fn shell_quote(s: &str) -> String {
312    format!("'{}'", s.replace('\'', r"'\''"))
313}
314
315fn write_private(p: &Path, data: &[u8]) -> Result<()> {
316    use std::io::Write;
317    use std::os::unix::fs::OpenOptionsExt;
318    let mut f = std::fs::OpenOptions::new()
319        .write(true)
320        .create_new(true)
321        .mode(0o600)
322        .open(p)?;
323    f.write_all(data)?;
324    // OpenSSH refuses a key without a final newline.
325    if !data.ends_with(b"\n") {
326        f.write_all(b"\n")?;
327    }
328    f.sync_all()?;
329    Ok(())
330}
331
332/// A 0700 directory removed (with what is in it) when dropped.
333pub(crate) struct TempDir(PathBuf);
334
335impl TempDir {
336    pub(crate) fn new(parent: &Path) -> Result<TempDir> {
337        std::fs::create_dir_all(parent)?;
338        for _ in 0..16 {
339            let p = parent.join(format!(".tmp-{}", random_hex(8)));
340            match std::fs::create_dir(&p) {
341                Ok(()) => {
342                    use std::os::unix::fs::PermissionsExt;
343                    std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o700))?;
344                    return Ok(TempDir(p));
345                }
346                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
347                Err(e) => return Err(e.into()),
348            }
349        }
350        Err(Error::invalid("cannot make a temporary directory"))
351    }
352    pub(crate) fn path(&self) -> &Path {
353        &self.0
354    }
355}
356
357impl Drop for TempDir {
358    fn drop(&mut self) {
359        let _ = std::fs::remove_dir_all(&self.0);
360    }
361}
362
363pub(crate) fn random_hex(n: usize) -> String {
364    use ring::rand::SecureRandom;
365    let mut b = vec![0u8; n];
366    ring::rand::SystemRandom::new()
367        .fill(&mut b)
368        .expect("the OS random source failed");
369    b.iter().map(|x| format!("{x:02x}")).collect()
370}
371
372/// The fixed part of every git command line.
373fn git_base() -> Vec<String> {
374    [
375        "-c",
376        "core.hooksPath=/dev/null",
377        "-c",
378        "protocol.file.allow=never",
379        "-c",
380        "protocol.ext.allow=never",
381        // An allowlist: anything else (fd, local, a remote helper) is refused.
382        "-c",
383        "protocol.allow=never",
384        "-c",
385        "protocol.https.allow=always",
386        "-c",
387        "protocol.http.allow=always",
388        "-c",
389        "protocol.ssh.allow=always",
390        "-c",
391        "protocol.git.allow=always",
392        "-c",
393        "submodule.recurse=false",
394        "-c",
395        "core.fsmonitor=false",
396        "-c",
397        "credential.helper=",
398        "-c",
399        "advice.detachedHead=false",
400    ]
401    .iter()
402    .map(|s| s.to_string())
403    .collect()
404}
405
406/// Run git in `dir` with a cleared environment plus `env`, bounded by
407/// `timeout`, its output going to `log`.
408fn git(
409    dir: &Path,
410    args: &[&str],
411    env: &[(String, String)],
412    home: &Path,
413    timeout: Duration,
414    log: &mut dyn FnMut(&str),
415) -> Result<String> {
416    let mut cmd = Command::new(std::env::var_os("ISB_GIT_BIN").unwrap_or_else(|| "git".into()));
417    cmd.args(git_base())
418        .args(args)
419        .current_dir(dir)
420        .env_clear()
421        .env("HOME", home)
422        .env("GIT_TERMINAL_PROMPT", "0")
423        .env("GIT_CONFIG_NOSYSTEM", "1")
424        .env("GIT_CONFIG_GLOBAL", "/dev/null")
425        .env("GIT_ASKPASS", "/bin/false")
426        .env("SSH_ASKPASS", "/bin/false")
427        .env("LC_ALL", "C")
428        .stdin(Stdio::null())
429        .stdout(Stdio::piped())
430        .stderr(Stdio::piped());
431    if let Some(p) = std::env::var_os("PATH") {
432        cmd.env("PATH", p);
433    }
434    for (k, v) in env {
435        cmd.env(k, v);
436    }
437    let mut child = cmd
438        .spawn()
439        .map_err(|e| Error::invalid(format!("cannot run git: {e}")))?;
440    let (mut out, mut err) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
441    let rd = std::thread::spawn(move || {
442        let mut b = Vec::new();
443        let _ = out.read_to_end(&mut b);
444        b
445    });
446    let ed = std::thread::spawn(move || {
447        let mut b = Vec::new();
448        let _ = err.read_to_end(&mut b);
449        b
450    });
451    let started = Instant::now();
452    let status = loop {
453        if let Some(s) = child.try_wait()? {
454            break s;
455        }
456        if started.elapsed() > timeout {
457            let _ = child.kill();
458            let _ = child.wait();
459            return Err(Error::invalid(format!(
460                "git {} took longer than {timeout:?}",
461                args.first().unwrap_or(&"")
462            )));
463        }
464        std::thread::sleep(Duration::from_millis(50));
465    };
466    let stdout = String::from_utf8_lossy(&rd.join().unwrap_or_default()).into_owned();
467    let stderr = String::from_utf8_lossy(&ed.join().unwrap_or_default()).into_owned();
468    for l in stderr.lines().filter(|l| !l.trim().is_empty()) {
469        log(&format!("git: {}", l.trim_end()));
470    }
471    if !status.success() {
472        let last = stderr.lines().rev().find(|l| !l.trim().is_empty());
473        return Err(Error::invalid(format!(
474            "git {} failed: {}",
475            args.first().unwrap_or(&""),
476            last.unwrap_or("no output").trim()
477        )));
478    }
479    Ok(stdout)
480}
481
482/// Fetch `src` into `dir` (`<sources>/<app>`) and check out the exact
483/// commit its ref names now.
484#[expect(
485    clippy::too_many_lines,
486    reason = "predates the lint ratchet; split it when next changed"
487)]
488pub fn fetch(
489    src: &GitSource,
490    creds: &Credentials,
491    dir: &Path,
492    log: &mut dyn FnMut(&str),
493) -> Result<Checkout> {
494    src.validate()?;
495    std::fs::create_dir_all(dir)?;
496    let repo = dir.join("repo");
497    let known_hosts = dir.join("known_hosts");
498    let tmp = TempDir::new(dir)?;
499    let home = tmp.path().join("home");
500    std::fs::create_dir(&home)?;
501    let env = creds.env(&src.url, tmp.path(), &known_hosts)?;
502    if !repo.join(".git").is_dir() {
503        let _ = std::fs::remove_dir_all(&repo);
504        std::fs::create_dir_all(&repo)?;
505        git(&repo, &["init", "-q"], &[], &home, GIT_TIMEOUT, log)?;
506    }
507    log(&format!("fetching {} {}", redact(&src.url), src.reference));
508    // `--` keeps the URL and ref from ever reading as options. A branch
509    // name is tried first, then a tag of that name.
510    let refspec = &src.reference;
511    git(
512        &repo,
513        &[
514            "fetch",
515            "--quiet",
516            "--depth=1",
517            "--no-tags",
518            "--no-recurse-submodules",
519            "--no-write-fetch-head",
520            "--",
521            &src.url,
522            &format!("+{refspec}:refs/isb/source"),
523        ],
524        &env,
525        &home,
526        GIT_TIMEOUT,
527        log,
528    )
529    .or_else(|e| {
530        if is_sha(&src.reference) || src.reference.starts_with("refs/") {
531            return Err(e);
532        }
533        // A tag whose name is not also a branch.
534        git(
535            &repo,
536            &[
537                "fetch",
538                "--quiet",
539                "--depth=1",
540                "--no-tags",
541                "--no-recurse-submodules",
542                "--no-write-fetch-head",
543                "--",
544                &src.url,
545                &format!("+refs/tags/{}:refs/isb/source", src.reference),
546            ],
547            &env,
548            &home,
549            GIT_TIMEOUT,
550            log,
551        )
552        .map_err(|_| e)
553    })?;
554    git(
555        &repo,
556        &[
557            "checkout",
558            "--quiet",
559            "--force",
560            "--detach",
561            "refs/isb/source",
562        ],
563        &[],
564        &home,
565        GIT_TIMEOUT,
566        log,
567    )?;
568    git(&repo, &["clean", "-ffdxq"], &[], &home, GIT_TIMEOUT, log)?;
569    if src.submodules {
570        log("updating submodules");
571        git(
572            &repo,
573            &[
574                "submodule",
575                "update",
576                "--init",
577                "--recursive",
578                "--depth=1",
579                "--force",
580            ],
581            &env,
582            &home,
583            GIT_TIMEOUT,
584            log,
585        )?;
586    }
587    let sha = git(&repo, &["rev-parse", "HEAD"], &[], &home, GIT_TIMEOUT, log)?
588        .trim()
589        .to_string();
590    if is_sha(&src.reference) && !sha.eq_ignore_ascii_case(&src.reference) {
591        return Err(Error::invalid(format!(
592            "fetched {sha}, not the pinned {}",
593            src.reference
594        )));
595    }
596    let message = git(
597        &repo,
598        &["log", "-1", "--format=%s", "HEAD"],
599        &[],
600        &home,
601        GIT_TIMEOUT,
602        log,
603    )?
604    .trim()
605    .to_string();
606    if let Some(sub) = &src.subdir {
607        // A real directory inside the checkout, not a symlink out of it.
608        let real = repo
609            .join(sub)
610            .canonicalize()
611            .map_err(|_| Error::invalid(format!("subdir {sub:?} is not in the repository")))?;
612        if !real.starts_with(repo.canonicalize()?) || !real.is_dir() {
613            return Err(Error::invalid(format!(
614                "subdir {sub:?} is not a directory in the repository"
615            )));
616        }
617    }
618    log(&format!("checked out {sha}: {message}"));
619    Ok(Checkout {
620        sha,
621        message,
622        dir: repo,
623    })
624}
625
626/// The URL without any userinfo, for logs.
627pub fn redact(url: &str) -> String {
628    match url.split_once("://") {
629        Some((s, rest)) => {
630            let (auth, tail) = match rest.find('/') {
631                Some(i) => rest.split_at(i),
632                None => (rest, ""),
633            };
634            match auth.rsplit_once('@') {
635                Some((_, host)) if s.starts_with("http") => format!("{s}://{host}{tail}"),
636                _ => url.to_string(),
637            }
638        }
639        None => url.to_string(),
640    }
641}
642
643/// A new ed25519 deploy key: `(private OpenSSH key, public key line)`,
644/// made by `ssh-keygen` in a 0700 directory that is removed afterwards.
645pub fn generate_deploy_key(scratch: &Path, comment: &str) -> Result<(Vec<u8>, String)> {
646    let tmp = TempDir::new(scratch)?;
647    let key = tmp.path().join("key");
648    let out = Command::new("ssh-keygen")
649        .args(["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f"])
650        .arg(&key)
651        .env_clear()
652        .stdin(Stdio::null())
653        .stdout(Stdio::null())
654        .stderr(Stdio::piped())
655        .output()
656        .map_err(|e| Error::invalid(format!("cannot run ssh-keygen: {e}")))?;
657    if !out.status.success() {
658        return Err(Error::invalid(format!(
659            "ssh-keygen: {}",
660            String::from_utf8_lossy(&out.stderr).trim()
661        )));
662    }
663    let private = std::fs::read(&key)?;
664    let public = std::fs::read_to_string(key.with_extension("pub"))?;
665    Ok((private, public.trim().to_string()))
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    fn src(url: &str, auth: GitAuth) -> GitSource {
673        GitSource {
674            url: url.into(),
675            reference: "main".into(),
676            subdir: None,
677            auth,
678            submodules: false,
679        }
680    }
681
682    #[test]
683    fn transports() {
684        assert_eq!(
685            transport("https://github.com/o/r.git").unwrap(),
686            Transport::Https
687        );
688        assert_eq!(transport("http://h:3000/o/r").unwrap(), Transport::Http);
689        assert_eq!(transport("ssh://git@h:22/o/r").unwrap(), Transport::Ssh);
690        assert_eq!(transport("git@github.com:o/r.git").unwrap(), Transport::Ssh);
691        assert_eq!(transport("git://127.0.0.1:9418/r").unwrap(), Transport::Git);
692        for bad in [
693            "",
694            "file:///etc",
695            "/srv/repo.git",
696            "./repo",
697            "../x",
698            "repo",
699            "ext::sh -c touch% /tmp/x",
700            "fd::3",
701            "-uhttps://x/y",
702            "--upload-pack=touch /tmp/x",
703            "https://user:pass@github.com/o/r",
704            "https:///o/r",
705            "ssh://-oProxyCommand=x/r",
706            "-oProxyCommand=x:r",
707            "https://h/o/r with space",
708            "ftp://h/r",
709        ] {
710            assert!(transport(bad).is_err(), "{bad:?} was allowed");
711        }
712    }
713
714    #[test]
715    fn auth_must_fit_the_transport() {
716        let tok = GitAuth::Token {
717            token_secret: "gh_token".into(),
718            username: None,
719        };
720        let key = GitAuth::SshKey {
721            ssh_key_secret: "deploy.key".into(),
722        };
723        assert!(src("https://h/o/r", tok.clone()).validate().is_ok());
724        assert!(src("http://h/o/r", tok.clone()).validate().is_err());
725        assert!(src("git@h:o/r", tok.clone()).validate().is_err());
726        assert!(src("git@h:o/r", key.clone()).validate().is_ok());
727        assert!(src("https://h/o/r", key).validate().is_err());
728        let mut s = src("https://h/o/r", GitAuth::None);
729        s.reference = "--upload-pack=x".into();
730        assert!(s.validate().is_err());
731        s.reference = "feature/x".into();
732        assert!(s.validate().is_ok());
733        s.subdir = Some("../etc".into());
734        assert!(s.validate().is_err());
735        s.subdir = Some("apps/web".into());
736        assert!(s.validate().is_ok());
737        // The serde forms.
738        let a: GitAuth = serde_json::from_value(serde_json::json!({"token_secret": "t"})).unwrap();
739        assert!(matches!(a, GitAuth::Token { .. }));
740        let a: GitAuth =
741            serde_json::from_value(serde_json::json!({"ssh_key_secret": "k"})).unwrap();
742        assert_eq!(a.secret(), Some("k"));
743    }
744
745    #[test]
746    fn token_travels_in_env_scoped_to_origin() {
747        let dir = tempfile::tempdir().unwrap();
748        let c = Credentials::Token {
749            username: "x-access-token".into(),
750            token: "s3cr3t".into(),
751        };
752        let env = c
753            .env(
754                "https://git.example.com:8443/o/r.git",
755                dir.path(),
756                &dir.path().join("kh"),
757            )
758            .unwrap();
759        let m: std::collections::BTreeMap<_, _> = env.into_iter().collect();
760        assert_eq!(
761            m["GIT_CONFIG_KEY_0"],
762            "http.https://git.example.com:8443/.extraHeader"
763        );
764        assert!(m["GIT_CONFIG_VALUE_0"].starts_with("Authorization: Basic "));
765        assert!(!m["GIT_CONFIG_VALUE_0"].contains("s3cr3t"));
766        // Never in argv: the fixed arguments carry no credential.
767        assert!(!git_base().iter().any(|a| a.contains("s3cr3t")));
768    }
769
770    #[test]
771    fn ssh_key_is_a_private_file() {
772        let dir = tempfile::tempdir().unwrap();
773        let c = Credentials::SshKey {
774            private_key:
775                b"-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----"
776                    .to_vec(),
777        };
778        let kh = dir.path().join("known hosts");
779        let env = c.env("git@h:o/r", dir.path(), &kh).unwrap();
780        let cmd = &env[0].1;
781        assert!(cmd.contains("IdentitiesOnly=yes"));
782        assert!(cmd.contains("StrictHostKeyChecking=accept-new"));
783        assert!(cmd.contains("'"), "{cmd}");
784        use std::os::unix::fs::PermissionsExt;
785        let m = std::fs::metadata(dir.path().join("key")).unwrap();
786        assert_eq!(m.permissions().mode() & 0o777, 0o600);
787        assert!(
788            std::fs::read(dir.path().join("key"))
789                .unwrap()
790                .ends_with(b"\n")
791        );
792    }
793
794    #[test]
795    fn push_matching() {
796        let mut s = src("https://h/o/r", GitAuth::None);
797        assert!(s.matches_push("refs/heads/main"));
798        assert!(s.matches_push("refs/tags/main"));
799        assert!(!s.matches_push("refs/heads/dev"));
800        assert!(!s.matches_push("refs/heads/main2"));
801        s.reference = "a".repeat(40);
802        assert!(!s.matches_push("refs/heads/main"));
803        s.reference = "refs/heads/release".into();
804        assert!(s.matches_push("refs/heads/release"));
805        assert!(!s.matches_push("refs/tags/release"));
806    }
807
808    #[test]
809    fn redacts_userinfo() {
810        assert_eq!(redact("https://u:p@h/o/r"), "https://h/o/r");
811        assert_eq!(redact("git@h:o/r"), "git@h:o/r");
812        assert_eq!(redact("ssh://git@h/o/r"), "ssh://git@h/o/r");
813    }
814}