Skip to main content

git_lfs_git/
endpoint.rs

1//! Resolve the LFS server endpoint for a repo.
2//!
3//! Implements the priority chain documented in
4//! `docs/api/server-discovery.md`, plus the SSH/git URL → HTTPS rewriting
5//! upstream does so a `git@github.com:foo/bar.git` remote yields the
6//! expected `https://github.com/foo/bar.git/info/lfs` endpoint.
7//!
8//! # Priority order
9//!
10//! 1. `GIT_LFS_URL` environment variable (matches upstream's escape hatch).
11//! 2. `lfs.url` from git config — local → global → system → `.lfsconfig`.
12//! 3. `remote.<name>.lfsurl` (same scopes as above).
13//! 4. `remote.<name>.url` rewritten via [`derive_lfs_url`].
14//!
15//! `<name>` defaults to `origin` when the caller hasn't passed a remote.
16//!
17//! # SSH-style URLs
18//!
19//! `git lfs` itself only speaks HTTP(S); for SSH remotes the *protocol* is
20//! still HTTPS, just inferred from the remote's host/path. Upstream also
21//! supports the `git-lfs-authenticate` SSH command for handing back a
22//! token; that's deferred (see NOTES.md).
23
24use std::path::Path;
25
26use crate::Error;
27use crate::config::{self, ConfigScope};
28
29const DEFAULT_REMOTE: &str = "origin";
30
31#[derive(Debug, thiserror::Error)]
32pub enum EndpointError {
33    #[error(transparent)]
34    Git(#[from] Error),
35    #[error("no LFS endpoint could be determined for remote {0:?}")]
36    Unresolved(String),
37    #[error("invalid remote URL {url:?}: {reason}")]
38    InvalidUrl { url: String, reason: String },
39}
40
41/// Resolve the LFS endpoint URL for `cwd` + `remote`. Pass `None` for the
42/// default (`origin`, with a "single remote" fallback when origin doesn't
43/// exist and exactly one other remote does).
44pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
45    let caller_specified_remote = remote.is_some();
46    let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
47
48    if let Some(v) = std::env::var_os("GIT_LFS_URL") {
49        let s = v.to_string_lossy().into_owned();
50        if !s.is_empty() {
51            return Ok(s);
52        }
53    }
54
55    if let Some(v) = config::get_effective(cwd, "lfs.url")? {
56        return Ok(v);
57    }
58
59    // When the caller didn't pin a remote name and `origin` doesn't
60    // exist, fall back to the only configured remote. Mirrors
61    // upstream's `git remote` discovery in `lfsfetch` and is what
62    // `t-fetch.sh::fetch with no origin remote` exercises (rename
63    // origin → something, then bare `git lfs fetch`).
64    if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
65        let remotes = list_remotes(cwd)?;
66        if remotes.len() == 1 {
67            remote = remotes.into_iter().next().expect("len==1");
68        }
69    }
70
71    let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
72    if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
73        return Ok(v);
74    }
75
76    if let Some(remote_url) = remote_url(cwd, &remote)? {
77        return derive_lfs_url(&remote_url);
78    }
79
80    // Last fallback: the caller may have passed a URL directly in
81    // place of a remote name (e.g. `git lfs push https://host/repo`).
82    // Treat anything that looks URL-shaped as the remote URL and run
83    // it through the same rewriter — same outcome as if they'd added
84    // a `remote.x.url = <URL>` entry first. Bare-SSH (`git@host:path`)
85    // also covers the SCP-style case the rewriter understands.
86    if looks_like_url(&remote) {
87        return derive_lfs_url(&remote);
88    }
89
90    Err(EndpointError::Unresolved(remote))
91}
92
93/// `git remote` enumeration. Returns the configured remote names in
94/// definition order. Used by [`endpoint_for_remote`] to fall back from
95/// `origin` to the "only remote" when one exists.
96fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
97    let out = std::process::Command::new("git")
98        .arg("-C")
99        .arg(cwd)
100        .args(["remote"])
101        .output()
102        .map_err(Error::Io)?;
103    if !out.status.success() {
104        return Ok(Vec::new());
105    }
106    Ok(String::from_utf8_lossy(&out.stdout)
107        .lines()
108        .filter(|l| !l.is_empty())
109        .map(str::to_owned)
110        .collect())
111}
112
113/// Quick syntactic check: does `s` look like one of the URL forms
114/// [`derive_lfs_url`] recognizes? Used to decide whether to treat a
115/// "remote name" argument as a literal URL.
116fn looks_like_url(s: &str) -> bool {
117    s.starts_with("http://")
118        || s.starts_with("https://")
119        || s.starts_with("ssh://")
120        || s.starts_with("git+ssh://")
121        || s.starts_with("ssh+git://")
122        || s.starts_with("git://")
123        || s.starts_with("file://")
124        || s.contains("://")
125        || s.contains('@')
126}
127
128/// Read `remote.<name>.url` from the standard git config scopes.
129///
130/// We don't currently honor `remote.<name>.pushurl` separately — that's a
131/// minor accuracy issue for `git push`-driven LFS uploads, captured in
132/// NOTES.md.
133fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
134    let key = format!("remote.{remote}.url");
135    if let Some(v) = config::get(cwd, ConfigScope::Local, &key)? {
136        return Ok(Some(v));
137    }
138    if let Some(v) = config::get(cwd, ConfigScope::Global, &key)? {
139        return Ok(Some(v));
140    }
141    config::get(cwd, ConfigScope::System, &key)
142}
143
144/// Convert a clone URL into the matching LFS endpoint URL.
145///
146/// Rules (mirroring upstream's `NewEndpointFromCloneURL`):
147/// - `https://host/path` → `https://host/path.git/info/lfs`
148/// - `https://host/path.git` → `https://host/path.git/info/lfs`
149/// - `ssh://[user@]host[:port]/path` → `https://host/path.git/info/lfs`
150///   (port is dropped — LFS is HTTPS-only at the wire layer)
151/// - `git@host:path` → `https://host/path.git/info/lfs`
152/// - `git://host/path` → `https://host/path.git/info/lfs`
153/// - `file://path` → returned unchanged (used by upstream test infra)
154pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
155    let trimmed = remote_url.trim();
156    if trimmed.is_empty() {
157        return Err(EndpointError::InvalidUrl {
158            url: remote_url.to_owned(),
159            reason: "empty URL".into(),
160        });
161    }
162
163    if let Some(rest) = trimmed.strip_prefix("file://") {
164        // file:// URLs are local — pass through. The transfer/ basic
165        // adapter doesn't speak file:// today, but rewriting it would be
166        // worse than letting it fall over visibly.
167        return Ok(format!("file://{rest}"));
168    }
169
170    // URL schemes we handle by parsing.
171    if let Some(rest) = trimmed.strip_prefix("https://") {
172        return Ok(append_lfs_path(&format!("https://{rest}")));
173    }
174    if let Some(rest) = trimmed.strip_prefix("http://") {
175        return Ok(append_lfs_path(&format!("http://{rest}")));
176    }
177    if let Some(rest) = trimmed.strip_prefix("ssh://") {
178        return ssh_to_https(rest, "ssh://");
179    }
180    if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
181        return ssh_to_https(rest, "git+ssh://");
182    }
183    if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
184        return ssh_to_https(rest, "ssh+git://");
185    }
186    if let Some(rest) = trimmed.strip_prefix("git://") {
187        // `git://` is the bare git protocol — LFS rides on top via HTTPS.
188        return Ok(append_lfs_path(&format!("https://{rest}")));
189    }
190
191    // Bare-SSH form: `[user@]host:path`. Distinguish from a Windows path
192    // (`C:\…`) by requiring the part before `:` to contain a `@` or be a
193    // hostname-shaped token (no backslash, no drive-letter pattern).
194    if let Some((host_part, path)) = bare_ssh_split(trimmed) {
195        let host = host_part.split('@').next_back().unwrap_or(host_part);
196        return Ok(append_lfs_path(&format!(
197            "https://{host}/{}",
198            path.trim_start_matches('/'),
199        )));
200    }
201
202    Err(EndpointError::InvalidUrl {
203        url: remote_url.to_owned(),
204        reason: "unrecognized URL form".into(),
205    })
206}
207
208/// Split `<host>:<path>` if `rawurl` looks like a bare SSH URL. Returns
209/// `None` if it doesn't (e.g. a plain filesystem path like `/foo/bar` or
210/// a Windows drive letter `C:\foo`).
211fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
212    // Reject things that look like local paths.
213    if rawurl.starts_with('/') || rawurl.starts_with('.') {
214        return None;
215    }
216    if rawurl.contains('\\') {
217        return None;
218    }
219
220    let (host, path) = rawurl.split_once(':')?;
221    if host.is_empty() || path.is_empty() {
222        return None;
223    }
224    // A single ASCII letter before `:` is almost certainly a Windows
225    // drive letter, not a hostname. `git@C:/foo` would be malformed
226    // anyway.
227    if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
228        return None;
229    }
230    Some((host, path))
231}
232
233/// Convert the post-scheme portion of an `ssh://` URL into the matching
234/// HTTPS endpoint.
235fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
236    let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
237    if authority.is_empty() {
238        return Err(EndpointError::InvalidUrl {
239            url: format!("{scheme_for_error}{rest}"),
240            reason: "missing host".into(),
241        });
242    }
243    // Strip off any `user@` prefix.
244    let host_with_port = authority.split('@').next_back().unwrap_or(authority);
245    // Drop the port: `ssh://host:22/foo` → host portion is just `host`.
246    let host = host_with_port.split(':').next().unwrap_or(host_with_port);
247    Ok(append_lfs_path(&format!(
248        "https://{host}/{}",
249        path.trim_start_matches('/'),
250    )))
251}
252
253/// Append the LFS protocol suffix to an HTTPS URL — `.git/info/lfs` if
254/// the URL doesn't already end in `.git`, just `/info/lfs` if it does.
255/// Trailing slash on the input URL is collapsed first.
256fn append_lfs_path(url: &str) -> String {
257    let trimmed = url.trim_end_matches('/');
258    if trimmed.ends_with(".git") {
259        format!("{trimmed}/info/lfs")
260    } else {
261        format!("{trimmed}.git/info/lfs")
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    // ---- derive_lfs_url ---------------------------------------------------
270
271    #[test]
272    fn https_url_without_dotgit_gets_dotgit_info_lfs() {
273        assert_eq!(
274            derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
275            "https://git-server.com/foo/bar.git/info/lfs",
276        );
277    }
278
279    #[test]
280    fn https_url_with_dotgit_gets_just_info_lfs() {
281        assert_eq!(
282            derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
283            "https://git-server.com/foo/bar.git/info/lfs",
284        );
285    }
286
287    #[test]
288    fn http_url_is_preserved_as_http() {
289        assert_eq!(
290            derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
291            "http://localhost:8080/foo/bar.git/info/lfs",
292        );
293    }
294
295    #[test]
296    fn trailing_slash_is_collapsed() {
297        assert_eq!(
298            derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
299            "https://git-server.com/foo/bar.git/info/lfs",
300        );
301    }
302
303    #[test]
304    fn ssh_url_becomes_https() {
305        assert_eq!(
306            derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
307            "https://git-server.com/foo/bar.git/info/lfs",
308        );
309    }
310
311    #[test]
312    fn ssh_url_strips_user_and_port() {
313        assert_eq!(
314            derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
315            "https://git-server.com/foo/bar.git/info/lfs",
316        );
317    }
318
319    #[test]
320    fn bare_ssh_url_becomes_https() {
321        assert_eq!(
322            derive_lfs_url("git@github.com:user/repo.git").unwrap(),
323            "https://github.com/user/repo.git/info/lfs",
324        );
325    }
326
327    #[test]
328    fn bare_ssh_without_user_becomes_https() {
329        // `host:path/to/repo.git` is a valid bare SSH form.
330        assert_eq!(
331            derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
332            "https://git-server.com/foo/bar.git/info/lfs",
333        );
334    }
335
336    #[test]
337    fn git_protocol_url_becomes_https() {
338        assert_eq!(
339            derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
340            "https://git-server.com/foo/bar.git/info/lfs",
341        );
342    }
343
344    #[test]
345    fn ssh_git_variants_are_recognized() {
346        for prefix in ["git+ssh", "ssh+git"] {
347            let url = format!("{prefix}://git@git-server.com/foo/bar.git");
348            assert_eq!(
349                derive_lfs_url(&url).unwrap(),
350                "https://git-server.com/foo/bar.git/info/lfs",
351            );
352        }
353    }
354
355    #[test]
356    fn file_url_is_passed_through_unchanged() {
357        assert_eq!(
358            derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
359            "file:///srv/repos/foo.git",
360        );
361    }
362
363    #[test]
364    fn empty_url_errors() {
365        assert!(matches!(
366            derive_lfs_url(""),
367            Err(EndpointError::InvalidUrl { .. }),
368        ));
369    }
370
371    #[test]
372    fn windows_path_is_not_misread_as_ssh() {
373        // `C:\repos\foo` would otherwise look like `host:path`, but a
374        // single drive letter is not a valid hostname.
375        assert!(derive_lfs_url("C:\\repos\\foo").is_err());
376    }
377
378    #[test]
379    fn relative_path_is_rejected_not_treated_as_ssh() {
380        assert!(derive_lfs_url("./relative/path").is_err());
381        assert!(derive_lfs_url("/abs/path").is_err());
382    }
383
384    // ---- endpoint_for_remote ---------------------------------------------
385    //
386    // Every test in this section reads `GIT_LFS_URL` indirectly via
387    // `endpoint_for_remote`. cargo runs tests in parallel by default, so we
388    // serialize them through a single mutex to keep one test's env-var
389    // mutation from leaking into another's expectations.
390
391    use std::sync::{Mutex, MutexGuard};
392    use tempfile::TempDir;
393
394    static ENV_LOCK: Mutex<()> = Mutex::new(());
395
396    fn lock_env() -> MutexGuard<'static, ()> {
397        // PoisonError can happen if a previous test panicked while holding
398        // the lock — that's a test bug, but recovering keeps the rest of
399        // the suite useful instead of cascading-failing.
400        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
401    }
402
403    fn fresh_repo() -> TempDir {
404        let tmp = TempDir::new().unwrap();
405        let s = std::process::Command::new("git")
406            .args(["init", "--quiet"])
407            .arg(tmp.path())
408            .status()
409            .unwrap();
410        assert!(s.success());
411        tmp
412    }
413
414    fn git_in(repo: &Path, args: &[&str]) {
415        let s = std::process::Command::new("git")
416            .arg("-C")
417            .arg(repo)
418            .args(args)
419            .status()
420            .unwrap();
421        assert!(s.success(), "git {args:?} failed");
422    }
423
424    #[test]
425    fn endpoint_prefers_explicit_lfs_url() {
426        let _g = lock_env();
427        unsafe { std::env::remove_var("GIT_LFS_URL") };
428        let repo = fresh_repo();
429        git_in(repo.path(), &["config", "--local", "lfs.url", "https://example.com/lfs"]);
430        git_in(
431            repo.path(),
432            &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
433        );
434        let url = endpoint_for_remote(repo.path(), None).unwrap();
435        assert_eq!(url, "https://example.com/lfs");
436    }
437
438    #[test]
439    fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
440        let _g = lock_env();
441        unsafe { std::env::remove_var("GIT_LFS_URL") };
442        let repo = fresh_repo();
443        git_in(
444            repo.path(),
445            &["config", "--local", "remote.origin.lfsurl", "https://lfs.dev/repo"],
446        );
447        git_in(
448            repo.path(),
449            &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
450        );
451        let url = endpoint_for_remote(repo.path(), None).unwrap();
452        assert_eq!(url, "https://lfs.dev/repo");
453    }
454
455    #[test]
456    fn endpoint_derives_from_remote_url() {
457        let _g = lock_env();
458        unsafe { std::env::remove_var("GIT_LFS_URL") };
459        let repo = fresh_repo();
460        git_in(
461            repo.path(),
462            &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
463        );
464        let url = endpoint_for_remote(repo.path(), None).unwrap();
465        assert_eq!(url, "https://github.com/x/y.git/info/lfs");
466    }
467
468    #[test]
469    fn endpoint_uses_named_remote_over_origin() {
470        let _g = lock_env();
471        unsafe { std::env::remove_var("GIT_LFS_URL") };
472        let repo = fresh_repo();
473        git_in(
474            repo.path(),
475            &["config", "--local", "remote.upstream.url", "https://other.example.com/foo"],
476        );
477        let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
478        assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
479    }
480
481    #[test]
482    fn endpoint_reads_lfsconfig_at_repo_root() {
483        let _g = lock_env();
484        unsafe { std::env::remove_var("GIT_LFS_URL") };
485        let repo = fresh_repo();
486        // Write a .lfsconfig file (it's just a git config file).
487        std::fs::write(
488            repo.path().join(".lfsconfig"),
489            "[lfs]\n\turl = https://from-lfsconfig.example/\n",
490        )
491        .unwrap();
492        let url = endpoint_for_remote(repo.path(), None).unwrap();
493        assert_eq!(url, "https://from-lfsconfig.example/");
494    }
495
496    #[test]
497    fn endpoint_local_config_overrides_lfsconfig() {
498        let _g = lock_env();
499        unsafe { std::env::remove_var("GIT_LFS_URL") };
500        let repo = fresh_repo();
501        std::fs::write(
502            repo.path().join(".lfsconfig"),
503            "[lfs]\n\turl = https://from-lfsconfig.example/\n",
504        )
505        .unwrap();
506        git_in(
507            repo.path(),
508            &["config", "--local", "lfs.url", "https://from-localconfig.example/"],
509        );
510        let url = endpoint_for_remote(repo.path(), None).unwrap();
511        assert_eq!(url, "https://from-localconfig.example/");
512    }
513
514    #[test]
515    fn endpoint_unresolved_when_nothing_configured() {
516        let _g = lock_env();
517        unsafe { std::env::remove_var("GIT_LFS_URL") };
518        let repo = fresh_repo();
519        let err = endpoint_for_remote(repo.path(), None).unwrap_err();
520        assert!(matches!(err, EndpointError::Unresolved(_)));
521    }
522
523    #[test]
524    fn endpoint_env_var_wins_over_everything() {
525        let _g = lock_env();
526        let repo = fresh_repo();
527        git_in(repo.path(), &["config", "--local", "lfs.url", "https://lo.cal/lfs"]);
528
529        let prev = std::env::var_os("GIT_LFS_URL");
530        unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
531        let url = endpoint_for_remote(repo.path(), None).unwrap();
532        assert_eq!(url, "https://from-env.example/");
533        unsafe {
534            match prev {
535                Some(v) => std::env::set_var("GIT_LFS_URL", v),
536                None => std::env::remove_var("GIT_LFS_URL"),
537            }
538        }
539    }
540}