Skip to main content

git_lfs_git/
http_options.rs

1//! Read git's `http.<key>` and `http.<url>.<key>` settings.
2//!
3//! Mirrors the small subset upstream consults when wiring TLS into its
4//! HTTP client. URL-specific overrides (`http.<url>.<key>`) are
5//! resolved by longest prefix match against the request URL, the same
6//! way git itself routes them.
7//!
8//! Only a few keys are surfaced for now (CA bundle + verify toggle);
9//! the rest of the surface (`sslCert`, `sslKey`, `sslCertPasswordProtected`,
10//! `cookieFile`, …) is on the deferred list.
11
12use std::path::Path;
13use std::process::Command;
14
15use crate::Error;
16
17#[derive(Debug, Default, Clone)]
18pub struct HttpOptions {
19    /// `http.sslcainfo` — path to a PEM bundle of trusted CAs.
20    pub ssl_ca_info: Option<String>,
21    /// `http.sslVerify` — false flips reqwest into accept-any-cert mode.
22    /// Default is true; this only stores the explicit value.
23    pub ssl_verify: Option<bool>,
24}
25
26impl HttpOptions {
27    /// Resolve options for the given `url`. Reads URL-specific
28    /// `http.<url>.<key>` (longest matching prefix wins), falling back
29    /// to global `http.<key>` when no override is present.
30    pub fn for_url(cwd: &Path, url: &str) -> Result<Self, Error> {
31        let scoped = scoped_keys(cwd, url)?;
32        Ok(Self {
33            ssl_ca_info: scoped
34                .lookup("sslcainfo")
35                .or_else(|| get_global(cwd, "http.sslcainfo").ok().flatten()),
36            ssl_verify: scoped
37                .lookup("sslverify")
38                .or_else(|| get_global(cwd, "http.sslVerify").ok().flatten())
39                .map(|v| parse_bool(&v)),
40        })
41    }
42}
43
44/// Per-URL overrides matched by prefix. Stored as a flat list of
45/// `(prefix, key, value)` so a single `git config --get-regexp` call
46/// covers the whole `http.*` namespace.
47struct Scoped(Vec<(String, String, String)>);
48
49impl Scoped {
50    fn lookup(&self, key: &str) -> Option<String> {
51        let key = key.to_ascii_lowercase();
52        // Longest prefix wins. Entries are already sorted by prefix
53        // length descending in `scoped_keys`.
54        self.0
55            .iter()
56            .find(|(_, k, _)| k.to_ascii_lowercase() == key)
57            .map(|(_, _, v)| v.clone())
58    }
59}
60
61fn scoped_keys(cwd: &Path, url: &str) -> Result<Scoped, Error> {
62    let out = Command::new("git")
63        .arg("-C")
64        .arg(cwd)
65        .args([
66            "config",
67            "--includes",
68            "--null",
69            "--get-regexp",
70            r"^http\..+\..+$",
71        ])
72        .output()?;
73    if !out.status.success() {
74        // exit 1 = no matches; treat as empty.
75        return Ok(Scoped(Vec::new()));
76    }
77    let raw = String::from_utf8_lossy(&out.stdout);
78    let mut entries: Vec<(String, String, String)> = Vec::new();
79    for record in raw.split('\0').filter(|s| !s.is_empty()) {
80        // `--null` separates records by NUL and key-from-value by LF.
81        let (key_full, value) = match record.split_once('\n') {
82            Some((k, v)) => (k, v),
83            None => (record, ""),
84        };
85        // key_full looks like `http.<url>.<subkey>`. The middle is
86        // case-sensitive (it's a URL); subkey is canonical-lowercase.
87        let parts: Vec<&str> = key_full.splitn(2, '.').collect();
88        if parts.len() != 2 || parts[0] != "http" {
89            continue;
90        }
91        let rest = parts[1];
92        let last_dot = rest.rfind('.').unwrap_or(rest.len());
93        if last_dot == rest.len() {
94            continue;
95        }
96        let prefix = &rest[..last_dot];
97        let subkey = &rest[last_dot + 1..];
98        if url_matches(prefix, url) {
99            entries.push((prefix.to_owned(), subkey.to_owned(), value.to_owned()));
100        }
101    }
102    // Longest prefix wins.
103    entries.sort_by_key(|e| std::cmp::Reverse(e.0.len()));
104    Ok(Scoped(entries))
105}
106
107fn get_global(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
108    let out = Command::new("git")
109        .arg("-C")
110        .arg(cwd)
111        .args(["config", "--includes", "--get", key])
112        .output()?;
113    match out.status.code() {
114        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
115        Some(1) | Some(128) | Some(129) => Ok(None),
116        _ => Err(Error::Failed(
117            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
118        )),
119    }
120}
121
122/// Whether `prefix` (the URL fragment in `http.<url>.*`) matches the
123/// request `url`. Git's rule: the prefix must be a prefix of the URL
124/// up to and including a path/host boundary. We approximate with a
125/// straight prefix check on the lowercased scheme+host, which is
126/// enough for the test fixtures and for typical real-world configs.
127fn url_matches(prefix: &str, url: &str) -> bool {
128    let p = prefix.trim_end_matches('/').to_ascii_lowercase();
129    let u = url.trim_end_matches('/').to_ascii_lowercase();
130    u == p || u.starts_with(&format!("{p}/"))
131}
132
133fn parse_bool(s: &str) -> bool {
134    matches!(
135        s.trim().to_ascii_lowercase().as_str(),
136        "true" | "1" | "yes" | "on"
137    )
138}