Skip to main content

git_lfs_api/
client.rs

1use std::sync::{Arc, Mutex};
2
3use git_lfs_creds::{Credentials, Helper, Query};
4use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
5use reqwest::{Method, RequestBuilder, Response};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8use url::Url;
9
10use crate::auth::Auth;
11use crate::error::ApiError;
12
13/// `Content-Type` and `Accept` value mandated by the LFS API.
14///
15/// See `docs/api/batch.md`. The spec also allows a `; charset=utf-8`
16/// parameter; we send the bare media type (servers must accept either).
17pub(crate) const LFS_MEDIA_TYPE: &str = "application/vnd.git-lfs+json";
18
19/// HTTP client for the git-lfs API endpoints.
20///
21/// One instance per LFS endpoint URL. `Client` is cheap to clone and shares
22/// an underlying connection pool — clone freely.
23///
24/// # Authentication
25///
26/// Two complementary mechanisms:
27///
28/// - [`Auth`] passed at construction is the initial auth — applied to every
29///   request, no retries on 401.
30/// - A credential helper attached via [`Self::with_credential_helper`] is
31///   queried on a 401 response: the request is retried once with the
32///   filled-in credentials, and the helper is told `approve`/`reject`
33///   based on the second attempt's outcome. Once a fill succeeds, the
34///   client remembers the credentials and uses them for subsequent
35///   requests, so the 401 dance only happens at most once per process.
36#[derive(Clone)]
37pub struct Client {
38    pub(crate) endpoint: Url,
39    pub(crate) http: reqwest::Client,
40    pub(crate) auth: Arc<Mutex<Auth>>,
41    pub(crate) credentials: Option<Arc<dyn Helper>>,
42    /// Cached creds + query they were filled for. `None` means we haven't
43    /// successfully filled yet (but may have an initial `Auth`).
44    pub(crate) filled: Arc<Mutex<Option<(Query, Credentials)>>>,
45}
46
47impl std::fmt::Debug for Client {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("Client")
50            .field("endpoint", &self.endpoint)
51            .field("auth", &self.auth)
52            .field("has_credential_helper", &self.credentials.is_some())
53            .finish()
54    }
55}
56
57impl Client {
58    /// Build a client rooted at the given LFS endpoint.
59    ///
60    /// `endpoint` is the LFS server URL (e.g.
61    /// `https://git-server.com/foo/bar.git/info/lfs`). Subpaths
62    /// (`/objects/batch`, `/locks`, …) are joined onto it per request.
63    pub fn new(endpoint: Url, auth: Auth) -> Self {
64        Self::with_http_client(endpoint, auth, reqwest::Client::new())
65    }
66
67    /// Like [`new`](Self::new) but reuses a caller-supplied `reqwest::Client`.
68    /// Useful for sharing a connection pool, custom timeouts, proxies, etc.
69    pub fn with_http_client(endpoint: Url, auth: Auth, http: reqwest::Client) -> Self {
70        Self {
71            endpoint,
72            http,
73            auth: Arc::new(Mutex::new(auth)),
74            credentials: None,
75            filled: Arc::new(Mutex::new(None)),
76        }
77    }
78
79    /// Attach a credential helper. On 401, the client will call
80    /// `helper.fill`, retry once with the result, then `approve`/`reject`
81    /// based on the outcome.
82    #[must_use]
83    pub fn with_credential_helper(mut self, helper: Arc<dyn Helper>) -> Self {
84        self.credentials = Some(helper);
85        self
86    }
87
88    /// Build a URL by joining `path` onto the endpoint.
89    ///
90    /// `path` should be a relative path like `objects/batch` or `locks`.
91    /// A trailing slash on the endpoint is added if missing so the join
92    /// preserves the endpoint's full path.
93    pub(crate) fn url(&self, path: &str) -> Result<Url, ApiError> {
94        let mut base = self.endpoint.clone();
95        if !base.path().ends_with('/') {
96            let p = format!("{}/", base.path());
97            base.set_path(&p);
98        }
99        Ok(base.join(path)?)
100    }
101
102    /// Build a request, applying the current auth.
103    pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder {
104        let auth = self.auth.lock().unwrap().clone();
105        let mut headers = HeaderMap::new();
106        headers.insert(ACCEPT, HeaderValue::from_static(LFS_MEDIA_TYPE));
107        let req = self.http.request(method, url).headers(headers);
108        auth.apply(req)
109    }
110
111    /// Default credential query for this client — derived from the
112    /// endpoint URL, with the path cleared (matches `git credential`'s
113    /// host-only default).
114    fn cred_query(&self) -> Query {
115        Query::from_url(&self.endpoint).without_path()
116    }
117
118    /// POST a JSON body and decode a JSON response, with LFS error handling
119    /// and the auth-retry loop.
120    pub(crate) async fn post_json<B, R>(&self, path: &str, body: &B) -> Result<R, ApiError>
121    where
122        B: Serialize + ?Sized,
123        R: DeserializeOwned,
124    {
125        let url = self.url(path)?;
126        let body_bytes = serde_json::to_vec(body)
127            .map_err(|e| ApiError::Decode(format!("serializing request body: {e}")))?;
128        self.send_with_auth_retry(|| {
129            self.request(Method::POST, url.clone())
130                .header(CONTENT_TYPE, LFS_MEDIA_TYPE)
131                .body(body_bytes.clone())
132        })
133        .await
134    }
135
136    /// GET a JSON response, with LFS error handling and the auth-retry loop.
137    /// `query` is appended as URL query parameters.
138    pub(crate) async fn get_json<Q, R>(&self, path: &str, query: &Q) -> Result<R, ApiError>
139    where
140        Q: Serialize + ?Sized,
141        R: DeserializeOwned,
142    {
143        let url = self.url(path)?;
144        // serde_urlencoded is what reqwest uses internally; serializing
145        // to a String once means the closure can rebuild the request
146        // cheaply on retry without re-running the serializer.
147        let qs = serde_urlencoded::to_string(query)
148            .map_err(|e| ApiError::Decode(format!("serializing query: {e}")))?;
149        self.send_with_auth_retry(|| {
150            let mut u = url.clone();
151            if !qs.is_empty() {
152                u.set_query(Some(&qs));
153            }
154            self.request(Method::GET, u)
155        })
156        .await
157    }
158
159    /// Drive a single request through the credential-helper retry loop
160    /// and return the (possibly second) raw `Response`. Caller is on the
161    /// hook for decoding it — used by endpoints with bespoke status
162    /// handling (`create_lock`'s 409 → Conflict path, mostly).
163    ///
164    /// `build` produces a fresh `RequestBuilder` each call — it's
165    /// invoked at most twice (once with whatever auth is in place, once
166    /// after a 401 → fill).
167    ///
168    /// Approve / reject semantics (intentionally narrow):
169    /// - 2xx response: approve cached creds (in case they were freshly
170    ///   filled this call, or stayed valid from a prior call).
171    /// - 401 response: reject + clear cached creds. After fill+retry, a
172    ///   second 401 rejects the freshly-filled creds too.
173    /// - Anything else (4xx not-401, 5xx): leave the credential helper
174    ///   alone; we can't tell whether auth was the problem.
175    pub(crate) async fn send_with_auth_retry_response<F>(
176        &self,
177        build: F,
178    ) -> Result<Response, ApiError>
179    where
180        F: Fn() -> RequestBuilder,
181    {
182        let resp = build().send().await?;
183        if resp.status().is_success() {
184            self.approve_filled().await;
185            return Ok(resp);
186        }
187        if resp.status().as_u16() != 401 {
188            return Ok(resp);
189        }
190        // 401 — try the fill+retry dance.
191        let Some(helper) = self.credentials.clone() else {
192            return Ok(resp);
193        };
194        let query = self.cred_query();
195        self.reject_filled().await;
196        let creds = match fill_blocking(helper.clone(), query.clone()).await? {
197            Some(c) => c,
198            None => return Ok(resp),
199        };
200        {
201            let mut auth = self.auth.lock().unwrap();
202            *auth = Auth::Basic {
203                username: creds.username.clone(),
204                password: creds.password.clone(),
205            };
206        }
207        {
208            let mut filled = self.filled.lock().unwrap();
209            *filled = Some((query.clone(), creds.clone()));
210        }
211        let resp2 = build().send().await?;
212        if resp2.status().is_success() {
213            approve_blocking(helper, query, creds).await?;
214        } else if resp2.status().as_u16() == 401 {
215            reject_blocking(helper, query, creds).await?;
216            *self.filled.lock().unwrap() = None;
217            *self.auth.lock().unwrap() = Auth::None;
218        }
219        Ok(resp2)
220    }
221
222    /// Like [`send_with_auth_retry_response`] but decodes a JSON body.
223    /// Used by `post_json` / `get_json`.
224    async fn send_with_auth_retry<F, R>(&self, build: F) -> Result<R, ApiError>
225    where
226        F: Fn() -> RequestBuilder,
227        R: DeserializeOwned,
228    {
229        let resp = self.send_with_auth_retry_response(build).await?;
230        decode::<R>(resp).await
231    }
232
233    async fn approve_filled(&self) {
234        let snapshot = self.filled.lock().unwrap().clone();
235        if let (Some(helper), Some((q, c))) = (self.credentials.clone(), snapshot) {
236            // Approve is best-effort — a failure to write to the keystore
237            // shouldn't fail the user's API call.
238            let _ = approve_blocking(helper, q, c).await;
239        }
240    }
241
242    async fn reject_filled(&self) {
243        let snapshot = self.filled.lock().unwrap().take();
244        if let (Some(helper), Some((q, c))) = (self.credentials.clone(), snapshot) {
245            let _ = reject_blocking(helper, q, c).await;
246            *self.auth.lock().unwrap() = Auth::None;
247        }
248    }
249}
250
251/// Convert an HTTP response into either a typed body or an [`ApiError`].
252pub(crate) async fn decode<R: DeserializeOwned>(resp: Response) -> Result<R, ApiError> {
253    let status = resp.status();
254    if status.is_success() {
255        let bytes = resp.bytes().await?;
256        return serde_json::from_slice(&bytes).map_err(|e| ApiError::Decode(e.to_string()));
257    }
258
259    let lfs_authenticate = resp
260        .headers()
261        .get("LFS-Authenticate")
262        .and_then(|v| v.to_str().ok())
263        .map(str::to_owned);
264    let bytes = resp.bytes().await.unwrap_or_default();
265
266    Err(ApiError::Status {
267        status: status.as_u16(),
268        lfs_authenticate,
269        body: serde_json::from_slice(&bytes).ok(),
270    })
271}
272
273/// `Helper` is a sync trait — wrap each call in `spawn_blocking` so we don't
274/// stall the executor while git-credential's subprocess runs.
275async fn fill_blocking(
276    helper: Arc<dyn Helper>,
277    query: Query,
278) -> Result<Option<Credentials>, ApiError> {
279    tokio::task::spawn_blocking(move || helper.fill(&query))
280        .await
281        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
282        .map_err(|e| ApiError::Decode(format!("credential helper: {e}")))
283}
284
285async fn approve_blocking(
286    helper: Arc<dyn Helper>,
287    query: Query,
288    creds: Credentials,
289) -> Result<(), ApiError> {
290    tokio::task::spawn_blocking(move || helper.approve(&query, &creds))
291        .await
292        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
293        .map_err(|e| ApiError::Decode(format!("credential helper approve: {e}")))
294}
295
296async fn reject_blocking(
297    helper: Arc<dyn Helper>,
298    query: Query,
299    creds: Credentials,
300) -> Result<(), ApiError> {
301    tokio::task::spawn_blocking(move || helper.reject(&query, &creds))
302        .await
303        .map_err(|e| ApiError::Decode(format!("credential helper join: {e}")))?
304        .map_err(|e| ApiError::Decode(format!("credential helper reject: {e}")))
305}