Skip to main content

git_lfs_api/
client.rs

1use std::io::Write;
2use std::sync::{Arc, Mutex};
3
4use git_lfs_creds::{Credentials, Helper, Query};
5use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
6use reqwest::{Method, RequestBuilder, Response};
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9use url::Url;
10
11use crate::auth::Auth;
12use crate::error::ApiError;
13use crate::ssh::{SharedSshResolver, SshAuth, SshOperation};
14
15/// `Content-Type` and `Accept` value mandated by the LFS API.
16///
17/// See `docs/api/batch.md`. The spec also allows a `; charset=utf-8`
18/// parameter; we send the bare media type (servers must accept either).
19pub(crate) const LFS_MEDIA_TYPE: &str = "application/vnd.git-lfs+json";
20
21/// HTTP client for the git-lfs API endpoints.
22///
23/// One instance per LFS endpoint URL. `Client` is cheap to clone and shares
24/// an underlying connection pool — clone freely.
25///
26/// # Authentication
27///
28/// Two complementary mechanisms:
29///
30/// - [`Auth`] passed at construction is the initial auth — applied to every
31///   request, no retries on 401.
32/// - A credential helper attached via [`Self::with_credential_helper`] is
33///   queried on a 401 response: the request is retried once with the
34///   filled-in credentials, and the helper is told `approve`/`reject`
35///   based on the second attempt's outcome. Once a fill succeeds, the
36///   client remembers the credentials and uses them for subsequent
37///   requests, so the 401 dance only happens at most once per process.
38#[derive(Clone)]
39pub struct Client {
40    pub(crate) endpoint: Url,
41    pub(crate) http: reqwest::Client,
42    pub(crate) auth: Arc<Mutex<Auth>>,
43    pub(crate) credentials: Option<Arc<dyn Helper>>,
44    /// Cached creds + query they were filled for. `None` means we haven't
45    /// successfully filled yet (but may have an initial `Auth`).
46    pub(crate) filled: Arc<Mutex<Option<(Query, Credentials)>>>,
47    /// Mirrors `credential.useHttpPath` (default `false`). When set, the
48    /// endpoint URL's path is included in the credential-fill query, so
49    /// helpers can scope per-repo. Off by default to match git's host-only
50    /// scoping.
51    pub(crate) use_http_path: bool,
52    /// URL used for credential-fill prompts and "Git credentials for X
53    /// not found" wording. When the LFS endpoint and the git remote URL
54    /// share scheme+host, upstream uses the **git** URL here so prompts
55    /// read like `Username for "https://host/repo"` instead of
56    /// `https://host/repo.git/info/lfs`. `None` falls back to
57    /// [`Self::endpoint`].
58    pub(crate) cred_url: Option<Url>,
59    /// SSH-mediated auth resolver (`git-lfs-authenticate`). Called once
60    /// per request; a non-empty `href` overrides the endpoint URL for
61    /// that call, and headers are merged into the outgoing request.
62    /// `None` means "not an SSH endpoint" — request flow is unchanged.
63    pub(crate) ssh_resolver: Option<SharedSshResolver>,
64    /// Snapshot of `http.<url>.extraHeader` values for `GIT_CURL_VERBOSE`
65    /// logging. The headers themselves are already installed on the
66    /// underlying `reqwest::Client` via `default_headers`, so they ride
67    /// along on every request — we just don't have a way to read them
68    /// back out for the verbose dump. Keeping a parallel copy here is
69    /// cheap and lets the dump match upstream's `> Name: Value` form
70    /// (which the `t-extra-header.sh` greps look for).
71    pub(crate) extra_headers: Vec<(String, String)>,
72}
73
74impl std::fmt::Debug for Client {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Client")
77            .field("endpoint", &self.endpoint)
78            .field("auth", &self.auth)
79            .field("has_credential_helper", &self.credentials.is_some())
80            .finish()
81    }
82}
83
84impl Client {
85    /// Build a client rooted at the given LFS endpoint.
86    ///
87    /// `endpoint` is the LFS server URL (e.g.
88    /// `https://git-server.com/foo/bar.git/info/lfs`). Subpaths
89    /// (`/objects/batch`, `/locks`, …) are joined onto it per request.
90    pub fn new(endpoint: Url, auth: Auth) -> Self {
91        Self::with_http_client(endpoint, auth, reqwest::Client::new())
92    }
93
94    /// Like [`new`](Self::new) but reuses a caller-supplied `reqwest::Client`.
95    /// Useful for sharing a connection pool, custom timeouts, proxies, etc.
96    pub fn with_http_client(endpoint: Url, auth: Auth, http: reqwest::Client) -> Self {
97        Self {
98            endpoint,
99            http,
100            auth: Arc::new(Mutex::new(auth)),
101            credentials: None,
102            filled: Arc::new(Mutex::new(None)),
103            use_http_path: false,
104            cred_url: None,
105            ssh_resolver: None,
106            extra_headers: Vec::new(),
107        }
108    }
109
110    /// Tell the client which `http.<url>.extraHeader` values are
111    /// installed on the underlying `reqwest::Client`, so we can echo
112    /// them under `GIT_CURL_VERBOSE`. Doesn't change what's sent — the
113    /// reqwest client's `default_headers` already carries them.
114    #[must_use]
115    pub fn with_extra_headers_for_verbose(mut self, headers: Vec<(String, String)>) -> Self {
116        self.extra_headers = headers;
117        self
118    }
119
120    /// Attach an SSH auth resolver. Called once per request to resolve
121    /// `git-lfs-authenticate` output; a non-empty returned `href`
122    /// overrides the endpoint URL for that request and the returned
123    /// headers are merged in. Pass when the LFS endpoint is reached via
124    /// SSH (`ssh://...` URL or bare `git@host:repo`); leave unset for
125    /// pure-HTTPS endpoints.
126    #[must_use]
127    pub fn with_ssh_resolver(mut self, resolver: SharedSshResolver) -> Self {
128        self.ssh_resolver = Some(resolver);
129        self
130    }
131
132    /// Override the URL used for credential prompts and the
133    /// `Git credentials for <url> not found` wording. Pass the git
134    /// remote URL when it shares scheme+host with the LFS endpoint;
135    /// otherwise leave unset and credentials key on the LFS endpoint.
136    #[must_use]
137    pub fn with_cred_url(mut self, url: Url) -> Self {
138        self.cred_url = Some(url);
139        self
140    }
141
142    /// Attach a credential helper. On 401, the client will call
143    /// `helper.fill`, retry once with the result, then `approve`/`reject`
144    /// based on the outcome.
145    #[must_use]
146    pub fn with_credential_helper(mut self, helper: Arc<dyn Helper>) -> Self {
147        self.credentials = Some(helper);
148        self
149    }
150
151    /// Toggle `credential.useHttpPath`. When `true`, the endpoint URL's
152    /// path is included in the credential-fill query (so a helper can
153    /// scope per-repo); when `false` (the default, matching git), only
154    /// protocol+host are sent.
155    #[must_use]
156    pub fn with_use_http_path(mut self, on: bool) -> Self {
157        self.use_http_path = on;
158        self
159    }
160
161    /// Read-only access to the endpoint URL this client was built
162    /// against. Used by callers that want to persist
163    /// `lfs.<url>.access` after a successful authenticated request.
164    pub fn endpoint(&self) -> &Url {
165        &self.endpoint
166    }
167
168    /// `true` if this client's current auth state is basic
169    /// (username/password). Used by callers to detect whether the
170    /// most recent operation actually used basic auth, so they can
171    /// persist `lfs.<url>.access = basic` to local git config.
172    pub fn used_basic_auth(&self) -> bool {
173        matches!(*self.auth.lock().unwrap(), Auth::Basic { .. })
174    }
175
176    /// Join `path` onto an explicit base URL. Used both for the
177    /// configured endpoint and for SSH-resolved `href` overrides — the
178    /// latter replaces the endpoint for a single request.
179    pub(crate) fn join(base: &Url, path: &str) -> Result<Url, ApiError> {
180        let mut base = base.clone();
181        if !base.path().ends_with('/') {
182            let p = format!("{}/", base.path());
183            base.set_path(&p);
184        }
185        Ok(base.join(path)?)
186    }
187
188    /// Resolve SSH auth (if a resolver is attached) for `operation`.
189    /// Returns the effective base URL (`href` override or the configured
190    /// endpoint) plus headers to merge into the request. With no
191    /// resolver, returns `(self.endpoint.clone(), {})`.
192    pub(crate) fn resolve_ssh(&self, operation: SshOperation) -> Result<(Url, SshAuth), ApiError> {
193        let Some(resolver) = self.ssh_resolver.as_ref() else {
194            return Ok((self.endpoint.clone(), SshAuth::default()));
195        };
196        let auth = resolver.resolve(operation)?;
197        let base = if auth.href.is_empty() {
198            self.endpoint.clone()
199        } else {
200            let mut u = Url::parse(&auth.href)
201                .map_err(|e| ApiError::Decode(format!("ssh href {:?}: {e}", auth.href)))?;
202            // Collapse consecutive slashes in the path. The reference
203            // `lfs-ssh-echo` test server produces hrefs like
204            // `http://host:port//repo.git/info/lfs` because the path
205            // argument we pass to `git-lfs-authenticate` already starts
206            // with `/`. Go's `http.ServeMux` 301-redirects double-slash
207            // paths to the cleaned form, and reqwest converts POST→GET
208            // on 301. Upstream Go's HTTP client preserves the method,
209            // so it never trips on this; we have to normalize ourselves.
210            let path = u.path().to_owned();
211            let cleaned = collapse_slashes(&path);
212            if cleaned != path {
213                u.set_path(&cleaned);
214            }
215            u
216        };
217        Ok((base, auth))
218    }
219
220    /// Build a request with the configured auth applied, then merge
221    /// `ssh.headers` on top — letting SSH-issued `Authorization` headers
222    /// override what we'd otherwise apply from the credential helper.
223    /// Pass `&SshAuth::default()` for non-SSH calls.
224    pub(crate) fn request_with_headers(
225        &self,
226        method: Method,
227        url: Url,
228        ssh: &SshAuth,
229    ) -> RequestBuilder {
230        let auth = self.auth.lock().unwrap().clone();
231        let mut headers = HeaderMap::new();
232        headers.insert(ACCEPT, HeaderValue::from_static(LFS_MEDIA_TYPE));
233        let req = self.http.request(method, url).headers(headers);
234        let mut req = auth.apply(req);
235        for (k, v) in &ssh.headers {
236            if let (Ok(name), Ok(value)) = (
237                HeaderName::try_from(k.as_str()),
238                HeaderValue::try_from(v.as_str()),
239            ) {
240                req = req.header(name, value);
241            }
242        }
243        req
244    }
245
246    /// Default credential query for this client — derived from
247    /// [`Self::cred_url`] when set (the git remote URL), otherwise from
248    /// [`Self::endpoint`]. Path is cleared unless `use_http_path` is
249    /// set (matches `git credential`'s host-only default and the
250    /// `credential.useHttpPath` knob).
251    fn cred_query(&self) -> Query {
252        let url = self.cred_url.as_ref().unwrap_or(&self.endpoint);
253        let q = Query::from_url(url);
254        if self.use_http_path {
255            q
256        } else {
257            q.without_path()
258        }
259    }
260
261    /// Render the credential URL as a string. Used when constructing
262    /// upstream-compatible error messages like
263    /// `Git credentials for <url> not found`.
264    fn cred_url_string(&self) -> String {
265        self.cred_url.as_ref().unwrap_or(&self.endpoint).to_string()
266    }
267
268    /// POST a JSON body and decode a JSON response, with LFS error handling
269    /// and the auth-retry loop. `op` selects the `git-lfs-authenticate`
270    /// operation when an SSH resolver is attached.
271    pub(crate) async fn post_json<B, R>(
272        &self,
273        path: &str,
274        body: &B,
275        op: SshOperation,
276    ) -> Result<R, ApiError>
277    where
278        B: Serialize + ?Sized,
279        R: DeserializeOwned,
280    {
281        let (base, ssh) = self.resolve_ssh(op)?;
282        let url = Self::join(&base, path)?;
283        let body_bytes = serde_json::to_vec(body)
284            .map_err(|e| ApiError::Decode(format!("serializing request body: {e}")))?;
285        // GIT_CURL_VERBOSE mimics upstream's libcurl-backed dump: shell
286        // tests grep request bodies (e.g. t-batch-transfer test 2 verifies
287        // descending-size object order in the upload batch). reqwest
288        // doesn't emit this on its own, so write the body to stderr
289        // ourselves when the env is set.
290        if std::env::var_os("GIT_CURL_VERBOSE").is_some_and(|v| !v.is_empty() && v != "0") {
291            let mut err = std::io::stderr().lock();
292            let _ = writeln!(err, "> POST {url}");
293            let _ = writeln!(err, "> Content-Type: {LFS_MEDIA_TYPE}");
294            // Mirror upstream's curl-style dump of `http.extraHeader`
295            // values — `t-extra-header.sh` greps for `> X-Foo: bar`
296            // and similar. Reqwest's `default_headers` carries these
297            // bytes on the wire; the parallel snapshot here exists
298            // purely so we can name them in the dump.
299            for (name, value) in &self.extra_headers {
300                let _ = writeln!(err, "> {name}: {value}");
301            }
302            let _ = writeln!(err);
303            let _ = err.write_all(&body_bytes);
304            let _ = writeln!(err);
305        }
306        self.send_with_auth_retry(|| {
307            self.request_with_headers(Method::POST, url.clone(), &ssh)
308                .header(CONTENT_TYPE, LFS_MEDIA_TYPE)
309                .body(body_bytes.clone())
310        })
311        .await
312    }
313
314    /// GET a JSON response, with LFS error handling and the auth-retry loop.
315    /// `query` is appended as URL query parameters. `op` selects the
316    /// `git-lfs-authenticate` operation when an SSH resolver is attached.
317    pub(crate) async fn get_json<Q, R>(
318        &self,
319        path: &str,
320        query: &Q,
321        op: SshOperation,
322    ) -> Result<R, ApiError>
323    where
324        Q: Serialize + ?Sized,
325        R: DeserializeOwned,
326    {
327        let (base, ssh) = self.resolve_ssh(op)?;
328        let url = Self::join(&base, path)?;
329        // serde_urlencoded is what reqwest uses internally; serializing
330        // to a String once means the closure can rebuild the request
331        // cheaply on retry without re-running the serializer.
332        let qs = serde_urlencoded::to_string(query)
333            .map_err(|e| ApiError::Decode(format!("serializing query: {e}")))?;
334        self.send_with_auth_retry(|| {
335            let mut u = url.clone();
336            if !qs.is_empty() {
337                u.set_query(Some(&qs));
338            }
339            self.request_with_headers(Method::GET, u, &ssh)
340        })
341        .await
342    }
343
344    /// Drive a single request through the credential-helper retry loop
345    /// and return the (possibly second) raw `Response`. Caller is on the
346    /// hook for decoding it — used by endpoints with bespoke status
347    /// handling (`create_lock`'s 409 → Conflict path, mostly).
348    ///
349    /// `build` produces a fresh `RequestBuilder` each call — it's
350    /// invoked at most twice (once with whatever auth is in place, once
351    /// after a 401 → fill).
352    ///
353    /// Approve / reject semantics (intentionally narrow):
354    /// - 2xx response: approve cached creds (in case they were freshly
355    ///   filled this call, or stayed valid from a prior call).
356    /// - 401 response: reject + clear cached creds. After fill+retry, a
357    ///   second 401 rejects the freshly-filled creds too.
358    /// - Anything else (4xx not-401, 5xx): leave the credential helper
359    ///   alone; we can't tell whether auth was the problem.
360    pub(crate) async fn send_with_auth_retry_response<F>(
361        &self,
362        build: F,
363    ) -> Result<Response, ApiError>
364    where
365        F: Fn() -> RequestBuilder,
366    {
367        // Preemptive fill: once we've successfully resolved credentials
368        // for this endpoint, re-walk the helper chain on every
369        // subsequent request. The chain returns the same creds from
370        // cache (no extra cost), but helpers that trace their fill
371        // (notably netrc) get to log a line — matching upstream's
372        // `lfshttp/auth.go::setRequestAuth` behavior, which fires
373        // helper.Fill every time an endpoint is in access=basic
374        // mode. `t-credentials.sh`'s netrc tests count these traces
375        // (2 fill + 2 approve per push); without this, we'd log 1
376        // fill + 2 approves and miss the count.
377        let filled_already = self.filled.lock().unwrap().is_some();
378        if filled_already && let Some(helper) = self.credentials.clone() {
379            let query = self.cred_query();
380            if let Ok(Some(c)) = tokio::task::spawn_blocking(move || helper.fill(&query))
381                .await
382                .unwrap_or(Ok(None))
383            {
384                // Replace cached creds with the freshly-resolved set
385                // so on success approve_filled() lands on the right
386                // pair. Same query as the initial fill, so the cache
387                // entry doesn't churn.
388                *self.auth.lock().unwrap() = Auth::Basic {
389                    username: c.username.clone(),
390                    password: c.password.clone(),
391                };
392                *self.filled.lock().unwrap() = Some((self.cred_query(), c));
393            }
394        }
395
396        let resp = build().send().await?;
397        if resp.status().is_success() {
398            self.approve_filled().await;
399            return Ok(resp);
400        }
401        if resp.status().as_u16() != 401 {
402            return Ok(resp);
403        }
404        // 401 — try the fill+retry dance.
405        let Some(helper) = self.credentials.clone() else {
406            return Ok(resp);
407        };
408        let query = self.cred_query();
409        self.reject_filled().await;
410        let cred_url_str = self.cred_url_string();
411        let creds = match fill_for_endpoint(helper.clone(), query.clone(), &cred_url_str).await? {
412            Some(c) => c,
413            // No helper had anything for this URL. Surface the upstream
414            // "Git credentials for X not found" wording so callers (and
415            // batch-error formatters) can distinguish "auth missing" from
416            // a generic 401 the server returned for non-auth reasons.
417            None => {
418                return Err(ApiError::CredentialsNotFound {
419                    url: cred_url_str,
420                    detail: None,
421                });
422            }
423        };
424        {
425            let mut auth = self.auth.lock().unwrap();
426            *auth = Auth::Basic {
427                username: creds.username.clone(),
428                password: creds.password.clone(),
429            };
430        }
431        {
432            let mut filled = self.filled.lock().unwrap();
433            *filled = Some((query.clone(), creds.clone()));
434        }
435        let resp2 = build().send().await?;
436        if resp2.status().is_success() {
437            approve_blocking(helper, query, creds).await?;
438        } else if matches!(resp2.status().as_u16(), 401 | 403) {
439            // Both 401 (unauthorized) and 403 (forbidden after auth)
440            // mean the just-filled creds are wrong. Drop them so the
441            // *next* request triggers another 401 → fill → retry
442            // dance — without this reset, every subsequent request
443            // would silently reuse the bad credentials and skip the
444            // helper. Matches upstream's per-request `getCreds` flow.
445            reject_blocking(helper, query, creds).await?;
446            *self.filled.lock().unwrap() = None;
447            *self.auth.lock().unwrap() = Auth::None;
448        }
449        Ok(resp2)
450    }
451
452    /// Like [`send_with_auth_retry_response`] but decodes a JSON body.
453    /// Used by `post_json` / `get_json`.
454    async fn send_with_auth_retry<F, R>(&self, build: F) -> Result<R, ApiError>
455    where
456        F: Fn() -> RequestBuilder,
457        R: DeserializeOwned,
458    {
459        let resp = self.send_with_auth_retry_response(build).await?;
460        decode::<R>(resp).await
461    }
462
463    async fn approve_filled(&self) {
464        let snapshot = self.filled.lock().unwrap().clone();
465        if let (Some(helper), Some((q, c))) = (self.credentials.clone(), snapshot) {
466            // Approve is best-effort — a failure to write to the keystore
467            // shouldn't fail the user's API call.
468            let _ = approve_blocking(helper, q, c).await;
469        }
470    }
471
472    async fn reject_filled(&self) {
473        let snapshot = self.filled.lock().unwrap().take();
474        if let (Some(helper), Some((q, c))) = (self.credentials.clone(), snapshot) {
475            let _ = reject_blocking(helper, q, c).await;
476            *self.auth.lock().unwrap() = Auth::None;
477        }
478    }
479}
480
481/// Collapse consecutive `/` runs in a URL path to a single `/`.
482/// Preserves a single leading slash if the input was rooted.
483fn collapse_slashes(path: &str) -> String {
484    let mut out = String::with_capacity(path.len());
485    let mut last_was_slash = false;
486    for c in path.chars() {
487        if c == '/' {
488            if !last_was_slash {
489                out.push('/');
490            }
491            last_was_slash = true;
492        } else {
493            out.push(c);
494            last_was_slash = false;
495        }
496    }
497    out
498}
499
500/// Convert an HTTP response into either a typed body or an [`ApiError`].
501pub(crate) async fn decode<R: DeserializeOwned>(resp: Response) -> Result<R, ApiError> {
502    let status = resp.status();
503    if status.is_success() {
504        let bytes = resp.bytes().await?;
505        return serde_json::from_slice(&bytes).map_err(|e| ApiError::Decode(e.to_string()));
506    }
507
508    let lfs_authenticate = resp
509        .headers()
510        .get("LFS-Authenticate")
511        .and_then(|v| v.to_str().ok())
512        .map(str::to_owned);
513    let retry_after = resp
514        .headers()
515        .get(reqwest::header::RETRY_AFTER)
516        .and_then(|v| v.to_str().ok())
517        .and_then(crate::error::parse_retry_after);
518    let request_url = resp.url().to_string();
519    let bytes = resp.bytes().await.unwrap_or_default();
520
521    Err(ApiError::Status {
522        status: status.as_u16(),
523        url: Some(request_url),
524        lfs_authenticate,
525        body: serde_json::from_slice(&bytes).ok(),
526        retry_after,
527    })
528}
529
530/// `Helper` is a sync trait — wrap each call in `spawn_blocking` so we don't
531/// stall the executor while git-credential's subprocess runs.
532///
533/// On a helper-side error (e.g. `protectProtocol` rejected a malformed
534/// URL), surface it as [`ApiError::CredentialsNotFound`] keyed on
535/// `endpoint`. Matches upstream's `FillCreds` wrapping so the underlying
536/// "credential value for path contains newline" message reaches the user
537/// alongside the "Git credentials for X not found" header.
538async fn fill_for_endpoint(
539    helper: Arc<dyn Helper>,
540    query: Query,
541    endpoint: &str,
542) -> Result<Option<Credentials>, ApiError> {
543    let endpoint_str = endpoint.to_owned();
544    tokio::task::spawn_blocking(move || helper.fill(&query))
545        .await
546        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
547        .map_err(|e| ApiError::CredentialsNotFound {
548            url: endpoint_str,
549            detail: Some(e.to_string()),
550        })
551}
552
553async fn approve_blocking(
554    helper: Arc<dyn Helper>,
555    query: Query,
556    creds: Credentials,
557) -> Result<(), ApiError> {
558    tokio::task::spawn_blocking(move || helper.approve(&query, &creds))
559        .await
560        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
561        .map_err(|e| ApiError::Decode(format!("credential helper approve: {e}")))
562}
563
564async fn reject_blocking(
565    helper: Arc<dyn Helper>,
566    query: Query,
567    creds: Credentials,
568) -> Result<(), ApiError> {
569    tokio::task::spawn_blocking(move || helper.reject(&query, &creds))
570        .await
571        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
572        .map_err(|e| ApiError::Decode(format!("credential helper reject: {e}")))
573}