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//! TLS surface is intentionally narrow (CA bundle, verify toggle, the
9//! client-cert pair for mTLS). The same URL-prefix machinery is reused
10//! for `http.extraHeader` (multi-value, applied to every request) and
11//! for the LFS-namespace boolean `lfs.<url>.contenttype`. Remaining
12//! surface (`sslCertPasswordProtected`, `cookieFile`, …) is deferred.
13
14use std::path::Path;
15use std::process::Command;
16
17use crate::Error;
18
19#[derive(Debug, Default, Clone)]
20pub struct HttpOptions {
21    /// `http.sslcainfo` — path to a PEM bundle of trusted CAs.
22    pub ssl_ca_info: Option<String>,
23    /// `http.sslVerify` — false flips reqwest into accept-any-cert mode.
24    /// Default is true; this only stores the explicit value.
25    pub ssl_verify: Option<bool>,
26    /// `http.sslCert` — path to a PEM-encoded client certificate (for
27    /// mTLS). Pairs with `ssl_key`.
28    pub ssl_cert: Option<String>,
29    /// `http.sslKey` — path to the matching PEM-encoded private key.
30    pub ssl_key: Option<String>,
31}
32
33impl HttpOptions {
34    /// Resolve options for the given `url`. Reads URL-specific
35    /// `http.<url>.<key>` (longest matching prefix wins), falling back
36    /// to global `http.<key>` when no override is present.
37    pub fn for_url(cwd: &Path, url: &str) -> Result<Self, Error> {
38        let scoped = scoped_keys(cwd, url)?;
39        Ok(Self {
40            ssl_ca_info: scoped
41                .lookup("sslcainfo")
42                .or_else(|| get_global(cwd, "http.sslcainfo").ok().flatten()),
43            ssl_verify: scoped
44                .lookup("sslverify")
45                .or_else(|| get_global(cwd, "http.sslVerify").ok().flatten())
46                .map(|v| parse_bool(&v)),
47            ssl_cert: scoped
48                .lookup("sslcert")
49                .or_else(|| get_global(cwd, "http.sslCert").ok().flatten()),
50            ssl_key: scoped
51                .lookup("sslkey")
52                .or_else(|| get_global(cwd, "http.sslKey").ok().flatten()),
53        })
54    }
55}
56
57/// Per-URL overrides matched by prefix. Stored as a flat list of
58/// `(prefix, key, value)` so a single `git config --get-regexp` call
59/// covers the whole `http.*` namespace.
60struct Scoped(Vec<(String, String, String)>);
61
62impl Scoped {
63    fn lookup(&self, key: &str) -> Option<String> {
64        let key = key.to_ascii_lowercase();
65        // Longest prefix wins. Entries are already sorted by prefix
66        // length descending in `scoped_keys`.
67        self.0
68            .iter()
69            .find(|(_, k, _)| k.to_ascii_lowercase() == key)
70            .map(|(_, _, v)| v.clone())
71    }
72}
73
74fn scoped_keys(cwd: &Path, url: &str) -> Result<Scoped, Error> {
75    let out = Command::new("git")
76        .arg("-C")
77        .arg(cwd)
78        .args([
79            "config",
80            "--includes",
81            "--null",
82            "--get-regexp",
83            r"^http\..+\..+$",
84        ])
85        .output()?;
86    if !out.status.success() {
87        // exit 1 = no matches; treat as empty.
88        return Ok(Scoped(Vec::new()));
89    }
90    let raw = String::from_utf8_lossy(&out.stdout);
91    let mut entries: Vec<(String, String, String)> = Vec::new();
92    for record in raw.split('\0').filter(|s| !s.is_empty()) {
93        // `--null` separates records by NUL and key-from-value by LF.
94        let (key_full, value) = match record.split_once('\n') {
95            Some((k, v)) => (k, v),
96            None => (record, ""),
97        };
98        // key_full looks like `http.<url>.<subkey>`. The middle is
99        // case-sensitive (it's a URL); subkey is canonical-lowercase.
100        let parts: Vec<&str> = key_full.splitn(2, '.').collect();
101        if parts.len() != 2 || parts[0] != "http" {
102            continue;
103        }
104        let rest = parts[1];
105        let last_dot = rest.rfind('.').unwrap_or(rest.len());
106        if last_dot == rest.len() {
107            continue;
108        }
109        let prefix = &rest[..last_dot];
110        let subkey = &rest[last_dot + 1..];
111        if url_matches(prefix, url) {
112            entries.push((prefix.to_owned(), subkey.to_owned(), value.to_owned()));
113        }
114    }
115    // Longest prefix wins.
116    entries.sort_by_key(|e| std::cmp::Reverse(e.0.len()));
117    Ok(Scoped(entries))
118}
119
120fn get_global(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
121    let out = Command::new("git")
122        .arg("-C")
123        .arg(cwd)
124        .args(["config", "--includes", "--get", key])
125        .output()?;
126    match out.status.code() {
127        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
128        Some(1) | Some(128) | Some(129) => Ok(None),
129        _ => Err(Error::Failed(
130            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
131        )),
132    }
133}
134
135/// Read every value of `key` from the merged config view, preserving
136/// config-file order. Used for multi-value keys like `http.extraHeader`
137/// where `git config --add` accumulates values.
138fn get_all_global(cwd: &Path, key: &str) -> Vec<String> {
139    let out = Command::new("git")
140        .arg("-C")
141        .arg(cwd)
142        .args(["config", "--includes", "--get-all", key])
143        .output();
144    match out {
145        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
146            .lines()
147            .map(str::to_owned)
148            .collect(),
149        _ => Vec::new(),
150    }
151}
152
153/// Every `http.<...>.extraHeader` and global `http.extraHeader` value
154/// whose URL part matches `url`, parsed into `(Name, Value)` pairs.
155///
156/// Ordering is "longest URL prefix first, then global" — same
157/// semantics as upstream's `lfshttp/client.go::extraHeaders()`. Lines
158/// without a `:` separator are silently dropped (we can't form a
159/// header from them).
160///
161/// Both the URL portion of the config key and the header name are
162/// case-insensitive; reqwest's `HeaderName::try_from` canonicalizes
163/// the name when the value is set, so `AUTHORIZATION:` and
164/// `Authorization:` end up as the same header.
165pub fn extra_headers_for(cwd: &Path, url: &str) -> Vec<(String, String)> {
166    let mut out: Vec<(String, String)> = Vec::new();
167    if let Ok(scoped) = scoped_keys(cwd, url) {
168        for (_prefix, subkey, value) in &scoped.0 {
169            if !subkey.eq_ignore_ascii_case("extraheader") {
170                continue;
171            }
172            if let Some(pair) = parse_header_line(value) {
173                out.push(pair);
174            }
175        }
176    }
177    for value in get_all_global(cwd, "http.extraHeader") {
178        if let Some(pair) = parse_header_line(&value) {
179            out.push(pair);
180        }
181    }
182    out
183}
184
185/// Split `Name: Value` on the first `:`. Both halves are trimmed. The
186/// name must be non-empty; the value may be empty (some servers expect
187/// a bare `X-Foo:` to clear a previously set header). Returns `None`
188/// for unparseable lines.
189fn parse_header_line(s: &str) -> Option<(String, String)> {
190    let (name, value) = s.split_once(':')?;
191    let name = name.trim();
192    if name.is_empty() {
193        return None;
194    }
195    Some((name.to_owned(), value.trim().to_owned()))
196}
197
198/// Resolve `lfs.<url>.<subkey>` with longest URL-prefix match,
199/// falling back to global `lfs.<subkey>`. Same machinery as
200/// [`HttpOptions::for_url`] but for the `lfs` namespace. `default` is
201/// returned when neither scope has the key set.
202///
203/// Only used today for `lfs.<url>.contenttype` — keep this scoped to
204/// boolean config keys until a non-bool URL-scoped lfs key shows up.
205pub fn lfs_url_bool(cwd: &Path, url: &str, subkey: &str, default: bool) -> bool {
206    let scoped = lfs_scoped_keys(cwd, url).unwrap_or(Scoped(Vec::new()));
207    if let Some(v) = scoped.lookup(subkey) {
208        return parse_bool(&v);
209    }
210    let global_key = format!("lfs.{subkey}");
211    match get_global(cwd, &global_key) {
212        Ok(Some(v)) => parse_bool(&v),
213        _ => default,
214    }
215}
216
217/// Twin of [`scoped_keys`] that walks the `lfs.*` namespace instead
218/// of `http.*`. Same URL-prefix matching + longest-prefix-first sort.
219fn lfs_scoped_keys(cwd: &Path, url: &str) -> Result<Scoped, Error> {
220    let out = Command::new("git")
221        .arg("-C")
222        .arg(cwd)
223        .args([
224            "config",
225            "--includes",
226            "--null",
227            "--get-regexp",
228            r"^lfs\..+\..+$",
229        ])
230        .output()?;
231    if !out.status.success() {
232        return Ok(Scoped(Vec::new()));
233    }
234    let raw = String::from_utf8_lossy(&out.stdout);
235    let mut entries: Vec<(String, String, String)> = Vec::new();
236    for record in raw.split('\0').filter(|s| !s.is_empty()) {
237        let (key_full, value) = match record.split_once('\n') {
238            Some((k, v)) => (k, v),
239            None => (record, ""),
240        };
241        let parts: Vec<&str> = key_full.splitn(2, '.').collect();
242        if parts.len() != 2 || parts[0] != "lfs" {
243            continue;
244        }
245        let rest = parts[1];
246        let Some(last_dot) = rest.rfind('.') else {
247            continue;
248        };
249        let prefix = &rest[..last_dot];
250        let subkey = &rest[last_dot + 1..];
251        if url_matches(prefix, url) {
252            entries.push((prefix.to_owned(), subkey.to_owned(), value.to_owned()));
253        }
254    }
255    entries.sort_by_key(|e| std::cmp::Reverse(e.0.len()));
256    Ok(Scoped(entries))
257}
258
259/// Whether `prefix` (the URL fragment in `http.<url>.*`) matches the
260/// request `url`. Git's rule: the prefix must be a prefix of the URL
261/// up to and including a path/host boundary. We approximate with a
262/// straight prefix check on the lowercased scheme+host, which is
263/// enough for the test fixtures and for typical real-world configs.
264fn url_matches(prefix: &str, url: &str) -> bool {
265    let p = prefix.trim_end_matches('/').to_ascii_lowercase();
266    let u = url.trim_end_matches('/').to_ascii_lowercase();
267    u == p || u.starts_with(&format!("{p}/"))
268}
269
270fn parse_bool(s: &str) -> bool {
271    matches!(
272        s.trim().to_ascii_lowercase().as_str(),
273        "true" | "1" | "yes" | "on"
274    )
275}