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