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