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