Skip to main content

grit_lib/transport/
http.rs

1//! Smart-HTTP Git transport over a pluggable HTTP client.
2//!
3//! This module ports the smart-HTTP fetch protocol from the CLI's
4//! `http_smart.rs` into an embedder-shaped surface:
5//!
6//! * [`HttpClient`] — the minimal request surface the protocol needs: a `GET`
7//!   (used for `info/refs?service=git-upload-pack` discovery) and a `POST`
8//!   (used for the stateless-RPC `git-upload-pack` / `git-receive-pack`
9//!   request body). Embedders supply their own client so grit-lib never forces
10//!   a particular TLS / async / proxy stack on them.
11//! * [`SmartHttpTransport`] — a [`Transport`] that performs the `info/refs`
12//!   discovery on [`Transport::connect`] and exposes the parsed advertisement
13//!   through a [`Connection`].
14//! * [`http_fetch`] — drives the stateless-RPC negotiation (`want`/`have`/`done`
15//!   over repeated POSTs), demultiplexes the side-band pack, ingests it, and
16//!   returns a fetch outcome using the same refspec/tag/prune helpers as other
17//!   fetch paths.
18//!
19//! A default `ureq`-backed [`HttpClient`] lives in the `transport::http::ureq_client`
20//! module behind the `http-ureq` cargo feature; it wires a
21//! [`CredentialProvider`](crate::credentials::CredentialProvider) for HTTP basic auth on `401`.
22//!
23//! Both protocol v0/v1 (the classic stateless RPC) and protocol v2 (the
24//! stateless multi-POST flow) are implemented here. A v2 server is detected from
25//! the `version 2` capability advertisement returned by `info/refs` (requested
26//! with the `Git-Protocol: version=2` header); [`http_fetch`] then runs the v2
27//! `command=ls-refs` + `command=fetch` rounds as separate POSTs — each round
28//! resends the capability echo, all `want`s, and the accumulated `have`s —
29//! reusing the shared v2 request framing and side-band demuxer from the fetch module.
30
31use std::collections::HashSet;
32use std::io::{Cursor, Read};
33use std::path::Path;
34
35use crate::error::{Error, RemoteUnreadableReason, Result};
36use crate::fetch::Progress;
37use crate::objects::ObjectId;
38use crate::pkt_line;
39use crate::transfer::{FetchOptions, FetchOutcome};
40use crate::transport::stateless_http::StatelessHttpConnection;
41use crate::transport::{ConnectOptions, Connection, Service, Transport};
42
43#[cfg(feature = "http-ureq")]
44pub mod ureq_client;
45
46/// The minimal HTTP surface the smart-HTTP transport needs.
47///
48/// Implementations legitimately perform real network I/O; the trait makes no
49/// assumption about the underlying stack (blocking/async, TLS provider, proxy,
50/// cookies), so an embedder can route Git's HTTP through whatever client it
51/// already uses.
52///
53/// The `git_protocol` argument carries the value of the `Git-Protocol` request
54/// header (e.g. `version=2`) when the caller wants to negotiate a protocol
55/// version; pass it through verbatim. A default `Git-Protocol` for every request
56/// may be supplied via [`HttpClient::git_protocol_header`].
57pub trait HttpClient: Send + Sync {
58    /// Issue a `GET` to `url`, returning the response body bytes.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error on a transport failure or a non-success HTTP status.
63    fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>>;
64
65    /// Issue a `POST` to `url` with the given `content_type`, `accept` header,
66    /// and request `body`, returning the response body bytes.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error on a transport failure or a non-success HTTP status.
71    fn post(
72        &self,
73        url: &str,
74        content_type: &str,
75        accept: &str,
76        body: &[u8],
77        git_protocol: Option<&str>,
78    ) -> Result<Vec<u8>>;
79
80    /// Issue a `POST` and return the response body as a readable stream.
81    ///
82    /// The default implementation buffers via [`post`](Self::post). Embedders
83    /// should override this when large packfiles make buffering untenable.
84    ///
85    /// # Errors
86    ///
87    /// Same as [`post`](Self::post).
88    fn post_into_reader(
89        &self,
90        url: &str,
91        content_type: &str,
92        accept: &str,
93        body: &[u8],
94        git_protocol: Option<&str>,
95    ) -> Result<Box<dyn Read + Send>> {
96        Ok(Box::new(Cursor::new(self.post(
97            url,
98            content_type,
99            accept,
100            body,
101            git_protocol,
102        )?)))
103    }
104
105    /// Issue a `POST` whose body is read from `body` while it is sent, and return
106    /// the response body as a readable stream.
107    ///
108    /// Push uses this for request bodies too large to hold in memory (the
109    /// packfile), sending them with chunked transfer encoding. The body cannot be
110    /// replayed, so the client should not expect an authentication challenge
111    /// here: the caller posts a small probe request through
112    /// [`post_into_reader`](Self::post_into_reader) first, which settles
113    /// credentials the way Git does before a large push.
114    ///
115    /// The default implementation reads `body` into memory and calls
116    /// [`post_into_reader`](Self::post_into_reader). Embedders should override it
117    /// so large pushes are not buffered.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error on a transport failure, a failure reading `body`, or a
122    /// non-success HTTP status (including `401`, since the body cannot be resent).
123    fn post_streaming(
124        &self,
125        url: &str,
126        content_type: &str,
127        accept: &str,
128        body: &mut dyn Read,
129        git_protocol: Option<&str>,
130    ) -> Result<Box<dyn Read + Send>> {
131        let mut buffered = Vec::new();
132        body.read_to_end(&mut buffered).map_err(Error::Io)?;
133        self.post_into_reader(url, content_type, accept, &buffered, git_protocol)
134    }
135
136    /// Issue a `GET` to `url` and return both the response body and the final
137    /// URL the request resolved to after any HTTP redirects the client followed
138    /// (`None` when the client does not track it).
139    ///
140    /// The smart-HTTP transport uses this on the `info/refs` discovery GET to
141    /// re-base subsequent `git-upload-pack` POSTs onto a redirected location
142    /// (Git's `http.followRedirects`): a host that redirects `info/refs` to a
143    /// backing host expects the stateless-RPC POSTs there too, and many HTTP
144    /// clients follow redirects on GET but not on POST. The default
145    /// implementation calls [`get`](Self::get) and reports no final URL, so
146    /// existing clients keep working unchanged (without redirect re-basing).
147    ///
148    /// # Errors
149    ///
150    /// Returns an error on a transport failure or a non-success HTTP status.
151    fn get_with_final_url(
152        &self,
153        url: &str,
154        git_protocol: Option<&str>,
155    ) -> Result<(Vec<u8>, Option<String>)> {
156        Ok((self.get(url, git_protocol)?, None))
157    }
158
159    /// Like [`Self::get_with_final_url`], but does not merge [`Self::git_protocol_header`]
160    /// when `git_protocol` is `None` (used for forced v0/v1 `list_refs`).
161    fn get_with_final_url_exact(
162        &self,
163        url: &str,
164        git_protocol: Option<&str>,
165    ) -> Result<(Vec<u8>, Option<String>)> {
166        self.get_with_final_url(url, git_protocol)
167    }
168
169    /// The default `Git-Protocol` request-header value to apply when the caller
170    /// passes `None`. Defaults to no header.
171    fn git_protocol_header(&self) -> Option<&str> {
172        None
173    }
174
175    /// Whether smart-HTTP is enabled (vs. dumb-HTTP fallback). Defaults to
176    /// `true`; embedders that honor `GIT_SMART_HTTP=0` may return `false`.
177    fn smart_http_enabled(&self) -> bool {
178        true
179    }
180
181    /// Drop cached HTTP authorization after an `info/refs` redirect changed the
182    /// effective repository base (Git re-binds credentials via `credential_from_url`).
183    fn reset_auth_after_redirect_rebase(&self) {}
184
185    /// Send an arbitrary request and return the response without judging its
186    /// status.
187    ///
188    /// The resumable transfers ([`crate::remote::resumable`]) need methods and headers
189    /// the smart-HTTP calls above do not: `HEAD`, `PATCH`, `Range` and the TUS
190    /// headers. Unlike [`Self::get`] and [`Self::post`], every status comes back
191    /// as `Ok` (a `206`, `404` or `409` means something to those callers); only
192    /// a transport failure, or an authentication failure the client could not
193    /// resolve, is an error. Clients that authenticate should do so here as
194    /// they do for [`Self::get`].
195    ///
196    /// The default implementation reports [`Error::Message`] saying the client
197    /// cannot send such requests, which turns resumable transfers off.
198    ///
199    /// # Errors
200    ///
201    /// A transport or authentication failure.
202    fn send(&self, request: &HttpRequest<'_>) -> Result<HttpResponse> {
203        Err(Error::Message(format!(
204            "this HTTP client cannot send {} requests",
205            request.method.as_str()
206        )))
207    }
208}
209
210/// Method of an [`HttpRequest`].
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum HttpMethod {
213    /// `GET`.
214    Get,
215    /// `HEAD`.
216    Head,
217    /// `POST`.
218    Post,
219    /// `PATCH`.
220    Patch,
221    /// `PUT`.
222    Put,
223    /// `DELETE`.
224    Delete,
225}
226
227impl HttpMethod {
228    /// The method name on the wire.
229    #[must_use]
230    pub fn as_str(self) -> &'static str {
231        match self {
232            Self::Get => "GET",
233            Self::Head => "HEAD",
234            Self::Post => "POST",
235            Self::Patch => "PATCH",
236            Self::Put => "PUT",
237            Self::Delete => "DELETE",
238        }
239    }
240}
241
242/// A request for [`HttpClient::send`].
243#[derive(Debug, Clone)]
244pub struct HttpRequest<'a> {
245    /// Request method.
246    pub method: HttpMethod,
247    /// Absolute URL.
248    pub url: &'a str,
249    /// Extra request headers, in order.
250    pub headers: Vec<(&'static str, String)>,
251    /// Request body; empty for `GET` and `HEAD`.
252    pub body: &'a [u8],
253    /// Whether the request may run longer than the client's usual whole-request
254    /// timeout (a large download or upload).
255    pub long_running: bool,
256}
257
258impl<'a> HttpRequest<'a> {
259    /// A request with no headers and no body.
260    #[must_use]
261    pub fn new(method: HttpMethod, url: &'a str) -> Self {
262        Self {
263            method,
264            url,
265            headers: Vec::new(),
266            body: &[],
267            long_running: false,
268        }
269    }
270
271    /// Add a header.
272    #[must_use]
273    pub fn header(mut self, name: &'static str, value: impl Into<String>) -> Self {
274        self.headers.push((name, value.into()));
275        self
276    }
277
278    /// Set the body.
279    #[must_use]
280    pub fn body(mut self, body: &'a [u8]) -> Self {
281        self.body = body;
282        self
283    }
284
285    /// Mark the request as a long transfer (see [`Self::long_running`]).
286    #[must_use]
287    pub fn long_running(mut self) -> Self {
288        self.long_running = true;
289        self
290    }
291}
292
293/// A response from [`HttpClient::send`].
294pub struct HttpResponse {
295    /// Status code.
296    pub status: u16,
297    /// Response headers; names are lowercase.
298    pub headers: Vec<(String, String)>,
299    /// The body, read as it arrives.
300    pub body: Box<dyn Read + Send>,
301}
302
303impl HttpResponse {
304    /// The first value of header `name` (case-insensitive).
305    #[must_use]
306    pub fn header(&self, name: &str) -> Option<&str> {
307        self.headers
308            .iter()
309            .find(|(n, _)| n.eq_ignore_ascii_case(name))
310            .map(|(_, v)| v.as_str())
311    }
312
313    /// Whether the status is in the 2xx range.
314    #[must_use]
315    pub fn is_success(&self) -> bool {
316        (200..300).contains(&self.status)
317    }
318
319    /// Read the whole body.
320    ///
321    /// # Errors
322    ///
323    /// The read error.
324    pub fn into_bytes(mut self) -> Result<Vec<u8>> {
325        let mut out = Vec::new();
326        self.body.read_to_end(&mut out).map_err(Error::Io)?;
327        Ok(out)
328    }
329}
330
331impl std::fmt::Debug for HttpResponse {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_struct("HttpResponse")
334            .field("status", &self.status)
335            .field("headers", &self.headers)
336            .finish_non_exhaustive()
337    }
338}
339
340/// Forward [`HttpClient`] through a shared [`std::sync::Arc`], so one client can
341/// back several transports (and be observed by the caller) without moving it.
342impl HttpClient for std::sync::Arc<dyn HttpClient> {
343    fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>> {
344        (**self).get(url, git_protocol)
345    }
346
347    fn post(
348        &self,
349        url: &str,
350        content_type: &str,
351        accept: &str,
352        body: &[u8],
353        git_protocol: Option<&str>,
354    ) -> Result<Vec<u8>> {
355        (**self).post(url, content_type, accept, body, git_protocol)
356    }
357
358    fn post_into_reader(
359        &self,
360        url: &str,
361        content_type: &str,
362        accept: &str,
363        body: &[u8],
364        git_protocol: Option<&str>,
365    ) -> Result<Box<dyn Read + Send>> {
366        (**self).post_into_reader(url, content_type, accept, body, git_protocol)
367    }
368
369    fn post_streaming(
370        &self,
371        url: &str,
372        content_type: &str,
373        accept: &str,
374        body: &mut dyn Read,
375        git_protocol: Option<&str>,
376    ) -> Result<Box<dyn Read + Send>> {
377        (**self).post_streaming(url, content_type, accept, body, git_protocol)
378    }
379
380    fn get_with_final_url(
381        &self,
382        url: &str,
383        git_protocol: Option<&str>,
384    ) -> Result<(Vec<u8>, Option<String>)> {
385        (**self).get_with_final_url(url, git_protocol)
386    }
387
388    fn get_with_final_url_exact(
389        &self,
390        url: &str,
391        git_protocol: Option<&str>,
392    ) -> Result<(Vec<u8>, Option<String>)> {
393        (**self).get_with_final_url_exact(url, git_protocol)
394    }
395
396    fn git_protocol_header(&self) -> Option<&str> {
397        (**self).git_protocol_header()
398    }
399
400    fn smart_http_enabled(&self) -> bool {
401        (**self).smart_http_enabled()
402    }
403
404    fn reset_auth_after_redirect_rebase(&self) {
405        (**self).reset_auth_after_redirect_rebase();
406    }
407
408    fn send(&self, request: &HttpRequest<'_>) -> Result<HttpResponse> {
409        (**self).send(request)
410    }
411}
412
413impl<C: HttpClient> HttpClient for std::sync::Arc<C> {
414    fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>> {
415        (**self).get(url, git_protocol)
416    }
417
418    fn post(
419        &self,
420        url: &str,
421        content_type: &str,
422        accept: &str,
423        body: &[u8],
424        git_protocol: Option<&str>,
425    ) -> Result<Vec<u8>> {
426        (**self).post(url, content_type, accept, body, git_protocol)
427    }
428
429    fn post_into_reader(
430        &self,
431        url: &str,
432        content_type: &str,
433        accept: &str,
434        body: &[u8],
435        git_protocol: Option<&str>,
436    ) -> Result<Box<dyn Read + Send>> {
437        (**self).post_into_reader(url, content_type, accept, body, git_protocol)
438    }
439
440    fn post_streaming(
441        &self,
442        url: &str,
443        content_type: &str,
444        accept: &str,
445        body: &mut dyn Read,
446        git_protocol: Option<&str>,
447    ) -> Result<Box<dyn Read + Send>> {
448        (**self).post_streaming(url, content_type, accept, body, git_protocol)
449    }
450
451    fn get_with_final_url(
452        &self,
453        url: &str,
454        git_protocol: Option<&str>,
455    ) -> Result<(Vec<u8>, Option<String>)> {
456        (**self).get_with_final_url(url, git_protocol)
457    }
458
459    fn get_with_final_url_exact(
460        &self,
461        url: &str,
462        git_protocol: Option<&str>,
463    ) -> Result<(Vec<u8>, Option<String>)> {
464        (**self).get_with_final_url_exact(url, git_protocol)
465    }
466
467    fn git_protocol_header(&self) -> Option<&str> {
468        (**self).git_protocol_header()
469    }
470
471    fn smart_http_enabled(&self) -> bool {
472        (**self).smart_http_enabled()
473    }
474
475    fn reset_auth_after_redirect_rebase(&self) {
476        (**self).reset_auth_after_redirect_rebase();
477    }
478
479    fn send(&self, request: &HttpRequest<'_>) -> Result<HttpResponse> {
480        (**self).send(request)
481    }
482}
483
484const UPLOAD_PACK: &str = "git-upload-pack";
485
486/// HTTP metadata for an `info/refs` discovery response (when the transport captured it).
487#[derive(Clone, Debug)]
488pub(crate) struct InfoRefsDiscoveryHttp {
489    pub url: String,
490    pub status: u16,
491    pub content_type: Option<String>,
492}
493
494/// Whether `url` is a smart-HTTP `info/refs` discovery GET.
495#[cfg(feature = "http-ureq")]
496#[must_use]
497pub(crate) fn is_info_refs_discovery_url(url: &str) -> bool {
498    url.contains("/info/refs") && url.contains("service=git-")
499}
500
501/// Keep only the media type from an HTTP `Content-Type` value (drop parameters such as `charset`).
502#[must_use]
503pub(crate) fn http_content_type_media_type(raw: &str) -> String {
504    raw.split(';').next().unwrap_or(raw).trim().to_owned()
505}
506
507/// True when the response body or `Content-Type` is obviously not a Git advertisement.
508#[must_use]
509pub(crate) fn body_looks_like_non_git_server(body: &[u8], content_type: Option<&str>) -> bool {
510    if content_type
511        .is_some_and(|ct| http_content_type_media_type(ct).eq_ignore_ascii_case("text/html"))
512    {
513        return true;
514    }
515    let body = body.strip_prefix(b"\xef\xbb\xbf").unwrap_or(body);
516    body.starts_with(b"<!")
517        || body
518            .get(..4)
519            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"<htm"))
520}
521
522pub(crate) fn not_git_server_error(
523    url: Option<String>,
524    status: u16,
525    content_type: Option<String>,
526) -> Error {
527    Error::RemoteUnreadable {
528        url,
529        reason: RemoteUnreadableReason::NotGitServer {
530            status,
531            content_type,
532        },
533    }
534}
535
536fn reject_non_git_discovery_http(http: &InfoRefsDiscoveryHttp, body: &[u8]) -> Result<()> {
537    if body_looks_like_non_git_server(body, http.content_type.as_deref()) {
538        return Err(not_git_server_error(
539            Some(http.url.clone()),
540            http.status,
541            http.content_type.clone(),
542        ));
543    }
544    Ok(())
545}
546
547fn strip_service_advertisement_for_discovery<'a>(
548    body: &'a [u8],
549    http: Option<&InfoRefsDiscoveryHttp>,
550) -> Result<&'a [u8]> {
551    if let Some(ctx) = http {
552        reject_non_git_discovery_http(ctx, body)?;
553    } else if body_looks_like_non_git_server(body, None) {
554        return Err(not_git_server_error(None, 200, None));
555    }
556    match strip_service_advertisement(body) {
557        Ok(stripped) => Ok(stripped),
558        Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::InvalidData => {
559            if let Some(ctx) = http {
560                Err(not_git_server_error(
561                    Some(ctx.url.clone()),
562                    ctx.status,
563                    ctx.content_type.clone(),
564                ))
565            } else {
566                Err(not_git_server_error(None, 200, None))
567            }
568        }
569        Err(e) => Err(e),
570    }
571}
572
573/// Strip the optional `# service=...\n` pkt-line + flush preamble that a
574/// smart-HTTP `info/refs?service=...` response begins with, returning the
575/// remaining advertisement bytes.
576///
577/// A smart server prefixes the advertisement with `001e# service=git-upload-pack\n`
578/// followed by a `0000` flush; a dumb server (or a raw `upload-pack
579/// --advertise-refs` body) omits it. Lifted from the CLI's
580/// `strip_v0_service_advertisement_if_present`.
581fn strip_service_advertisement(body: &[u8]) -> Result<&[u8]> {
582    let mut cur = Cursor::new(body);
583    let start = cur.position();
584    match pkt_line::read_packet(&mut cur)? {
585        Some(pkt_line::Packet::Data(line)) if line.starts_with("# service=") => {
586            // Consume the trailing flush after the service header.
587            match pkt_line::read_packet(&mut cur)? {
588                Some(pkt_line::Packet::Flush) | None => {}
589                _ => {
590                    // No flush after the service line: not a smart preamble; rewind.
591                    return Ok(body);
592                }
593            }
594            let pos = cur.position() as usize;
595            Ok(&body[pos..])
596        }
597        _ => {
598            cur.set_position(start);
599            Ok(body)
600        }
601    }
602}
603
604/// A parsed v0/v1 advertisement ref entry (name -> oid).
605#[derive(Clone, Debug)]
606struct AdvRef {
607    name: String,
608    oid: ObjectId,
609}
610
611/// The discovery outcome: protocol version, advertised refs, capabilities, and
612/// the symref target for `HEAD` (if any).
613struct Discovery {
614    protocol_version: u8,
615    refs: Vec<AdvRef>,
616    caps: HashSet<String>,
617    head_symref: Option<String>,
618    object_format: String,
619}
620
621/// Parse a v0/v1 ref advertisement (after the service preamble is stripped).
622///
623/// Hash-width aware via [`ObjectId::from_hex`]. Capabilities ride on the NUL
624/// suffix of the first ref line; the `symref=HEAD:<target>` capability records
625/// the default branch. The all-zero "unborn HEAD" carrier and `shallow`
626/// trailers are skipped. Lifted from the CLI's `parse_v0_v1_advertisement` /
627/// `discover_http_protocol`.
628fn parse_advertisement(body: &[u8]) -> Result<Discovery> {
629    let mut cur = Cursor::new(body);
630
631    // Peek the first packet to distinguish v2 from v0/v1.
632    let first = match pkt_line::read_packet(&mut cur)? {
633        None | Some(pkt_line::Packet::Flush) => {
634            // Empty advertisement (empty repo on an older server): no refs.
635            return Ok(Discovery {
636                protocol_version: 0,
637                refs: Vec::new(),
638                caps: HashSet::new(),
639                head_symref: None,
640                object_format: "sha1".to_owned(),
641            });
642        }
643        Some(pkt_line::Packet::Data(s)) => s,
644        Some(other) => {
645            return Err(Error::Message(format!(
646                "unexpected first advertisement packet: {other:?}"
647            )))
648        }
649    };
650    pkt_line::reject_err_line(&first)?;
651    if first.trim_end() == "version 2" {
652        // Detect v2 so the caller can report it as unsupported in this pass.
653        let mut caps = HashSet::new();
654        loop {
655            match pkt_line::read_packet(&mut cur)? {
656                None | Some(pkt_line::Packet::Flush) => break,
657                Some(pkt_line::Packet::Data(s)) => {
658                    pkt_line::reject_err_line(&s)?;
659                    caps.insert(s.trim_end().to_owned());
660                }
661                Some(_) => break,
662            }
663        }
664        let object_format = caps
665            .iter()
666            .find_map(|c| c.strip_prefix("object-format="))
667            .unwrap_or("sha1")
668            .to_owned();
669        return Ok(Discovery {
670            protocol_version: 2,
671            refs: Vec::new(),
672            caps,
673            head_symref: None,
674            object_format,
675        });
676    }
677
678    // v0/v1: rewind and parse the ref lines.
679    cur.set_position(0);
680    let mut refs = Vec::new();
681    let mut caps: HashSet<String> = HashSet::new();
682    let mut head_symref = None;
683    let mut first_ref_line = true;
684    loop {
685        match pkt_line::read_packet(&mut cur)? {
686            None | Some(pkt_line::Packet::Flush) => break,
687            Some(pkt_line::Packet::Data(line)) => {
688                let line = line.trim_end_matches('\n');
689                pkt_line::reject_err_line(line)?;
690                if line.starts_with("version ") {
691                    continue;
692                }
693                if line.starts_with("shallow ") || line.starts_with("unshallow ") {
694                    continue;
695                }
696                let (payload, cap_part) = match line.split_once('\0') {
697                    Some((p, c)) => (p.trim(), Some(c)),
698                    None => (line.trim(), None),
699                };
700                let Some((oid_hex, refname)) =
701                    payload.split_once('\t').or_else(|| payload.split_once(' '))
702                else {
703                    continue;
704                };
705                let oid_hex = oid_hex.trim();
706                let refname = refname.trim();
707                if first_ref_line {
708                    if let Some(raw_caps) = cap_part {
709                        for cap in raw_caps.split_whitespace() {
710                            if let Some(target) = cap.strip_prefix("symref=HEAD:") {
711                                if crate::refs::is_valid_advertised_symref_target(target) {
712                                    head_symref = Some(target.to_owned());
713                                }
714                            }
715                            caps.insert(cap.to_owned());
716                        }
717                    }
718                    first_ref_line = false;
719                }
720                if refname.is_empty() {
721                    continue;
722                }
723                // All-zero OID marks the unborn-HEAD capabilities carrier (empty repo).
724                if oid_hex.bytes().all(|b| b == b'0') {
725                    continue;
726                }
727                let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
728                    Error::Message(format!("bad oid in advertisement: {oid_hex}: {e}"))
729                })?;
730                let valid_ref = refname == "HEAD"
731                    || crate::refs::is_valid_fetch_advertised_ref(refname)
732                    || refname
733                        .strip_suffix("^{}")
734                        .is_some_and(crate::refs::is_valid_fetch_advertised_ref);
735                if valid_ref {
736                    refs.push(AdvRef {
737                        name: refname.to_owned(),
738                        oid,
739                    });
740                }
741            }
742            Some(other) => {
743                return Err(Error::Message(format!(
744                    "unexpected packet in advertisement: {other:?}"
745                )))
746            }
747        }
748    }
749    let object_format = caps
750        .iter()
751        .find_map(|c| c.strip_prefix("object-format="))
752        .unwrap_or("sha1")
753        .to_owned();
754    Ok(Discovery {
755        protocol_version: if caps.contains("version 1") { 1 } else { 0 },
756        refs,
757        caps,
758        head_symref,
759        object_format,
760    })
761}
762
763/// Build the `info/refs?service=git-upload-pack` discovery URL for `repo_url`.
764fn info_refs_url(repo_url: &str) -> String {
765    smart_info_refs_discovery_url(repo_url)
766}
767
768/// Parsed smart-HTTP upload-pack discovery: protocol version, refs, caps, HEAD symref.
769pub type UploadPackDiscovery = (u8, Vec<(String, ObjectId)>, Vec<String>, Option<String>);
770
771fn upload_pack_discovery_from_body(body: &[u8]) -> Result<UploadPackDiscovery> {
772    upload_pack_discovery_from_body_with_http(body, None)
773}
774
775fn upload_pack_discovery_from_body_with_http(
776    body: &[u8],
777    http: Option<&InfoRefsDiscoveryHttp>,
778) -> Result<UploadPackDiscovery> {
779    let stripped = strip_service_advertisement_for_discovery(body, http)?;
780    let disc = parse_advertisement(stripped)?;
781    let refs: Vec<(String, ObjectId)> = disc
782        .refs
783        .iter()
784        .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
785        .map(|r| (r.name.clone(), r.oid))
786        .collect();
787    let caps: Vec<String> = disc.caps.iter().cloned().collect();
788    Ok((disc.protocol_version, refs, caps, disc.head_symref))
789}
790
791/// Parse a smart-HTTP `info/refs` response body for `git-upload-pack`.
792///
793/// # Errors
794///
795/// [`Error::RemoteFatal`] when the server answers with an `ERR` packet instead of an
796/// advertisement; other malformed advertisements fail with parse errors.
797pub fn discover_upload_pack_from_body(body: &[u8]) -> Result<UploadPackDiscovery> {
798    upload_pack_discovery_from_body(body)
799}
800
801/// Like [`discover_upload_pack_from_body`], but retains annotated-tag `^{}` peel lines
802/// (for `git ls-remote`-style ref listing).
803pub fn discover_upload_pack_for_list_refs_from_body(body: &[u8]) -> Result<UploadPackDiscovery> {
804    let stripped = strip_service_advertisement_for_discovery(body, None)?;
805    let disc = parse_advertisement(stripped)?;
806    let refs: Vec<(String, ObjectId)> = disc
807        .refs
808        .iter()
809        .filter(|r| r.name != "HEAD")
810        .map(|r| (r.name.clone(), r.oid))
811        .collect();
812    let caps: Vec<String> = disc.caps.iter().cloned().collect();
813    Ok((disc.protocol_version, refs, caps, disc.head_symref))
814}
815
816/// Discover upload-pack for ref listing (peel lines preserved).
817pub fn discover_upload_pack_for_list_refs(
818    client: &dyn HttpClient,
819    repo_url: &str,
820    git_protocol: Option<&str>,
821) -> Result<UploadPackDiscovery> {
822    let url = info_refs_url(repo_url);
823    let (body, _) = client.get_with_final_url_exact(&url, git_protocol)?;
824    discover_upload_pack_for_list_refs_from_body(&body)
825}
826
827/// Discover `git-upload-pack` capabilities and (for v0/v1) advertised refs.
828///
829/// For protocol v2 the ref list is empty; use `command=ls-refs` to enumerate refs.
830pub fn discover_upload_pack(
831    client: &dyn HttpClient,
832    repo_url: &str,
833    git_protocol: Option<&str>,
834) -> Result<UploadPackDiscovery> {
835    let url = info_refs_url(repo_url);
836    let gp = git_protocol.or_else(|| client.git_protocol_header());
837    let body = client.get(&url, gp)?;
838    upload_pack_discovery_from_body(&body)
839}
840
841/// Discover upload-pack using an exact `Git-Protocol` header (no client default).
842pub fn discover_upload_pack_exact(
843    client: &dyn HttpClient,
844    repo_url: &str,
845    git_protocol: Option<&str>,
846) -> Result<UploadPackDiscovery> {
847    let url = info_refs_url(repo_url);
848    let (body, _) = client.get_with_final_url_exact(&url, git_protocol)?;
849    upload_pack_discovery_from_body(&body)
850}
851
852/// Build the smart-HTTP `info/refs?service=git-upload-pack` discovery URL for a repo base.
853#[must_use]
854pub fn smart_info_refs_discovery_url(repo_base: &str) -> String {
855    let base = repo_base.trim_end_matches('/');
856    let mut url = format!("{base}/info/refs");
857    url.push_str(if url.contains('?') { "&" } else { "?" });
858    url.push_str("service=");
859    url.push_str(UPLOAD_PACK);
860    url
861}
862
863/// Strip the `/info/refs` suffix (and query) from a discovery URL, returning the repo base.
864#[must_use]
865pub fn repo_base_from_info_refs_url(info_refs_url: &str) -> Option<String> {
866    let path = info_refs_url.split('?').next()?;
867    let base = path.strip_suffix("/info/refs")?.trim_end_matches('/');
868    (!base.is_empty()).then(|| base.to_string())
869}
870
871/// After an `info/refs` GET that returned `401` following redirects, return the
872/// discovery URL whose path matches the effective repository base (Git's
873/// `credential_from_url` after `update_url_from_redirect`).
874#[must_use]
875pub fn effective_info_refs_url_after_redirect(
876    requested_info_refs_url: &str,
877    final_url: Option<&str>,
878) -> String {
879    let Some(original_base) = repo_base_from_info_refs_url(requested_info_refs_url) else {
880        return requested_info_refs_url.to_string();
881    };
882    match rebased_base_from_redirect(&original_base, final_url) {
883        Some(new_base) => smart_info_refs_discovery_url(&new_base),
884        None => requested_info_refs_url.to_string(),
885    }
886}
887
888/// Given the `original_base` of an `info/refs` discovery request and the final
889/// URL it resolved to after any HTTP redirects the client followed, return the
890/// re-based repo URL to use for subsequent smart-HTTP requests — or `None` when
891/// there was no usable redirect (keep the original base).
892///
893/// This implements the client side of Git's `http.followRedirects`: when a host
894/// redirects `info/refs` to a backing location (e.g. tangled.org → its "knot"
895/// host), the later `git-upload-pack` POSTs must target the redirected location.
896/// Many HTTP clients follow the redirect on the discovery GET but not on a POST
897/// (which then hits the redirecting host and comes back as an un-followed `3xx`
898/// with an empty body — zero refs, a silently empty clone). The redirect must
899/// preserve the `/info/refs` path suffix; the new base is the final URL with
900/// that suffix (and any query) removed.
901#[must_use]
902pub fn rebased_base_from_redirect(original_base: &str, final_url: Option<&str>) -> Option<String> {
903    let final_url = final_url?;
904    let final_path = final_url.split('?').next().unwrap_or(final_url);
905    let new_base = final_path.strip_suffix("/info/refs")?.trim_end_matches('/');
906    if new_base.is_empty() || new_base == original_base.trim_end_matches('/') {
907        return None;
908    }
909    Some(new_base.to_owned())
910}
911
912/// Normalized HTTP origin (`scheme://host:port`) used to scope cached credentials
913/// and cookies across redirects.
914///
915/// Host names are lowercased and not aliased (`127.0.0.1` and `localhost` stay
916/// distinct). Non-default ports are always included; default ports for the scheme
917/// are filled in when omitted from the URL.
918#[must_use]
919pub fn http_origin_key(url: &str) -> Option<String> {
920    let parsed = url::Url::parse(url).ok()?;
921    let scheme = parsed.scheme().to_ascii_lowercase();
922    let host = parsed.host_str()?.to_ascii_lowercase();
923    let port = parsed.port_or_known_default()?;
924    Some(format!("{scheme}://{host}:{port}"))
925}
926
927/// Returns whether two URLs share the same HTTP origin (scheme, host, port).
928#[must_use]
929pub fn http_origins_match(a: &str, b: &str) -> bool {
930    match (http_origin_key(a), http_origin_key(b)) {
931        (Some(left), Some(right)) => left == right,
932        _ => false,
933    }
934}
935
936/// A smart-HTTP [`Transport`] over a pluggable [`HttpClient`].
937///
938/// [`Transport::connect`] performs the `info/refs?service=git-upload-pack`
939/// discovery GET and parses the advertisement; the returned [`Connection`]
940/// exposes the advertised refs/capabilities. Use [`http_fetch`] to drive the
941/// fetch negotiation over the same client.
942pub struct SmartHttpTransport<C: HttpClient> {
943    client: C,
944}
945
946impl<C: HttpClient + Clone + 'static> SmartHttpTransport<C> {
947    /// Build a transport backed by `client`.
948    pub fn new(client: C) -> Self {
949        Self { client }
950    }
951
952    /// Borrow the underlying HTTP client.
953    pub fn client(&self) -> &C {
954        &self.client
955    }
956
957    /// Push `refs` to `repo_url` over smart HTTP (`git-receive-pack`), returning a
958    /// [`crate::transfer::PushOutcome`].
959    ///
960    /// This is the push counterpart to [`http_fetch`]: it discovers the
961    /// receive-pack advertisement, decides each update, builds the command block +
962    /// pack, POSTs `git-receive-pack`, and parses the `report-status` reply —
963    /// reusing the same decision/pack/report machinery as the duplex
964    /// [`crate::push::push_remote`]. Delegates to [`crate::push::push_http`].
965    ///
966    /// Protocol v0/v1 only (a v2 receive-pack advertisement is rejected).
967    ///
968    /// # Errors
969    ///
970    /// Returns an error if discovery fails, the advertisement is protocol v2, a
971    /// source object is missing locally, the pack build fails, or on wire/parse
972    /// I/O failure.
973    pub fn push(
974        &self,
975        local_git_dir: &Path,
976        repo_url: &str,
977        refs: &[crate::transfer::PushRefSpec],
978        opts: &crate::transfer::PushOptions,
979        progress: &mut dyn Progress,
980        cancel: &dyn crate::progress::Cancel,
981    ) -> Result<crate::transfer::PushOutcome> {
982        use crate::push::push_remote;
983        use crate::transport::{ConnectOptions, Transport};
984        let connect = ConnectOptions {
985            protocol_version: 0,
986            diagnostics: opts.diagnostics.clone(),
987            network_trace: opts.network_trace,
988            ..Default::default()
989        };
990        let mut conn = self.connect(repo_url, Service::ReceivePack, &connect)?;
991        push_remote(local_git_dir, &mut *conn, refs, opts, progress, cancel)
992    }
993
994    /// Perform the `info/refs` discovery for `repo_url` and `service`, returning
995    /// the parsed [`Discovery`].
996    ///
997    /// `git_protocol` is the `Git-Protocol` request-header value to apply (e.g.
998    /// `version=2` to request a v2 advertisement); when `None`, the client's
999    /// default ([`HttpClient::git_protocol_header`]) is used.
1000    /// Discover capabilities/refs and re-base the repo URL when `info/refs` redirects.
1001    fn discover_with_redirect(
1002        &self,
1003        repo_url: &str,
1004        service: Service,
1005        git_protocol: Option<&str>,
1006    ) -> Result<(String, Discovery)> {
1007        let info_url = info_refs_url_for_service(repo_url, service);
1008        let gp = git_protocol.or_else(|| self.client.git_protocol_header());
1009        let (body, final_url) = self.client.get_with_final_url(&info_url, gp)?;
1010        if let Some(new_base) = rebased_base_from_redirect(repo_url, final_url.as_deref()) {
1011            let _redirect = format!("http(s): redirected base {repo_url} -> {new_base}");
1012            self.client.reset_auth_after_redirect_rebase();
1013            let url = info_refs_url_for_service(&new_base, service);
1014            let body = self.client.get(&url, gp)?;
1015            let stripped = strip_service_advertisement_for_discovery(&body, None)?;
1016            let disc = parse_advertisement(stripped)?;
1017            return Ok((new_base, disc));
1018        }
1019        let stripped = strip_service_advertisement_for_discovery(&body, None)?;
1020        let disc = parse_advertisement(stripped)?;
1021        Ok((repo_url.to_owned(), disc))
1022    }
1023}
1024
1025fn info_refs_url_for_service(repo_url: &str, service: Service) -> String {
1026    let base = repo_url.trim_end_matches('/');
1027    let mut url = format!("{base}/info/refs");
1028    url.push_str(if url.contains('?') { "&" } else { "?" });
1029    url.push_str("service=");
1030    url.push_str(service.wire_name());
1031    url
1032}
1033
1034/// The `Git-Protocol` request-header value for a requested protocol version, or
1035/// `None` for v0 (no header — the classic advertisement).
1036fn git_protocol_for_version(version: u8) -> Option<String> {
1037    if version >= 1 {
1038        Some(format!("version={version}"))
1039    } else {
1040        None
1041    }
1042}
1043
1044impl<C: HttpClient + Clone + 'static> Transport for SmartHttpTransport<C> {
1045    fn connect(
1046        &self,
1047        url: &str,
1048        service: Service,
1049        opts: &ConnectOptions,
1050    ) -> Result<Box<dyn Connection>> {
1051        crate::net_trace::net_trace!(
1052            opts.network_trace,
1053            opts.diagnostics.as_ref(),
1054            "http(s) discover {url} (service={}, request protocol v{})",
1055            service.wire_name(),
1056            opts.protocol_version
1057        );
1058        let gp = git_protocol_for_version(opts.protocol_version);
1059        let (repo_url, disc) = self.discover_with_redirect(url, service, gp.as_deref())?;
1060        let adv_refs: Vec<(String, ObjectId)> = disc
1061            .refs
1062            .iter()
1063            .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
1064            .map(|r| (r.name.clone(), r.oid))
1065            .collect();
1066        let caps: Vec<String> = disc.caps.iter().cloned().collect();
1067        crate::net_trace::net_trace!(
1068            opts.network_trace,
1069            opts.diagnostics.as_ref(),
1070            "http(s) discovered: protocol v{}, {} ref(s) advertised",
1071            disc.protocol_version,
1072            adv_refs.len()
1073        );
1074        Ok(Box::new(StatelessHttpConnection::new(
1075            self.client.clone(),
1076            service,
1077            repo_url,
1078            disc.protocol_version,
1079            adv_refs,
1080            caps,
1081            disc.head_symref,
1082            disc.object_format,
1083            gp,
1084        )))
1085    }
1086}
1087
1088/// Wrap an owned HTTP client for [`http_fetch`] / [`SmartHttpTransport`].
1089pub fn http_client_arc(client: impl HttpClient + 'static) -> std::sync::Arc<dyn HttpClient> {
1090    let boxed: Box<dyn HttpClient> = Box::new(client);
1091    std::sync::Arc::from(boxed)
1092}
1093
1094/// Fetch from a smart-HTTP remote via the shared [`crate::fetch::fetch_remote`] engine.
1095///
1096/// Performs `info/refs` discovery (with redirect re-basing) and drives stateless
1097/// `git-upload-pack` POSTs through a [`crate::transport::stateless_http::StatelessHttpConnection`].
1098///
1099/// # Errors
1100///
1101/// Returns an error if discovery, negotiation, pack ingest, or ref updates fail.
1102pub fn http_fetch(
1103    client: std::sync::Arc<dyn HttpClient>,
1104    local_git_dir: &Path,
1105    repo_url: &str,
1106    opts: &mut FetchOptions,
1107    progress: &mut dyn Progress,
1108) -> Result<FetchOutcome> {
1109    use crate::fetch::fetch_remote;
1110    use crate::transport::Transport;
1111    let protocol_version = client
1112        .git_protocol_header()
1113        .and_then(|h| h.strip_prefix("version="))
1114        .and_then(|v| v.parse::<u8>().ok())
1115        .unwrap_or(0);
1116    let connect = ConnectOptions {
1117        protocol_version,
1118        diagnostics: opts.diagnostics.clone(),
1119        network_trace: opts.network_trace,
1120        ..Default::default()
1121    };
1122    let transport = SmartHttpTransport::new(client);
1123    let mut conn = transport.connect(repo_url, Service::UploadPack, &connect)?;
1124    fetch_remote(
1125        local_git_dir,
1126        &mut *conn,
1127        opts,
1128        progress,
1129        &crate::progress::NeverCancel,
1130    )
1131}
1132
1133#[cfg(test)]
1134#[path = "http_discovery_tests.rs"]
1135mod discovery_tests;