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}