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}