Skip to main content

sley_remote/
http.rs

1//! Shared smart-HTTP(S) transport plumbing.
2//!
3//! These are the reusable request/advertisement/auth helpers behind the HTTP
4//! fetch/push/clone/ls-remote paths. They drive the transport-agnostic protocol
5//! codecs ([`sley_protocol`]) over an [`HttpClient`] from [`sley_transport`],
6//! taking everything as explicit parameters and never touching process-global
7//! state, argument parsing, or stdout/stderr — so both the CLI orchestration and
8//! an embedder can call them.
9//!
10//! HTTP mirrors the SSH path, with two wire differences: the info/refs GET
11//! carries a `# service=` announcement preamble (handled by
12//! [`read_service_discovery_response`]), and the RPC POST response goes straight
13//! to the packfile/report (no re-advertised refs to skip).
14
15use std::io::Read;
16use std::path::Path;
17
18use crate::install::{
19    ProgressInstaller, install_protocol_v2_fetch_promisor_response_from_reader,
20    install_protocol_v2_fetch_response_from_reader,
21    install_upload_pack_packfile_promisor_response_from_reader,
22    install_upload_pack_packfile_response_from_reader,
23    install_upload_pack_shallow_packfile_promisor_response_from_reader,
24    install_upload_pack_shallow_packfile_response_from_reader,
25    shallow_info_from_protocol_v2_fetch_header,
26};
27use sley_config::GitConfig;
28use sley_core::{
29    Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
30};
31use sley_odb::FileObjectDatabase;
32use sley_protocol::{
33    GitService, ProtocolV2CommandOptions, ProtocolV2CommandRequest, ProtocolV2FetchAcknowledgment,
34    ProtocolV2FetchRequest, ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo,
35    ProtocolV2LsRefsRequest, ProtocolVersion, RefAdvertisement, RefAdvertisementSet,
36    TransportHandshake, UploadPackFeatures, UploadPackNegotiationRequest, UploadPackRequest,
37    encode_protocol_v2_command_options, parse_protocol_v2_fetch_features,
38    parse_upload_pack_features, protocol_v2_object_format,
39    read_protocol_v2_fetch_negotiation_response, read_protocol_v2_fetch_response,
40    read_protocol_v2_fetch_sideband_all_response,
41    read_protocol_v2_ls_refs_response_as_ref_advertisement_set,
42    smart_http_advertisement_content_type, smart_http_rpc_request_content_type,
43    trace_protocol_v2_advertisement_read, validate_protocol_v2_fetch_command_request,
44    validate_protocol_v2_ls_refs_command_request, write_protocol_v2_command_request,
45    write_upload_pack_negotiation_request, write_upload_pack_request,
46};
47use sley_transport::{
48    GitProtocolHeader, HttpClient, HttpResponse, RemoteTransport, RemoteUrl,
49    ServiceDiscoveryPayload, ServiceDiscoveryResponse, UreqHttpClient, encode_git_protocol_header,
50    git_credential_basic_authorization, http_smart_info_refs_url, http_smart_rpc_url,
51    parse_remote_url, read_service_discovery_response,
52};
53
54use crate::credentials::{credential_request_for_url, http_url_credential};
55use crate::{CredentialProvider, ProgressSink};
56
57/// Whether an already-resolved remote `url` uses HTTP(S) transport.
58///
59/// Callers that start from a configured remote name or relative source resolve
60/// the URL first (the resolution is repository/process-state dependent and lives
61/// in the caller); this only classifies a concrete URL.
62pub fn remote_url_is_http(url: &str) -> Result<bool> {
63    Ok(matches!(
64        parse_remote_url(url)?.transport,
65        RemoteTransport::Http | RemoteTransport::Https
66    ))
67}
68
69/// Reusable HTTP client for every smart-HTTP RPC in one remote operation.
70pub struct HttpOperationBatch {
71    client: UreqHttpClient,
72}
73
74impl HttpOperationBatch {
75    pub fn new() -> Self {
76        Self {
77            client: UreqHttpClient::new(),
78        }
79    }
80    pub fn client(&self) -> &UreqHttpClient {
81        &self.client
82    }
83}
84
85impl Default for HttpOperationBatch {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91pub fn new_http_client() -> UreqHttpClient {
92    UreqHttpClient::new()
93}
94
95/// Perform an HTTP request, retrying once with credential-provider-supplied
96/// authentication if the first attempt returns 401. `perform` is invoked with an
97/// optional `Authorization` header value and must be idempotent (it may run twice).
98/// A successful retry approves the credential with `credentials`; a still-401
99/// retry rejects it.
100pub fn http_send_with_auth(
101    remote: &RemoteUrl,
102    credentials: &mut dyn CredentialProvider,
103    mut perform: impl FnMut(Option<&str>) -> Result<HttpResponse>,
104) -> Result<HttpResponse> {
105    let initial = http_url_credential(remote);
106    let initial_header = match &initial {
107        Some(credential) => git_credential_basic_authorization(credential)?,
108        None => None,
109    };
110    let response = perform(initial_header.as_deref())?;
111    if response.status != 401 {
112        return Ok(response);
113    }
114    let mut request = credential_request_for_url(remote);
115    if request.username.is_none() {
116        request.username = initial.and_then(|credential| credential.username);
117    }
118    let Some(filled) = credentials.fill(request)? else {
119        return Ok(response);
120    };
121    let Some(header) = git_credential_basic_authorization(&filled)? else {
122        return Ok(response);
123    };
124    let retry = perform(Some(&header))?;
125    if retry.status != 401 {
126        credentials.approve(&filled)?;
127    } else {
128        credentials.reject(&filled)?;
129    }
130    Ok(retry)
131}
132
133/// Resolve the client protocol version for smart HTTP, defaulting to v2 like upstream git.
134pub fn http_protocol_version_from_config(config: Option<&GitConfig>) -> Option<ProtocolVersion> {
135    match config.and_then(|config| config.get("protocol", None, "version")) {
136        Some("0") => Some(ProtocolVersion::V0),
137        Some("1") => Some(ProtocolVersion::V1),
138        Some("2") => Some(ProtocolVersion::V2),
139        _ => Some(ProtocolVersion::V2),
140    }
141}
142
143/// Encode the `Git-Protocol` request header value, if any (`None` for protocol v0).
144pub fn http_git_protocol_header_value(config: Option<&GitConfig>) -> Result<Option<String>> {
145    http_git_protocol_header_value_for_service(config, GitService::UploadPack)
146}
147
148/// Encode the `Git-Protocol` header for a specific smart-HTTP service.
149///
150/// Upstream `remote-curl.c` only negotiates protocol v2 for `git-upload-pack`;
151/// push (`git-receive-pack`) and other RPCs fall back to v0.
152pub fn http_git_protocol_header_value_for_service(
153    config: Option<&GitConfig>,
154    service: GitService,
155) -> Result<Option<String>> {
156    let mut version = http_protocol_version_from_config(config);
157    if matches!(version, Some(ProtocolVersion::V2)) && service != GitService::UploadPack {
158        version = Some(ProtocolVersion::V0);
159    }
160    match version {
161        Some(ProtocolVersion::V0) => Ok(None),
162        Some(version) => encode_git_protocol_header(&GitProtocolHeader {
163            protocol: Some(version),
164            extra_parameters: Vec::new(),
165        }),
166        None => Ok(None),
167    }
168}
169
170/// Build smart-HTTP request headers for an optional credential and `Git-Protocol` value.
171pub fn http_request_headers<'a>(
172    auth: Option<&'a str>,
173    git_protocol: Option<&'a str>,
174) -> Vec<(&'a str, &'a str)> {
175    let mut headers = Vec::with_capacity(2);
176    if let Some(value) = git_protocol {
177        headers.push(("Git-Protocol", value));
178    }
179    if let Some(value) = auth {
180        headers.push(("Authorization", value));
181    }
182    headers
183}
184
185/// Build the `Authorization` header list for an optional credential header value.
186pub fn http_authorization_headers(auth: Option<&str>) -> Vec<(&str, &str)> {
187    http_request_headers(auth, None)
188}
189
190/// Map an HTTP response status to success or a descriptive error for `url`.
191pub fn http_check_status(response: &HttpResponse, url: &str) -> Result<()> {
192    if (200..300).contains(&response.status) {
193        Ok(())
194    } else if response.status == 401 {
195        Err(GitError::Command(format!(
196            "authentication failed for {url}"
197        )))
198    } else {
199        Err(GitError::Command(format!(
200            "unexpected HTTP status {} for {url}",
201            response.status
202        )))
203    }
204}
205
206/// Verify the response `Content-Type` matches `expected` (ignoring parameters).
207pub fn http_validate_content_type(response: &HttpResponse, expected: &str) -> Result<()> {
208    let actual = response
209        .content_type
210        .as_deref()
211        .unwrap_or("")
212        .split(';')
213        .next()
214        .unwrap_or("")
215        .trim();
216    if actual.eq_ignore_ascii_case(expected) {
217        Ok(())
218    } else {
219        Err(GitError::InvalidFormat(format!(
220            "unexpected content type {actual:?}, expected {expected:?}"
221        )))
222    }
223}
224
225/// Result of smart-HTTP service discovery: parsed ref advertisements plus the
226/// protocol v2 handshake when the server negotiated v2 on the info/refs exchange.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct HttpServiceAdvertisements {
229    pub set: RefAdvertisementSet,
230    pub handshake: Option<TransportHandshake>,
231}
232
233/// Upload-pack discovery for a repository whose object format is not known yet.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct HttpUploadPackDiscovery {
236    pub advertisements: HttpServiceAdvertisements,
237    pub features: UploadPackFeatures,
238    pub object_format: ObjectFormat,
239}
240
241/// Parse a smart-HTTP info/refs body into a ref advertisement set for protocol
242/// v0/v1. Protocol v2 discovery responses require a follow-up `ls-refs` RPC; use
243/// [`http_service_advertisements`] instead.
244pub fn http_advertised_refs(
245    format: ObjectFormat,
246    mut response: HttpResponse,
247) -> Result<RefAdvertisementSet> {
248    let discovery = read_http_service_discovery_response(format, &mut response.body)?;
249    match discovery.payload {
250        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(set),
251        ServiceDiscoveryPayload::ProtocolV2(_) => Err(GitError::Unsupported(
252            "protocol v2 advertisements over HTTP require an ls-refs RPC; use http_service_advertisements".into(),
253        )),
254    }
255}
256
257fn protocol_v2_ls_refs_command_request(
258    format: ObjectFormat,
259    handshake: &TransportHandshake,
260) -> Result<ProtocolV2CommandRequest> {
261    let ls_refs = ProtocolV2LsRefsRequest {
262        peel: true,
263        symrefs: true,
264        unborn: false,
265        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
266    };
267    let mut command = ls_refs.to_command_request()?;
268    let mut options = ProtocolV2CommandOptions::default();
269    if handshake
270        .capabilities
271        .iter()
272        .any(|capability| capability.name == "agent")
273    {
274        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
275    }
276    if handshake
277        .capabilities
278        .iter()
279        .any(|capability| capability.name == "object-format")
280    {
281        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
282        if advertised_format != format {
283            return Err(GitError::InvalidObjectId(format!(
284                "remote repository uses {}, local repository uses {}",
285                advertised_format.name(),
286                format.name()
287            )));
288        }
289        options.object_format = Some(format);
290    }
291    command.capabilities = encode_protocol_v2_command_options(&options)?;
292    validate_protocol_v2_ls_refs_command_request(handshake, &command)?;
293    Ok(command)
294}
295
296fn protocol_v2_fetch_command_request(
297    format: ObjectFormat,
298    handshake: &TransportHandshake,
299    fetch: &ProtocolV2FetchRequest,
300) -> Result<ProtocolV2CommandRequest> {
301    let mut command = fetch.to_command_request()?;
302    let mut options = ProtocolV2CommandOptions::default();
303    if handshake
304        .capabilities
305        .iter()
306        .any(|capability| capability.name == "agent")
307    {
308        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
309    }
310    if handshake
311        .capabilities
312        .iter()
313        .any(|capability| capability.name == "object-format")
314    {
315        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
316        if advertised_format != format {
317            return Err(GitError::InvalidObjectId(format!(
318                "remote repository uses {}, local repository uses {}",
319                advertised_format.name(),
320                format.name()
321            )));
322        }
323        options.object_format = Some(format);
324    }
325    command.capabilities = encode_protocol_v2_command_options(&options)?;
326    validate_protocol_v2_fetch_command_request(handshake, format, &command)?;
327    Ok(command)
328}
329
330#[allow(clippy::too_many_arguments)]
331fn protocol_v2_fetch_request_from_upload_pack_semantics(
332    wants: Vec<ObjectId>,
333    haves: Vec<ObjectId>,
334    shallow: Vec<ObjectId>,
335    deepen: Option<u32>,
336    deepen_since: Option<i64>,
337    deepen_not: Vec<String>,
338    deepen_relative: bool,
339    filter: Option<&sley_odb::PackObjectFilter>,
340    handshake: &TransportHandshake,
341) -> Result<ProtocolV2FetchRequest> {
342    let v2_features =
343        parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
344    Ok(ProtocolV2FetchRequest {
345        wants,
346        haves,
347        shallow,
348        deepen,
349        deepen_since: deepen_since_u64(deepen_since),
350        deepen_not,
351        deepen_relative,
352        filter: filter.and_then(crate::local::upload_pack_filter_protocol_spec),
353        thin_pack: true,
354        include_tag: true,
355        ofs_delta: true,
356        done: true,
357        // Normal fetch negotiation expects the pack immediately after `ready`.
358        // `wait-for-done` is reserved for callers such as `--negotiate-only`.
359        wait_for_done: false,
360        sideband_all: v2_features.sideband_all,
361        ..ProtocolV2FetchRequest::default()
362    })
363}
364
365fn deepen_since_u64(deepen_since: Option<i64>) -> Option<u64> {
366    deepen_since.and_then(|value| u64::try_from(value).ok())
367}
368
369fn build_http_upload_pack_request(
370    wants: Vec<ObjectId>,
371    shallow: Vec<ObjectId>,
372    deepen: Option<u32>,
373    deepen_since: Option<i64>,
374    deepen_not: Vec<String>,
375    filter: Option<&sley_odb::PackObjectFilter>,
376) -> UploadPackRequest {
377    UploadPackRequest {
378        wants,
379        capabilities: upload_pack_request_capabilities(
380            deepen,
381            filter
382                .and_then(crate::local::upload_pack_filter_protocol_spec)
383                .is_some(),
384        ),
385        shallow,
386        deepen,
387        deepen_since: deepen_since_u64(deepen_since),
388        deepen_not,
389        filter: filter.and_then(crate::local::upload_pack_filter_protocol_spec),
390    }
391}
392
393fn upload_pack_request_capabilities(deepen: Option<u32>, filter: bool) -> Vec<Capability> {
394    let mut capabilities = Vec::new();
395    // The v0 upload-pack response reader demuxes a side-band-64k stream, so the
396    // request must negotiate it; otherwise the server streams a bare packfile
397    // and the reader mis-parses the leading `PACK` signature as a pkt-line
398    // length ("invalid pkt-line length byte 0x50"). Capabilities not available
399    // in this request type are omitted rather than being requested without an
400    // advertisement; in particular, Sley's server does not advertise
401    // `ofs-delta` yet.
402    capabilities.push(Capability {
403        name: "side-band-64k".into(),
404        value: None,
405    });
406    if deepen.is_some() {
407        capabilities.push(Capability {
408            name: "shallow".into(),
409            value: None,
410        });
411    }
412    if filter {
413        capabilities.push(Capability {
414            name: "filter".into(),
415            value: None,
416        });
417    }
418    capabilities
419}
420
421fn request_replays_shallow_boundary(
422    deepen: Option<u32>,
423    deepen_since: Option<i64>,
424    deepen_not: &[String],
425) -> bool {
426    deepen.is_some() || deepen_since.is_some() || !deepen_not.is_empty()
427}
428
429fn http_protocol_v2_ls_refs_advertisements<C: HttpClient + ?Sized>(
430    client: &C,
431    remote: &RemoteUrl,
432    format: ObjectFormat,
433    service: GitService,
434    handshake: TransportHandshake,
435    credentials: &mut dyn CredentialProvider,
436    git_protocol: Option<&str>,
437) -> Result<RefAdvertisementSet> {
438    let command = protocol_v2_ls_refs_command_request(format, &handshake)?;
439    let url = http_smart_rpc_url(remote, service)?;
440    let mut body = Vec::new();
441    write_protocol_v2_command_request(&mut body, &command)?;
442    let content_type = smart_http_rpc_request_content_type(service)?;
443    let mut response = http_send_with_auth(remote, credentials, |auth| {
444        client.post(
445            &url,
446            &content_type,
447            &http_request_headers(auth, git_protocol),
448            &body,
449        )
450    })?;
451    http_check_status(&response, &url)?;
452    read_protocol_v2_ls_refs_response_as_ref_advertisement_set(format, &mut response.body)
453}
454
455/// Fetch and parse the ref advertisements for `service` from the smart-HTTP
456/// info/refs endpoint, authenticating and validating status + content type.
457pub fn http_service_advertisements<C: HttpClient + ?Sized>(
458    client: &C,
459    remote: &RemoteUrl,
460    format: ObjectFormat,
461    service: GitService,
462    credentials: &mut dyn CredentialProvider,
463    config: Option<&GitConfig>,
464) -> Result<HttpServiceAdvertisements> {
465    http_service_advertisements_with_expected_format(
466        client,
467        remote,
468        Some(format),
469        service,
470        credentials,
471        config,
472    )
473}
474
475fn http_service_advertisements_with_expected_format<C: HttpClient + ?Sized>(
476    client: &C,
477    remote: &RemoteUrl,
478    expected_format: Option<ObjectFormat>,
479    service: GitService,
480    credentials: &mut dyn CredentialProvider,
481    config: Option<&GitConfig>,
482) -> Result<HttpServiceAdvertisements> {
483    // Git's smart-HTTP packet stream is owned by the logical remote-curl
484    // helper, whose packet trace identity is `git` even though Sley performs
485    // the same operation in-process. Keep this observable boundary without
486    // spawning `git-remote-http(s)` or consulting an installed Git.
487    let _packet_trace_identity = sley_protocol::scoped_packet_trace_identity("git");
488    let git_protocol = http_git_protocol_header_value_for_service(config, service)?;
489    let url = http_smart_info_refs_url(remote, service)?;
490    let mut response = http_send_with_auth(remote, credentials, |auth| {
491        client.get(&url, &http_request_headers(auth, git_protocol.as_deref()))
492    })?;
493    http_check_status(&response, &url)?;
494    http_validate_content_type(&response, &smart_http_advertisement_content_type(service)?)?;
495    let discovery = read_http_service_discovery_response(
496        expected_format.unwrap_or(ObjectFormat::Sha1),
497        &mut response.body,
498    )?;
499    let object_format = service_discovery_object_format(&discovery)?;
500    if let Some(expected) = expected_format
501        && object_format != expected
502    {
503        return Err(GitError::InvalidObjectId(format!(
504            "remote repository uses {}, local repository uses {}",
505            object_format.name(),
506            expected.name()
507        )));
508    }
509    match discovery.payload {
510        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(HttpServiceAdvertisements {
511            set,
512            handshake: None,
513        }),
514        ServiceDiscoveryPayload::ProtocolV2(handshake) => {
515            let set = http_protocol_v2_ls_refs_advertisements(
516                client,
517                remote,
518                object_format,
519                service,
520                handshake.clone(),
521                credentials,
522                git_protocol.as_deref(),
523            )?;
524            Ok(HttpServiceAdvertisements {
525                set,
526                handshake: Some(handshake),
527            })
528        }
529    }
530}
531
532fn service_discovery_object_format(discovery: &ServiceDiscoveryResponse) -> Result<ObjectFormat> {
533    match &discovery.payload {
534        ServiceDiscoveryPayload::ProtocolV2(handshake) => {
535            protocol_v2_object_format(&handshake.capabilities)
536        }
537        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(set
538            .refs
539            .first()
540            .map(|reference| parse_upload_pack_features(&reference.capabilities))
541            .transpose()?
542            .and_then(|features| features.object_format)
543            .unwrap_or(ObjectFormat::Sha1)),
544    }
545}
546
547/// Discover upload-pack capabilities and object format before creating a clone.
548pub fn http_discover_upload_pack<C: HttpClient + ?Sized>(
549    client: &C,
550    remote: &RemoteUrl,
551    credentials: &mut dyn CredentialProvider,
552    config: Option<&GitConfig>,
553) -> Result<HttpUploadPackDiscovery> {
554    let advertisements = http_service_advertisements_with_expected_format(
555        client,
556        remote,
557        None,
558        GitService::UploadPack,
559        credentials,
560        config,
561    )?;
562    let features =
563        http_upload_pack_features(&advertisements.set.refs, advertisements.handshake.as_ref())?;
564    let object_format = features.object_format.unwrap_or(ObjectFormat::Sha1);
565    Ok(HttpUploadPackDiscovery {
566        advertisements,
567        features,
568        object_format,
569    })
570}
571
572fn read_http_service_discovery_response(
573    format: ObjectFormat,
574    reader: &mut impl Read,
575) -> Result<ServiceDiscoveryResponse> {
576    let mut bytes = Vec::new();
577    reader.read_to_end(&mut bytes)?;
578    let first = read_service_discovery_response(format, &mut bytes.as_slice());
579    let alternate = match format {
580        ObjectFormat::Sha1 => ObjectFormat::Sha256,
581        ObjectFormat::Sha256 => ObjectFormat::Sha1,
582    };
583    match first {
584        Ok(discovery) => Ok(discovery),
585        Err(original) => {
586            let retry = read_service_discovery_response(alternate, &mut bytes.as_slice());
587            match retry {
588                Ok(discovery) if discovery_advertises_object_format(&discovery, alternate) => {
589                    Ok(discovery)
590                }
591                _ => Err(original),
592            }
593        }
594    }
595}
596
597fn discovery_advertises_object_format(
598    discovery: &ServiceDiscoveryResponse,
599    format: ObjectFormat,
600) -> bool {
601    match &discovery.payload {
602        ServiceDiscoveryPayload::AdvertisedRefs(set) => set.refs.first().is_some_and(|reference| {
603            reference.capabilities.iter().any(|capability| {
604                capability.name == "object-format"
605                    && capability.value.as_deref() == Some(format.name())
606            })
607        }),
608        ServiceDiscoveryPayload::ProtocolV2(handshake) => {
609            handshake.capabilities.iter().any(|capability| {
610                capability.name == "object-format"
611                    && capability.value.as_deref() == Some(format.name())
612            })
613        }
614    }
615}
616
617/// The upload-pack ref advertisements and parsed features for `remote`.
618pub fn http_upload_pack_advertisements<C: HttpClient + ?Sized>(
619    client: &C,
620    remote: &RemoteUrl,
621    format: ObjectFormat,
622    credentials: &mut dyn CredentialProvider,
623    config: Option<&GitConfig>,
624) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
625    let discovered = http_service_advertisements(
626        client,
627        remote,
628        format,
629        GitService::UploadPack,
630        credentials,
631        config,
632    )?;
633    let features = http_upload_pack_features(&discovered.set.refs, discovered.handshake.as_ref())?;
634    Ok((discovered.set.refs, features))
635}
636
637/// Bridge protocol v2 handshake capabilities (filter/shallow/…) and v0/v1 ref
638/// advertisement capabilities into [`UploadPackFeatures`].
639pub fn http_upload_pack_features(
640    advertisements: &[RefAdvertisement],
641    handshake: Option<&TransportHandshake>,
642) -> Result<UploadPackFeatures> {
643    if let Some(handshake) = handshake {
644        let v2 = parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
645        let mut features = UploadPackFeatures {
646            object_format: Some(protocol_v2_object_format(&handshake.capabilities)?),
647            shallow: v2.shallow,
648            deepen_since: v2.shallow,
649            deepen_not: v2.shallow,
650            filter: v2.filter,
651            ..UploadPackFeatures::default()
652        };
653        if let Some(first) = advertisements.first() {
654            let bridged = parse_upload_pack_features(&first.capabilities)?;
655            features.symrefs = bridged.symrefs;
656        }
657        return Ok(features);
658    }
659    Ok(advertisements
660        .first()
661        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
662        .transpose()?
663        .unwrap_or_default())
664}
665
666/// Post an upload-pack RPC `request` + `haves` and return the validated HTTP
667/// response with its body still unread, so the caller can parse the packfile
668/// stream (with or without a leading shallow-info section). Authenticates and
669/// validates status; like Git's RPC path, the response body is parsed without
670/// enforcing a response content type.
671fn http_upload_pack_post<C: HttpClient + ?Sized>(
672    client: &C,
673    remote: &RemoteUrl,
674    request: &UploadPackRequest,
675    haves: Vec<ObjectId>,
676    credentials: &mut dyn CredentialProvider,
677    git_protocol: Option<&str>,
678    post_buffer: usize,
679) -> Result<HttpResponse> {
680    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
681    let mut body = Vec::new();
682    write_upload_pack_request(&mut body, Some(request))?;
683    write_upload_pack_negotiation_request(
684        &mut body,
685        &UploadPackNegotiationRequest { haves, done: true },
686    )?;
687    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
688    let response = http_send_with_auth(remote, credentials, |auth| {
689        http_post_rpc_body(
690            client,
691            &url,
692            &content_type,
693            &http_request_headers(auth, git_protocol),
694            &body,
695            post_buffer,
696        )
697    })?;
698    http_check_status(&response, &url)?;
699    // Git validates the discovery response's content type, but its stateless
700    // RPC path streams a successful POST body directly into the pkt-line
701    // parser. Preserve that distinction so malformed-response diagnostics
702    // report the wire truncation rather than being masked by an unusual CGI
703    // content type.
704    Ok(response)
705}
706
707/// Post a protocol v2 `fetch` RPC with `wants`/`haves`/`shallow`/`deepen` and
708/// read back the sectioned response. Authenticates and validates status. When
709/// the server advertises `sideband-all`, the request and response use the
710/// sideband-all wire form.
711pub fn http_protocol_v2_fetch_response<C: HttpClient + ?Sized>(
712    client: &C,
713    remote: &RemoteUrl,
714    format: ObjectFormat,
715    handshake: &TransportHandshake,
716    fetch: ProtocolV2FetchRequest,
717    credentials: &mut dyn CredentialProvider,
718    config: Option<&GitConfig>,
719) -> Result<Vec<ProtocolV2FetchResponseSection>> {
720    let git_protocol = http_git_protocol_header_value(config)?;
721    let post_buffer = http_post_buffer(config);
722    let sideband_all = fetch.sideband_all;
723    let mut response = http_protocol_v2_fetch_post(
724        client,
725        remote,
726        format,
727        handshake,
728        fetch,
729        credentials,
730        HttpRpcOptions {
731            git_protocol: git_protocol.as_deref(),
732            post_buffer,
733        },
734    )?;
735    if sideband_all {
736        Ok(read_protocol_v2_fetch_sideband_all_response(format, &mut response.body)?.sections)
737    } else {
738        read_protocol_v2_fetch_response(format, &mut response.body)
739    }
740}
741
742#[derive(Clone, Copy)]
743struct HttpRpcOptions<'a> {
744    git_protocol: Option<&'a str>,
745    post_buffer: usize,
746}
747
748fn http_protocol_v2_fetch_post<C: HttpClient + ?Sized>(
749    client: &C,
750    remote: &RemoteUrl,
751    format: ObjectFormat,
752    handshake: &TransportHandshake,
753    fetch: ProtocolV2FetchRequest,
754    credentials: &mut dyn CredentialProvider,
755    options: HttpRpcOptions<'_>,
756) -> Result<HttpResponse> {
757    let command = protocol_v2_fetch_command_request(format, handshake, &fetch)?;
758    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
759    let mut body = Vec::new();
760    write_protocol_v2_command_request(&mut body, &command)?;
761    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
762    let response = http_send_with_auth(remote, credentials, |auth| {
763        http_post_rpc_body(
764            client,
765            &url,
766            &content_type,
767            &http_request_headers(auth, options.git_protocol),
768            &body,
769            options.post_buffer,
770        )
771    })?;
772    http_check_status(&response, &url)?;
773    Ok(response)
774}
775
776/// Fetch `wants` from an HTTP upload-pack remote into the repository at `git_dir`,
777/// installing the resulting pack. Objects already present locally are skipped (for
778/// non-shallow fetches); `promisor` selects promisor-pack installation.
779///
780/// When `deepen` is set the fetch is shallow: the request replays `shallow` (the
781/// client's current boundary, read from `$GIT_DIR/shallow`) and asks the server to
782/// truncate history to `deepen` commits. The returned [`ProtocolV2FetchShallowInfo`]
783/// entries are the server's shallow-info updates the caller must fold into
784/// `$GIT_DIR/shallow` (see [`crate::apply_shallow_info`]); they are empty for a
785/// non-deepen fetch.
786pub struct HttpFetchPackRequest<'a, C: HttpClient + ?Sized> {
787    /// HTTP client used for smart-HTTP RPCs. Generic over [`HttpClient`] so a host
788    /// can inject a network-policy-enforcing client (e.g. an SSRF guard); the
789    /// default fetch/clone path uses [`UreqHttpClient`].
790    pub client: &'a C,
791    /// Local repository `$GIT_DIR`.
792    pub git_dir: &'a Path,
793    /// Local repository object format.
794    pub format: ObjectFormat,
795    /// Resolved HTTP(S) remote.
796    pub remote: &'a RemoteUrl,
797    /// Wanted object ids.
798    pub wants: Vec<ObjectId>,
799    /// Caller-selected negotiation haves. `None` means advertise the default
800    /// local haves.
801    pub haves: Option<Vec<ObjectId>>,
802    /// Existing shallow boundary to replay.
803    pub shallow: Vec<ObjectId>,
804    /// Requested deepen depth, if this is a shallow fetch.
805    pub deepen: Option<u32>,
806    /// Whether to install the response as a promisor pack.
807    pub promisor: bool,
808    /// Maximum raw pack bytes to accept from the remote (`fetch.maxInputSize` /
809    /// `transfer.maxSize`). `None` means unlimited.
810    pub max_input_size: Option<u64>,
811    pub filter: Option<sley_odb::PackObjectFilter>,
812    pub deepen_since: Option<i64>,
813    pub deepen_not: Vec<String>,
814    pub deepen_relative: bool,
815    pub git_protocol: Option<&'a str>,
816    /// Maximum buffered smart-HTTP request size (`http.postBuffer`). Larger
817    /// request bodies use chunked transfer encoding.
818    pub post_buffer: usize,
819    /// Send no `have` lines. Used by a partial clone's checkout-blob top-up
820    /// fetch: the client already has the commit whose tree references the wanted
821    /// blob, so advertising it as a `have` would make the server treat the blob
822    /// as already transferred (reachable from the have) and omit it. Suppressing
823    /// haves forces the server to send the explicitly wanted objects.
824    pub omit_haves: bool,
825}
826
827pub fn install_fetch_pack_via_http_upload_pack<C: HttpClient + ?Sized>(
828    request: HttpFetchPackRequest<'_, C>,
829    credentials: &mut dyn CredentialProvider,
830    progress: &mut dyn ProgressSink,
831) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
832    if request.wants.is_empty() {
833        return Ok(Vec::new());
834    }
835    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
836    // A deepen request must always reach the server (the shallow boundary may move
837    // even when every wanted object is already present), so only the plain fetch
838    // takes the "everything is local already" shortcut.
839    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
840        && request.filter.is_none()
841        && all_wants_present(&local_db, &request.wants)?
842    {
843        return Ok(Vec::new());
844    }
845    let upload_request = build_http_upload_pack_request(
846        request.wants,
847        request.shallow,
848        request.deepen,
849        request.deepen_since,
850        request.deepen_not,
851        request.filter.as_ref(),
852    );
853    let haves = request_haves(
854        request.git_dir,
855        request.format,
856        request.omit_haves,
857        request.haves.clone(),
858    )?;
859    if request.deepen.is_none() {
860        let mut response = http_upload_pack_post(
861            request.client,
862            request.remote,
863            &upload_request,
864            haves,
865            credentials,
866            request.git_protocol,
867            request.post_buffer,
868        )?;
869        if request.promisor {
870            install_upload_pack_packfile_promisor_response_from_reader(
871                request.format,
872                &mut response.body,
873                &local_db,
874                request.max_input_size,
875            )?;
876        } else {
877            install_upload_pack_packfile_response_from_reader(
878                request.format,
879                &mut response.body,
880                &ProgressInstaller::new(&local_db, progress),
881                request.max_input_size,
882            )?;
883        }
884        return Ok(Vec::new());
885    }
886
887    let mut response = http_upload_pack_post(
888        request.client,
889        request.remote,
890        &upload_request,
891        haves,
892        credentials,
893        request.git_protocol,
894        request.post_buffer,
895    )?;
896    let shallow_info = if request.promisor {
897        let (shallow_info, _) = install_upload_pack_shallow_packfile_promisor_response_from_reader(
898            request.format,
899            &mut response.body,
900            &local_db,
901            request.max_input_size,
902        )?;
903        shallow_info
904    } else {
905        let (shallow_info, _) = install_upload_pack_shallow_packfile_response_from_reader(
906            request.format,
907            &mut response.body,
908            &ProgressInstaller::new(&local_db, progress),
909            request.max_input_size,
910        )?;
911        shallow_info
912    };
913    Ok(shallow_info)
914}
915
916pub fn install_fetch_pack_via_http_protocol_v2_fetch<C: HttpClient + ?Sized>(
917    request: HttpFetchPackRequest<'_, C>,
918    handshake: &TransportHandshake,
919    credentials: &mut dyn CredentialProvider,
920    progress: &mut dyn ProgressSink,
921) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
922    if request.wants.is_empty() {
923        return Ok(Vec::new());
924    }
925    trace_protocol_v2_advertisement_read(handshake)?;
926    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
927    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
928        && request.filter.is_none()
929        && all_wants_present(&local_db, &request.wants)?
930    {
931        return Ok(Vec::new());
932    }
933    let haves = request_negotiation_haves(
934        request.git_dir,
935        request.format,
936        request.omit_haves,
937        request.haves.clone(),
938    )?;
939    let mut fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
940        request.wants,
941        haves,
942        request.shallow,
943        request.deepen,
944        request.deepen_since,
945        request.deepen_not,
946        request.deepen_relative,
947        request.filter.as_ref(),
948        handshake,
949    )?;
950    let sideband_all = fetch.sideband_all;
951    let all_haves = std::mem::take(&mut fetch.haves);
952    const INITIAL_HAVE_BATCH: usize = 16;
953    let mut sent_haves = all_haves.len().min(INITIAL_HAVE_BATCH);
954    fetch.haves = all_haves[..sent_haves].to_vec();
955    fetch.done = all_haves.is_empty();
956
957    loop {
958        let sent_done = fetch.done;
959        let wait_for_done = fetch.wait_for_done;
960        let mut response = http_protocol_v2_fetch_post(
961            request.client,
962            request.remote,
963            request.format,
964            handshake,
965            fetch.clone(),
966            credentials,
967            HttpRpcOptions {
968                git_protocol: request.git_protocol,
969                post_buffer: request.post_buffer,
970            },
971        )?;
972
973        let has_packfile = if sent_done {
974            true
975        } else {
976            let negotiation = read_protocol_v2_fetch_negotiation_response(
977                request.format,
978                &mut response.body,
979                sideband_all,
980                wait_for_done,
981            )?;
982            if negotiation.has_following_sections {
983                true
984            } else {
985                let ready = negotiation
986                    .acknowledgments
987                    .iter()
988                    .any(|ack| matches!(ack, ProtocolV2FetchAcknowledgment::Ready));
989                if ready || sent_haves == all_haves.len() {
990                    fetch.done = true;
991                    fetch.haves = all_haves.clone();
992                } else {
993                    sent_haves = (sent_haves * 2).min(all_haves.len());
994                    fetch.haves = all_haves[..sent_haves].to_vec();
995                }
996                false
997            }
998        };
999        if !has_packfile {
1000            continue;
1001        }
1002
1003        let (header, _install) = if request.promisor {
1004            install_protocol_v2_fetch_promisor_response_from_reader(
1005                request.format,
1006                &mut response.body,
1007                sideband_all,
1008                &local_db,
1009                request.max_input_size,
1010            )?
1011        } else {
1012            install_protocol_v2_fetch_response_from_reader(
1013                request.format,
1014                &mut response.body,
1015                sideband_all,
1016                &ProgressInstaller::new(&local_db, progress),
1017                request.max_input_size,
1018            )?
1019        };
1020        return Ok(shallow_info_from_protocol_v2_fetch_header(&header));
1021    }
1022}
1023
1024pub(crate) fn http_post_buffer(config: Option<&GitConfig>) -> usize {
1025    const DEFAULT_HTTP_POST_BUFFER: usize = 1 << 20;
1026    config
1027        .and_then(|config| config.get("http", None, "postBuffer"))
1028        .and_then(sley_config::parse_config_int)
1029        .and_then(|bytes| usize::try_from(bytes).ok())
1030        .filter(|bytes| *bytes > 0)
1031        .unwrap_or(DEFAULT_HTTP_POST_BUFFER)
1032}
1033
1034fn http_post_rpc_body<C: HttpClient + ?Sized>(
1035    client: &C,
1036    url: &str,
1037    content_type: &str,
1038    headers: &[(&str, &str)],
1039    body: &[u8],
1040    post_buffer: usize,
1041) -> Result<HttpResponse> {
1042    if body.len() > post_buffer {
1043        let mut reader = std::io::Cursor::new(body);
1044        client.post_reader(url, content_type, headers, &mut reader)
1045    } else {
1046        client.post(url, content_type, headers, body)
1047    }
1048}
1049
1050fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
1051    for want in wants {
1052        if !db.contains(want)? {
1053            return Ok(false);
1054        }
1055    }
1056    Ok(true)
1057}
1058
1059fn request_haves(
1060    git_dir: &Path,
1061    format: ObjectFormat,
1062    omit_haves: bool,
1063    custom_haves: Option<Vec<ObjectId>>,
1064) -> Result<Vec<ObjectId>> {
1065    if omit_haves {
1066        Ok(Vec::new())
1067    } else if let Some(haves) = custom_haves {
1068        Ok(haves)
1069    } else {
1070        crate::local::local_have_oids(git_dir, format)
1071    }
1072}
1073
1074fn request_negotiation_haves(
1075    git_dir: &Path,
1076    format: ObjectFormat,
1077    omit_haves: bool,
1078    custom_haves: Option<Vec<ObjectId>>,
1079) -> Result<Vec<ObjectId>> {
1080    if omit_haves {
1081        Ok(Vec::new())
1082    } else if let Some(haves) = custom_haves {
1083        Ok(haves)
1084    } else {
1085        crate::local::local_negotiation_have_oids(git_dir, format)
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092    use sley_protocol::{
1093        ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRecord,
1094        ProtocolVersion, RefAdvertisement, read_protocol_v2_fetch_response,
1095        write_protocol_v2_fetch_response, write_protocol_v2_ls_refs_response,
1096    };
1097    use std::sync::Mutex;
1098
1099    #[test]
1100    fn protocol_v1_header_is_sent_for_upload_and_receive_discovery() {
1101        let config = GitConfig::parse(b"[protocol]\n\tversion = 1\n").expect("config");
1102        assert_eq!(
1103            http_git_protocol_header_value_for_service(Some(&config), GitService::UploadPack)
1104                .expect("upload-pack header")
1105                .as_deref(),
1106            Some("version=1")
1107        );
1108        assert_eq!(
1109            http_git_protocol_header_value_for_service(Some(&config), GitService::ReceivePack)
1110                .expect("receive-pack header")
1111                .as_deref(),
1112            Some("version=1")
1113        );
1114
1115        let config = GitConfig::parse(b"[protocol]\n\tversion = 2\n").expect("config");
1116        assert_eq!(
1117            http_git_protocol_header_value_for_service(Some(&config), GitService::ReceivePack)
1118                .expect("receive-pack fallback"),
1119            None
1120        );
1121    }
1122
1123    struct PostModeClient {
1124        mode: Mutex<Option<&'static str>>,
1125    }
1126
1127    impl PostModeClient {
1128        fn response() -> Result<HttpResponse> {
1129            Ok(HttpResponse {
1130                status: 200,
1131                content_type: None,
1132                body: Box::new(std::io::empty()),
1133            })
1134        }
1135    }
1136
1137    impl HttpClient for PostModeClient {
1138        fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
1139            Self::response()
1140        }
1141
1142        fn post(
1143            &self,
1144            _url: &str,
1145            _content_type: &str,
1146            _headers: &[(&str, &str)],
1147            _body: &[u8],
1148        ) -> Result<HttpResponse> {
1149            *self.mode.lock().expect("post mode") = Some("buffered");
1150            Self::response()
1151        }
1152
1153        fn post_reader(
1154            &self,
1155            _url: &str,
1156            _content_type: &str,
1157            _headers: &[(&str, &str)],
1158            body: &mut dyn Read,
1159        ) -> Result<HttpResponse> {
1160            let mut consumed = Vec::new();
1161            body.read_to_end(&mut consumed)?;
1162            *self.mode.lock().expect("post mode") = Some("chunked");
1163            Self::response()
1164        }
1165    }
1166
1167    #[test]
1168    fn http_post_buffer_selects_buffered_or_chunked_rpc_body() {
1169        let config = GitConfig::parse(b"[http]\n\tpostBuffer = 64k\n").expect("config");
1170        assert_eq!(http_post_buffer(Some(&config)), 64 * 1024);
1171
1172        let client = PostModeClient {
1173            mode: Mutex::new(None),
1174        };
1175        http_post_rpc_body(
1176            &client,
1177            "http://example.test/rpc",
1178            "request",
1179            &[],
1180            b"1234",
1181            4,
1182        )
1183        .expect("buffered post");
1184        assert_eq!(*client.mode.lock().expect("mode"), Some("buffered"));
1185
1186        http_post_rpc_body(
1187            &client,
1188            "http://example.test/rpc",
1189            "request",
1190            &[],
1191            b"12345",
1192            4,
1193        )
1194        .expect("chunked post");
1195        assert_eq!(*client.mode.lock().expect("mode"), Some("chunked"));
1196    }
1197
1198    #[test]
1199    fn v1_upload_request_omits_unadvertised_ofs_delta() {
1200        let capabilities = upload_pack_request_capabilities(None, false);
1201        assert!(
1202            capabilities
1203                .iter()
1204                .any(|capability| capability.name == "side-band-64k")
1205        );
1206        assert!(
1207            capabilities
1208                .iter()
1209                .all(|capability| capability.name != "ofs-delta")
1210        );
1211    }
1212
1213    #[test]
1214    fn v1_discovery_adopts_explicit_sha256_object_format() {
1215        let response = ServiceDiscoveryResponse {
1216            announcement: sley_transport::ServiceAnnouncement {
1217                service: GitService::UploadPack,
1218            },
1219            payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
1220                protocol: ProtocolVersion::V1,
1221                refs: vec![RefAdvertisement {
1222                    oid: ObjectId::null(ObjectFormat::Sha256),
1223                    name: "capabilities^{}".into(),
1224                    capabilities: vec![Capability {
1225                        name: "object-format".into(),
1226                        value: Some("sha256".into()),
1227                    }],
1228                }],
1229                shallow: Vec::new(),
1230            }),
1231        };
1232        let mut encoded = Vec::new();
1233        sley_transport::write_service_discovery_response(&mut encoded, &response)
1234            .expect("discovery response");
1235
1236        let parsed =
1237            read_http_service_discovery_response(ObjectFormat::Sha1, &mut encoded.as_slice())
1238                .expect("adaptive discovery");
1239        let ServiceDiscoveryPayload::AdvertisedRefs(set) = parsed.payload else {
1240            panic!("expected advertised refs");
1241        };
1242        assert_eq!(set.protocol, ProtocolVersion::V1);
1243        assert_eq!(set.refs[0].oid.format(), ObjectFormat::Sha256);
1244    }
1245
1246    /// An [`HttpClient`] double that records POST dials and returns a canned
1247    /// upload-pack RPC result. Proves the smart-HTTP pack-fetch POST is driven by
1248    /// the injected client, not a crate-constructed ureq one.
1249    struct PostRecorder {
1250        result_content_type: String,
1251        post_calls: std::sync::atomic::AtomicUsize,
1252    }
1253
1254    impl HttpClient for PostRecorder {
1255        fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
1256            Err(GitError::Command(
1257                "recording client received an unexpected GET".into(),
1258            ))
1259        }
1260
1261        fn post(
1262            &self,
1263            _url: &str,
1264            _content_type: &str,
1265            _headers: &[(&str, &str)],
1266            _body: &[u8],
1267        ) -> Result<HttpResponse> {
1268            self.post_calls
1269                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1270            Ok(HttpResponse {
1271                status: 200,
1272                content_type: Some(self.result_content_type.clone()),
1273                body: Box::new(std::io::Cursor::new(Vec::new())),
1274            })
1275        }
1276    }
1277
1278    #[test]
1279    fn http_upload_pack_post_uses_injected_client() {
1280        let recorder = PostRecorder {
1281            result_content_type: sley_protocol::smart_http_rpc_result_content_type(
1282                GitService::UploadPack,
1283            )
1284            .expect("content type"),
1285            post_calls: std::sync::atomic::AtomicUsize::new(0),
1286        };
1287        let remote = parse_remote_url("http://example.invalid/repo.git").expect("url");
1288        let want = ObjectId::from_hex(
1289            ObjectFormat::Sha1,
1290            "1111111111111111111111111111111111111111",
1291        )
1292        .expect("oid");
1293        let request = UploadPackRequest {
1294            wants: vec![want],
1295            capabilities: Vec::new(),
1296            shallow: Vec::new(),
1297            deepen: None,
1298            deepen_since: None,
1299            deepen_not: Vec::new(),
1300            filter: None,
1301        };
1302        let mut credentials = crate::NoCredentials;
1303        let response = http_upload_pack_post(
1304            &recorder,
1305            &remote,
1306            &request,
1307            Vec::new(),
1308            &mut credentials,
1309            None,
1310            1 << 20,
1311        )
1312        .expect("post via injected client should succeed");
1313        assert_eq!(response.status, 200);
1314        assert_eq!(
1315            recorder
1316                .post_calls
1317                .load(std::sync::atomic::Ordering::Relaxed),
1318            1,
1319            "the injected client must own the pack-fetch POST dial"
1320        );
1321    }
1322
1323    fn sample_v2_handshake() -> TransportHandshake {
1324        TransportHandshake {
1325            protocol: ProtocolVersion::V2,
1326            capabilities: vec![
1327                Capability {
1328                    name: "ls-refs".into(),
1329                    value: Some("peel symrefs".into()),
1330                },
1331                Capability {
1332                    name: "agent".into(),
1333                    value: Some("git/2.54.0".into()),
1334                },
1335                Capability {
1336                    name: "object-format".into(),
1337                    value: Some("sha1".into()),
1338                },
1339            ],
1340        }
1341    }
1342
1343    #[test]
1344    fn protocol_v2_ls_refs_command_request_includes_agent_and_object_format() {
1345        let handshake = sample_v2_handshake();
1346        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
1347            .expect("test operation should succeed");
1348        assert_eq!(command.command, "ls-refs");
1349        assert_eq!(
1350            command.capabilities,
1351            vec![
1352                Capability {
1353                    name: "agent".into(),
1354                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1355                },
1356                Capability {
1357                    name: "object-format".into(),
1358                    value: Some("sha1".into()),
1359                },
1360            ]
1361        );
1362        assert_eq!(
1363            ProtocolV2LsRefsRequest::from_command_request(&command)
1364                .expect("test operation should succeed"),
1365            ProtocolV2LsRefsRequest {
1366                peel: true,
1367                symrefs: true,
1368                unborn: false,
1369                ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into(),],
1370            }
1371        );
1372    }
1373
1374    #[test]
1375    fn protocol_v2_ls_refs_command_request_omits_object_format_when_unadvertised() {
1376        let handshake = TransportHandshake {
1377            protocol: ProtocolVersion::V2,
1378            capabilities: vec![
1379                Capability {
1380                    name: "ls-refs".into(),
1381                    value: None,
1382                },
1383                Capability {
1384                    name: "agent".into(),
1385                    value: Some("git/2.54.0".into()),
1386                },
1387            ],
1388        };
1389        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
1390            .expect("test operation should succeed");
1391        assert_eq!(
1392            command.capabilities,
1393            vec![Capability {
1394                name: "agent".into(),
1395                value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1396            }]
1397        );
1398    }
1399
1400    #[test]
1401    fn protocol_v2_ls_refs_round_trip_bridges_into_ref_advertisement_set() {
1402        let handshake = sample_v2_handshake();
1403        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
1404            .expect("test operation should succeed");
1405        let head = ObjectId::from_hex(
1406            ObjectFormat::Sha1,
1407            "1111111111111111111111111111111111111111",
1408        )
1409        .expect("test operation should succeed");
1410        let tag = ObjectId::from_hex(
1411            ObjectFormat::Sha1,
1412            "2222222222222222222222222222222222222222",
1413        )
1414        .expect("test operation should succeed");
1415        let tag_peeled = ObjectId::from_hex(
1416            ObjectFormat::Sha1,
1417            "3333333333333333333333333333333333333333",
1418        )
1419        .expect("test operation should succeed");
1420        let records = vec![
1421            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1422                oid: head.clone(),
1423                name: "HEAD".into(),
1424                peeled: None,
1425                symref_target: Some("refs/heads/main".into()),
1426                attributes: Vec::new(),
1427            }),
1428            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1429                oid: head.clone(),
1430                name: "refs/heads/main".into(),
1431                peeled: None,
1432                symref_target: None,
1433                attributes: Vec::new(),
1434            }),
1435            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1436                oid: tag.clone(),
1437                name: "refs/tags/v1".into(),
1438                peeled: Some(tag_peeled.clone()),
1439                symref_target: None,
1440                attributes: Vec::new(),
1441            }),
1442        ];
1443
1444        let mut request_body = Vec::new();
1445        write_protocol_v2_command_request(&mut request_body, &command)
1446            .expect("test operation should succeed");
1447        let mut response_body = Vec::new();
1448        write_protocol_v2_ls_refs_response(&mut response_body, &records)
1449            .expect("test operation should succeed");
1450
1451        let set = read_protocol_v2_ls_refs_response_as_ref_advertisement_set(
1452            ObjectFormat::Sha1,
1453            &mut response_body.as_slice(),
1454        )
1455        .expect("test operation should succeed");
1456        assert_eq!(
1457            set,
1458            RefAdvertisementSet {
1459                protocol: ProtocolVersion::V2,
1460                refs: vec![
1461                    RefAdvertisement {
1462                        oid: head.clone(),
1463                        name: "HEAD".into(),
1464                        capabilities: vec![Capability {
1465                            name: "symref".into(),
1466                            value: Some("HEAD:refs/heads/main".into()),
1467                        }],
1468                    },
1469                    RefAdvertisement {
1470                        oid: head,
1471                        name: "refs/heads/main".into(),
1472                        capabilities: Vec::new(),
1473                    },
1474                    RefAdvertisement {
1475                        oid: tag,
1476                        name: "refs/tags/v1".into(),
1477                        capabilities: Vec::new(),
1478                    },
1479                    RefAdvertisement {
1480                        oid: tag_peeled,
1481                        name: "refs/tags/v1^{}".into(),
1482                        capabilities: Vec::new(),
1483                    },
1484                ],
1485                shallow: Vec::new(),
1486            }
1487        );
1488        assert!(!request_body.is_empty());
1489    }
1490
1491    fn sample_v2_fetch_handshake() -> TransportHandshake {
1492        TransportHandshake {
1493            protocol: ProtocolVersion::V2,
1494            capabilities: vec![
1495                Capability {
1496                    name: "fetch".into(),
1497                    value: Some("shallow sideband-all".into()),
1498                },
1499                Capability {
1500                    name: "agent".into(),
1501                    value: Some("git/2.54.0".into()),
1502                },
1503                Capability {
1504                    name: "object-format".into(),
1505                    value: Some("sha1".into()),
1506                },
1507            ],
1508        }
1509    }
1510
1511    #[test]
1512    fn protocol_v2_fetch_command_request_includes_agent_object_format_and_deepen() {
1513        let handshake = sample_v2_fetch_handshake();
1514        let want = ObjectId::from_hex(
1515            ObjectFormat::Sha1,
1516            "1111111111111111111111111111111111111111",
1517        )
1518        .expect("test operation should succeed");
1519        let have = ObjectId::from_hex(
1520            ObjectFormat::Sha1,
1521            "2222222222222222222222222222222222222222",
1522        )
1523        .expect("test operation should succeed");
1524        let shallow = ObjectId::from_hex(
1525            ObjectFormat::Sha1,
1526            "3333333333333333333333333333333333333333",
1527        )
1528        .expect("test operation should succeed");
1529        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1530            vec![want.clone()],
1531            vec![have.clone()],
1532            vec![shallow.clone()],
1533            Some(3),
1534            None,
1535            Vec::new(),
1536            false,
1537            None,
1538            &handshake,
1539        )
1540        .expect("test operation should succeed");
1541        assert!(fetch.sideband_all);
1542        assert!(fetch.done);
1543        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1544            .expect("test operation should succeed");
1545        assert_eq!(command.command, "fetch");
1546        assert_eq!(
1547            command.capabilities,
1548            vec![
1549                Capability {
1550                    name: "agent".into(),
1551                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1552                },
1553                Capability {
1554                    name: "object-format".into(),
1555                    value: Some("sha1".into()),
1556                },
1557            ]
1558        );
1559        assert_eq!(
1560            ProtocolV2FetchRequest::from_command_request(ObjectFormat::Sha1, &command)
1561                .expect("test operation should succeed"),
1562            ProtocolV2FetchRequest {
1563                wants: vec![want],
1564                haves: vec![have],
1565                shallow: vec![shallow],
1566                deepen: Some(3),
1567                thin_pack: true,
1568                include_tag: true,
1569                ofs_delta: true,
1570                done: true,
1571                sideband_all: true,
1572                ..ProtocolV2FetchRequest::default()
1573            }
1574        );
1575    }
1576
1577    #[test]
1578    fn protocol_v2_fetch_round_trip_extracts_shallow_info_and_packfile_sections() {
1579        let handshake = sample_v2_fetch_handshake();
1580        let want = ObjectId::from_hex(
1581            ObjectFormat::Sha1,
1582            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1583        )
1584        .expect("test operation should succeed");
1585        let shallow = ObjectId::from_hex(
1586            ObjectFormat::Sha1,
1587            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1588        )
1589        .expect("test operation should succeed");
1590        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1591            vec![want],
1592            Vec::new(),
1593            vec![shallow.clone()],
1594            Some(1),
1595            None,
1596            Vec::new(),
1597            false,
1598            None,
1599            &handshake,
1600        )
1601        .expect("test operation should succeed");
1602        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1603            .expect("test operation should succeed");
1604        let mut request_body = Vec::new();
1605        write_protocol_v2_command_request(&mut request_body, &command)
1606            .expect("test operation should succeed");
1607
1608        let sections = vec![
1609            ProtocolV2FetchResponseSection::ShallowInfo(vec![ProtocolV2FetchShallowInfo::Shallow(
1610                shallow,
1611            )]),
1612            ProtocolV2FetchResponseSection::Packfile(vec![b"PACK-test".to_vec()]),
1613        ];
1614        let mut response_body = Vec::new();
1615        write_protocol_v2_fetch_response(&mut response_body, &sections)
1616            .expect("test operation should succeed");
1617        let parsed =
1618            read_protocol_v2_fetch_response(ObjectFormat::Sha1, &mut response_body.as_slice())
1619                .expect("test operation should succeed");
1620        assert_eq!(parsed, sections);
1621        assert_eq!(
1622            parsed.first(),
1623            Some(&ProtocolV2FetchResponseSection::ShallowInfo(vec![
1624                ProtocolV2FetchShallowInfo::Shallow(
1625                    ObjectId::from_hex(
1626                        ObjectFormat::Sha1,
1627                        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1628                    )
1629                    .expect("test operation should succeed")
1630                )
1631            ]))
1632        );
1633        assert!(!request_body.is_empty());
1634    }
1635}