Skip to main content

git_lfs_git/
config.rs

1//! Git config get/set/unset, scoped to one of git's config files.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::{Mutex, OnceLock};
7
8use crate::Error;
9
10/// Which config file `git config` operates on.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConfigScope {
13    /// `~/.gitconfig` (or `~/.config/git/config`). The default for upstream
14    /// `git lfs install`.
15    Global,
16    /// The current repository's `.git/config`.
17    Local,
18    /// `/etc/gitconfig`. Usually requires root.
19    System,
20}
21
22impl ConfigScope {
23    fn flag(self) -> &'static str {
24        match self {
25            Self::Global => "--global",
26            Self::Local => "--local",
27            Self::System => "--system",
28        }
29    }
30}
31
32/// Read a single config value from the given scope. Returns `Ok(None)` if
33/// the key isn't set, *or* if the scope itself isn't readable here:
34/// `git config --local` exits 128 outside any repo, exits 129 ("only
35/// one config file at a time") when `GIT_CONFIG` is also set, and any
36/// scope exits 128 when env-vars like `GIT_WORK_TREE` point at a
37/// missing path. Treating all of those as "no value" matches upstream's
38/// `cfg.Git.Get(key)` semantics — `git lfs env` distinguishes a
39/// configured value from an unconfigured one, but not between "key not
40/// set" and "scope unreachable."
41pub fn get(cwd: &Path, scope: ConfigScope, key: &str) -> Result<Option<String>, Error> {
42    let out = Command::new("git")
43        .arg("-C")
44        .arg(cwd)
45        .args(["config", "--includes", scope.flag(), "--get", key])
46        .output()?;
47    match out.status.code() {
48        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
49        // exit 1 = key not set; 128 = scope unreachable (no repo / bad
50        // work tree); 129 = "only one config file at a time" when both
51        // `GIT_CONFIG` and a scope flag are in effect. All three are
52        // "no value here" from our perspective.
53        Some(1) | Some(128) | Some(129) => Ok(None),
54        _ => Err(Error::Failed(
55            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
56        )),
57    }
58}
59
60/// Read a single config value from the merged (local → global → system,
61/// plus `GIT_CONFIG` and `include.path` directives) view. Mirrors
62/// upstream's `cfg.Git.Get` — they always read scope-less so git's own
63/// priority + include resolution applies in one shot. Returns `Ok(None)`
64/// for missing keys *or* unreadable config (no repo, bad work tree).
65fn get_any_scope(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
66    let out = Command::new("git")
67        .arg("-C")
68        .arg(cwd)
69        .args(["config", "--includes", "--get", key])
70        .output()?;
71    match out.status.code() {
72        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
73        Some(1) | Some(128) => Ok(None),
74        _ => Err(Error::Failed(
75            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
76        )),
77    }
78}
79
80/// Read a single config value from a specific file (e.g. `.lfsconfig`).
81/// Returns `Ok(None)` if the file doesn't exist or the key isn't set.
82pub fn get_from_file(cwd: &Path, file: &Path, key: &str) -> Result<Option<String>, Error> {
83    if !cwd.join(file).is_file() {
84        // `git config --file` errors loudly on a missing file. The common
85        // case for `.lfsconfig` is "no file" — treat that as "no value".
86        return Ok(None);
87    }
88    let file_arg = format!("--file={}", file.display());
89    let out = Command::new("git")
90        .arg("-C")
91        .arg(cwd)
92        .args(["config", "--includes", &file_arg, "--get", key])
93        .output()?;
94    match out.status.code() {
95        Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
96        Some(1) => Ok(None),
97        _ => Err(Error::Failed(
98            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
99        )),
100    }
101}
102
103/// Look up `key` across `.lfsconfig` (committed; lowest priority) and
104/// the standard git config scopes (local → global → system). Returns the
105/// first match.
106///
107/// Mirrors upstream's effective config: settings written to `.lfsconfig`
108/// at the repo root are visible without overriding anything explicitly
109/// set in the user's git config. `.lfsconfig` reads are filtered through
110/// a safe-key allowlist — settings that aren't URL/access related are
111/// ignored, with a one-shot warning to stderr.
112pub fn get_effective(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
113    if let Some(v) = get_any_scope(cwd, key)? {
114        return Ok(Some(v));
115    }
116    get_from_lfsconfig(cwd, key)
117}
118
119/// Look up `key` in `.lfsconfig`, applying the safe-key allowlist.
120///
121/// The first call per `cwd` per process loads + filters `.lfsconfig`,
122/// caches the result, and emits the `warning: These unsafe '.lfsconfig'
123/// keys were ignored:` line that t-config 9 / upstream's config loader
124/// produces. Subsequent lookups hit the cache.
125pub fn get_from_lfsconfig(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
126    let entries = load_lfsconfig(cwd)?;
127    Ok(entries
128        .get(&fold_key(key))
129        .and_then(|vs| vs.last().cloned()))
130}
131
132/// Set `key = value` in the given scope.
133pub fn set(cwd: &Path, scope: ConfigScope, key: &str, value: &str) -> Result<(), Error> {
134    let out = Command::new("git")
135        .arg("-C")
136        .arg(cwd)
137        .args(["config", scope.flag(), key, value])
138        .output()?;
139    if out.status.success() {
140        Ok(())
141    } else {
142        Err(Error::Failed(
143            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
144        ))
145    }
146}
147
148/// Unset `key` in the given scope. Idempotent: if the key isn't there,
149/// returns `Ok(())` rather than erroring.
150pub fn unset(cwd: &Path, scope: ConfigScope, key: &str) -> Result<(), Error> {
151    let out = Command::new("git")
152        .arg("-C")
153        .arg(cwd)
154        .args(["config", scope.flag(), "--unset", key])
155        .output()?;
156    match out.status.code() {
157        Some(0) => Ok(()),
158        // git config --unset exits 5 when the key isn't set.
159        Some(5) => Ok(()),
160        _ => Err(Error::Failed(
161            String::from_utf8_lossy(&out.stderr).trim().to_owned(),
162        )),
163    }
164}
165
166/// Hardcoded list of `.lfsconfig` keys upstream considers safe outside
167/// of the URL/access pattern rules. Mirrors `safeKeys` in upstream's
168/// `config/git_fetcher.go`. Anything not on this list (and not matching
169/// the remote/extension/access rules) gets stripped from `.lfsconfig`
170/// reads with a warning.
171const SAFE_KEYS: &[&str] = &[
172    "lfs.allowincompletepush",
173    "lfs.fetchexclude",
174    "lfs.fetchinclude",
175    "lfs.gitprotocol",
176    "lfs.locksverify",
177    "lfs.pushurl",
178    "lfs.skipdownloaderrors",
179    "lfs.url",
180];
181
182/// Whether `key` is allowed to come from `.lfsconfig`. Compared against
183/// the lowercase canonical form (sections + final subkey lowercased,
184/// middle preserved — as git itself emits via `--list`).
185fn is_safe_key(key: &str) -> bool {
186    let parts: Vec<&str> = key.split('.').collect();
187
188    // `lfs.extension.<name>.priority` is the only extension knob safe
189    // from `.lfsconfig`; `clean`/`smudge` are intentionally excluded
190    // upstream because they're command-execution surfaces.
191    if parts.len() == 4 && parts[0] == "lfs" && parts[1] == "extension" && parts[3] == "priority" {
192        return true;
193    }
194
195    // `remote.<name>.lfsurl` is the only safe key under `remote.*`.
196    if parts.len() >= 3 && parts[0] == "remote" && *parts.last().unwrap() == "lfsurl" {
197        return true;
198    }
199
200    // Any 3+ part key ending in `.access` — e.g. `lfs.<url>.access` —
201    // is allowed; this is what attaches an auth scheme to a per-URL
202    // override.
203    if parts.len() >= 3 && *parts.last().unwrap() == "access" {
204        return true;
205    }
206
207    SAFE_KEYS.contains(&key)
208}
209
210/// Canonicalize a config key the way git does: lowercase the first and
211/// last components, preserve the middle (which may be a URL or branch
212/// name and is case-sensitive). Used so a stored `lfs.URL` and a lookup
213/// for `lfs.url` resolve to the same entry.
214fn fold_key(key: &str) -> String {
215    let parts: Vec<&str> = key.split('.').collect();
216    if parts.len() < 3 {
217        return key.to_lowercase();
218    }
219    let last = parts.len() - 1;
220    let middle = parts[1..last].join(".");
221    format!(
222        "{}.{}.{}",
223        parts[0].to_lowercase(),
224        middle,
225        parts[last].to_lowercase(),
226    )
227}
228
229type LfsConfigEntries = HashMap<String, Vec<String>>;
230
231/// Process-wide cache of parsed `.lfsconfig` files, keyed by the
232/// canonicalized cwd. We load each `.lfsconfig` at most once per process
233/// — both to avoid the `git config --list` subprocess on every
234/// `get_effective` call, and so the unsafe-key warning fires once.
235static LFSCONFIG_CACHE: OnceLock<Mutex<HashMap<PathBuf, LfsConfigEntries>>> = OnceLock::new();
236
237fn lfsconfig_cache() -> &'static Mutex<HashMap<PathBuf, LfsConfigEntries>> {
238    LFSCONFIG_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
239}
240
241fn load_lfsconfig(cwd: &Path) -> Result<LfsConfigEntries, Error> {
242    // Resolve the repo root so subdirectory invocations still find the
243    // `.lfsconfig` at the top of the work tree. Falls back to `cwd` when
244    // we can't determine a top-level (e.g. bare repos, or callers that
245    // explicitly pass a non-repo path for testing).
246    let root = repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
247    let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
248    if let Some(cached) = lfsconfig_cache().lock().unwrap().get(&canon) {
249        return Ok(cached.clone());
250    }
251
252    // Upstream's lookup chain (`git/config.go::Sources`):
253    //   - non-bare: working tree → `:.lfsconfig` (index) → `HEAD:.lfsconfig`
254    //   - bare:     `HEAD:.lfsconfig` only
255    // First hit wins; failures fall through silently. The index step is
256    // what t-config 2 exercises after `git read-tree` populates the
257    // staged copy without ever touching the working tree.
258    let bare = is_bare(cwd);
259    let mut entries = None;
260    if !bare && root.join(".lfsconfig").is_file() {
261        entries = Some(read_lfsconfig_file(&root)?);
262    }
263    if entries.is_none() && !bare {
264        entries = read_lfsconfig_blob(cwd, ":.lfsconfig")?;
265    }
266    if entries.is_none() {
267        entries = read_lfsconfig_blob(cwd, "HEAD:.lfsconfig")?;
268    }
269    let entries = entries.unwrap_or_default();
270
271    let (safe, ignored) = filter_safe(entries);
272
273    if !ignored.is_empty() {
274        // Match upstream's wording verbatim — t-config 9 greps for the
275        // exact prefix "warning: These unsafe '.lfsconfig' keys were
276        // ignored:" and for each indented key on its own line.
277        eprintln!("warning: These unsafe '.lfsconfig' keys were ignored:");
278        eprintln!();
279        for key in &ignored {
280            eprintln!("  {key}");
281        }
282    }
283
284    lfsconfig_cache()
285        .lock()
286        .unwrap()
287        .insert(canon, safe.clone());
288    Ok(safe)
289}
290
291/// Read an existing working-tree `.lfsconfig` via `git config --file`.
292fn read_lfsconfig_file(root: &Path) -> Result<LfsConfigEntries, Error> {
293    let out = Command::new("git")
294        .arg("-C")
295        .arg(root)
296        .args(["config", "--includes", "--file=.lfsconfig", "--list"])
297        .output()?;
298    if !out.status.success() {
299        return Err(Error::Failed(format!(
300            "git config --file=.lfsconfig --list failed: {}",
301            String::from_utf8_lossy(&out.stderr).trim()
302        )));
303    }
304    Ok(parse_list_output(&out.stdout))
305}
306
307/// Read `.lfsconfig` from a git revision (`:.lfsconfig` for index,
308/// `HEAD:.lfsconfig` for HEAD's tree). Returns `None` when the blob
309/// doesn't exist — git emits exit 128 with an "ambiguous argument" or
310/// "does not exist" error in that case.
311fn read_lfsconfig_blob(cwd: &Path, revision: &str) -> Result<Option<LfsConfigEntries>, Error> {
312    let blob_arg = format!("--blob={revision}");
313    let out = Command::new("git")
314        .arg("-C")
315        .arg(cwd)
316        .args(["config", "--includes", &blob_arg, "--list"])
317        .output()?;
318    match out.status.code() {
319        Some(0) => Ok(Some(parse_list_output(&out.stdout))),
320        // Missing blob ⇒ silent fallback. Both "exit 1" (key not found
321        // when the blob is empty) and "exit 128" (ambiguous arg / no
322        // such ref) land here.
323        _ => Ok(None),
324    }
325}
326
327/// Whether `cwd` is inside a bare repository. Returns `false` for non-
328/// repos too — anything we can't classify is treated as non-bare so
329/// the working-tree lookup still runs.
330fn is_bare(cwd: &Path) -> bool {
331    Command::new("git")
332        .arg("-C")
333        .arg(cwd)
334        .args(["rev-parse", "--is-bare-repository"])
335        .output()
336        .ok()
337        .filter(|o| o.status.success())
338        .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
339}
340
341/// Locate the work-tree root (where `.lfsconfig` lives). Returns `None`
342/// for bare repos (no work tree) or when `cwd` isn't inside a repo at
343/// all. We can't reuse [`crate::path::git_dir`] because that yields the
344/// `.git` directory; `.lfsconfig` lives one level up.
345fn repo_root(cwd: &Path) -> Option<PathBuf> {
346    let out = Command::new("git")
347        .arg("-C")
348        .arg(cwd)
349        .args(["rev-parse", "--show-toplevel"])
350        .output()
351        .ok()?;
352    if !out.status.success() {
353        return None;
354    }
355    let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
356    if s.is_empty() {
357        return None;
358    }
359    Some(PathBuf::from(s))
360}
361
362/// Parse `git config --list` style `key=value` lines. Values can contain
363/// `=` (URLs commonly do), so split on the *first* one only. Keys come
364/// out already case-folded to git's canonical form.
365fn parse_list_output(bytes: &[u8]) -> LfsConfigEntries {
366    let s = String::from_utf8_lossy(bytes);
367    let mut entries: LfsConfigEntries = HashMap::new();
368    for line in s.lines() {
369        if let Some((k, v)) = line.split_once('=') {
370            entries.entry(k.to_owned()).or_default().push(v.to_owned());
371        }
372    }
373    entries
374}
375
376/// Split parsed `.lfsconfig` entries into a (safe-keys map, ignored-key
377/// list). The ignored list is sorted for deterministic warning output.
378fn filter_safe(entries: LfsConfigEntries) -> (LfsConfigEntries, Vec<String>) {
379    let mut safe = LfsConfigEntries::new();
380    let mut ignored = Vec::new();
381    let mut keys: Vec<String> = entries.keys().cloned().collect();
382    keys.sort();
383    for k in keys {
384        let values = entries.get(&k).cloned().unwrap_or_default();
385        if is_safe_key(&k) {
386            safe.insert(k, values);
387        } else {
388            ignored.push(k);
389        }
390    }
391    (safe, ignored)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use tempfile::TempDir;
398
399    fn init_repo() -> TempDir {
400        let tmp = TempDir::new().unwrap();
401        let status = Command::new("git")
402            .args(["init", "--quiet"])
403            .arg(tmp.path())
404            .status()
405            .unwrap();
406        assert!(status.success());
407        tmp
408    }
409
410    #[test]
411    fn get_unset_key_returns_none() {
412        let tmp = init_repo();
413        let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
414        assert_eq!(v, None);
415    }
416
417    #[test]
418    fn set_then_get_round_trips() {
419        let tmp = init_repo();
420        set(
421            tmp.path(),
422            ConfigScope::Local,
423            "filter.lfs.clean",
424            "git-lfs clean -- %f",
425        )
426        .unwrap();
427        let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
428        assert_eq!(v.as_deref(), Some("git-lfs clean -- %f"));
429    }
430
431    #[test]
432    fn unset_removes_key() {
433        let tmp = init_repo();
434        set(
435            tmp.path(),
436            ConfigScope::Local,
437            "filter.lfs.required",
438            "true",
439        )
440        .unwrap();
441        unset(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
442        let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
443        assert_eq!(v, None);
444    }
445
446    #[test]
447    fn unset_missing_key_is_ok() {
448        let tmp = init_repo();
449        unset(tmp.path(), ConfigScope::Local, "never.was.set").unwrap();
450    }
451
452    #[test]
453    fn safe_key_classification() {
454        // From the hardcoded list.
455        assert!(is_safe_key("lfs.url"));
456        assert!(is_safe_key("lfs.fetchinclude"));
457        assert!(is_safe_key("lfs.locksverify"));
458
459        // Per-URL access knob — middle component is a URL.
460        assert!(is_safe_key("lfs.http://example.com/repo.git.access"));
461        assert!(is_safe_key("lfs.https://host.access"));
462
463        // Remote LFS URL override — but only `lfsurl`, no other remote.* keys.
464        assert!(is_safe_key("remote.origin.lfsurl"));
465        assert!(!is_safe_key("remote.origin.url"));
466        assert!(!is_safe_key("remote.origin.pushurl"));
467
468        // Extension priority is safe; clean/smudge are not (they execute commands).
469        assert!(is_safe_key("lfs.extension.foo.priority"));
470        assert!(!is_safe_key("lfs.extension.foo.clean"));
471        assert!(!is_safe_key("lfs.extension.foo.smudge"));
472
473        // Generic credential / core knobs from .lfsconfig — never safe.
474        assert!(!is_safe_key("core.askpass"));
475        assert!(!is_safe_key("credential.helper"));
476        assert!(!is_safe_key("lfs.concurrenttransfers"));
477    }
478
479    #[test]
480    fn fold_key_lowercases_first_and_last_only() {
481        assert_eq!(fold_key("LFS.URL"), "lfs.url");
482        assert_eq!(
483            fold_key("LFS.http://Example.com.ACCESS"),
484            "lfs.http://Example.com.access"
485        );
486        assert_eq!(fold_key("Section.Key"), "section.key");
487    }
488
489    #[test]
490    fn parse_list_handles_values_with_equals() {
491        let raw = b"lfs.url=http://example.com/path?x=1\nremote.origin.lfsurl=http://a\n";
492        let parsed = parse_list_output(raw);
493        assert_eq!(
494            parsed["lfs.url"],
495            vec!["http://example.com/path?x=1".to_owned()]
496        );
497        assert_eq!(parsed["remote.origin.lfsurl"], vec!["http://a".to_owned()]);
498    }
499
500    #[test]
501    fn parse_list_collects_repeated_keys_in_order() {
502        let raw = b"url.http://a/.insteadof=alias\nurl.http://b/.insteadof=alias\n";
503        let parsed = parse_list_output(raw);
504        assert_eq!(parsed["url.http://a/.insteadof"], vec!["alias".to_owned()]);
505        assert_eq!(parsed["url.http://b/.insteadof"], vec!["alias".to_owned()]);
506    }
507
508    #[test]
509    fn lfsconfig_falls_back_to_head_blob_when_no_working_tree_file() {
510        // Mirrors t-config 2's "config reads from repository" scenario:
511        // no working-tree `.lfsconfig`, but HEAD has one committed.
512        let tmp = init_repo();
513        let path = tmp.path();
514        // Configure a local identity so commits work without HOME.
515        let _ = Command::new("git")
516            .arg("-C")
517            .arg(path)
518            .args(["config", "user.name", "test"])
519            .status();
520        let _ = Command::new("git")
521            .arg("-C")
522            .arg(path)
523            .args(["config", "user.email", "test@example.com"])
524            .status();
525        std::fs::write(
526            path.join(".lfsconfig"),
527            "[lfs]\n\turl = http://from-head/\n",
528        )
529        .unwrap();
530        let _ = Command::new("git")
531            .arg("-C")
532            .arg(path)
533            .args(["add", ".lfsconfig"])
534            .status();
535        let _ = Command::new("git")
536            .arg("-C")
537            .arg(path)
538            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
539            .status();
540        // Remove the working-tree copy so only HEAD has it.
541        std::fs::remove_file(path.join(".lfsconfig")).unwrap();
542
543        let entries = read_lfsconfig_blob(path, "HEAD:.lfsconfig")
544            .unwrap()
545            .unwrap();
546        assert_eq!(
547            entries.get("lfs.url").and_then(|v| v.last().cloned()),
548            Some("http://from-head/".to_owned())
549        );
550    }
551
552    #[test]
553    fn read_lfsconfig_blob_missing_returns_none() {
554        let tmp = init_repo();
555        // Empty repo: no `:.lfsconfig`, no `HEAD:.lfsconfig`.
556        assert!(
557            read_lfsconfig_blob(tmp.path(), ":.lfsconfig")
558                .unwrap()
559                .is_none()
560        );
561        assert!(
562            read_lfsconfig_blob(tmp.path(), "HEAD:.lfsconfig")
563                .unwrap()
564                .is_none()
565        );
566    }
567
568    #[test]
569    fn filter_safe_partitions_keys() {
570        let mut entries = LfsConfigEntries::new();
571        entries.insert("lfs.url".into(), vec!["http://x".into()]);
572        entries.insert("core.askpass".into(), vec!["unsafe".into()]);
573        entries.insert("lfs.extension.e.priority".into(), vec!["1".into()]);
574        entries.insert("lfs.extension.e.clean".into(), vec!["bad".into()]);
575
576        let (safe, ignored) = filter_safe(entries);
577        assert!(safe.contains_key("lfs.url"));
578        assert!(safe.contains_key("lfs.extension.e.priority"));
579        assert!(!safe.contains_key("core.askpass"));
580        assert!(!safe.contains_key("lfs.extension.e.clean"));
581        // Sorted for deterministic warning output.
582        assert_eq!(ignored, vec!["core.askpass", "lfs.extension.e.clean"]);
583    }
584}