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::path::Path;
16
17use crate::install::{
18    install_protocol_v2_fetch_promisor_response_from_reader,
19    install_protocol_v2_fetch_response_from_reader,
20    install_upload_pack_packfile_promisor_response_from_reader,
21    install_upload_pack_packfile_response_from_reader,
22    install_upload_pack_shallow_packfile_promisor_response_from_reader,
23    install_upload_pack_shallow_packfile_response_from_reader,
24    shallow_info_from_protocol_v2_fetch_header, ProgressInstaller,
25};
26use sley_config::GitConfig;
27use sley_core::{
28    Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
29};
30use sley_odb::FileObjectDatabase;
31use sley_protocol::{
32    encode_protocol_v2_command_options, parse_protocol_v2_fetch_features,
33    parse_upload_pack_features, protocol_v2_object_format, read_protocol_v2_fetch_response,
34    read_protocol_v2_fetch_sideband_all_response,
35    read_protocol_v2_ls_refs_response_as_ref_advertisement_set,
36    smart_http_advertisement_content_type, smart_http_rpc_request_content_type,
37    smart_http_rpc_result_content_type, validate_protocol_v2_fetch_command_request,
38    validate_protocol_v2_ls_refs_command_request, write_protocol_v2_command_request,
39    write_upload_pack_negotiation_request, write_upload_pack_request, GitService,
40    ProtocolV2CommandOptions, ProtocolV2CommandRequest, ProtocolV2FetchRequest,
41    ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRequest,
42    ProtocolVersion, RefAdvertisement, RefAdvertisementSet, TransportHandshake, UploadPackFeatures,
43    UploadPackNegotiationRequest, UploadPackRequest,
44};
45use sley_transport::{
46    encode_git_protocol_header, git_credential_basic_authorization, http_smart_info_refs_url,
47    http_smart_rpc_url, parse_remote_url, read_service_discovery_response, GitProtocolHeader,
48    HttpClient, HttpResponse, RemoteTransport, RemoteUrl, ServiceDiscoveryPayload, UreqHttpClient,
49};
50
51use crate::credentials::{credential_request_for_url, http_url_credential};
52use crate::{CredentialProvider, ProgressSink};
53
54/// Whether an already-resolved remote `url` uses HTTP(S) transport.
55///
56/// Callers that start from a configured remote name or relative source resolve
57/// the URL first (the resolution is repository/process-state dependent and lives
58/// in the caller); this only classifies a concrete URL.
59pub fn remote_url_is_http(url: &str) -> Result<bool> {
60    Ok(matches!(
61        parse_remote_url(url)?.transport,
62        RemoteTransport::Http | RemoteTransport::Https
63    ))
64}
65
66/// Reusable HTTP client for every smart-HTTP RPC in one remote operation.
67pub struct HttpOperationBatch {
68    client: UreqHttpClient,
69}
70
71impl HttpOperationBatch {
72    pub fn new() -> Self {
73        Self {
74            client: UreqHttpClient::new(),
75        }
76    }
77    pub fn client(&self) -> &UreqHttpClient {
78        &self.client
79    }
80}
81
82impl Default for HttpOperationBatch {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88pub fn new_http_client() -> UreqHttpClient {
89    UreqHttpClient::new()
90}
91
92/// Perform an HTTP request, retrying once with credential-provider-supplied
93/// authentication if the first attempt returns 401. `perform` is invoked with an
94/// optional `Authorization` header value and must be idempotent (it may run twice).
95/// A successful retry approves the credential with `credentials`; a still-401
96/// retry rejects it.
97pub fn http_send_with_auth(
98    remote: &RemoteUrl,
99    credentials: &mut dyn CredentialProvider,
100    mut perform: impl FnMut(Option<&str>) -> Result<HttpResponse>,
101) -> Result<HttpResponse> {
102    let initial = http_url_credential(remote);
103    let initial_header = match &initial {
104        Some(credential) => git_credential_basic_authorization(credential)?,
105        None => None,
106    };
107    let response = perform(initial_header.as_deref())?;
108    if response.status != 401 {
109        return Ok(response);
110    }
111    let mut request = credential_request_for_url(remote);
112    if request.username.is_none() {
113        request.username = initial.and_then(|credential| credential.username);
114    }
115    let Some(filled) = credentials.fill(request)? else {
116        return Ok(response);
117    };
118    let Some(header) = git_credential_basic_authorization(&filled)? else {
119        return Ok(response);
120    };
121    let retry = perform(Some(&header))?;
122    if retry.status != 401 {
123        credentials.approve(&filled)?;
124    } else {
125        credentials.reject(&filled)?;
126    }
127    Ok(retry)
128}
129
130/// Resolve the client protocol version for smart HTTP, defaulting to v2 like upstream git.
131pub fn http_protocol_version_from_config(config: Option<&GitConfig>) -> Option<ProtocolVersion> {
132    match config.and_then(|config| config.get("protocol", None, "version")) {
133        Some("0") => Some(ProtocolVersion::V0),
134        Some("1") => Some(ProtocolVersion::V1),
135        Some("2") => Some(ProtocolVersion::V2),
136        _ => Some(ProtocolVersion::V2),
137    }
138}
139
140/// Encode the `Git-Protocol` request header value, if any (`None` for protocol v0).
141pub fn http_git_protocol_header_value(config: Option<&GitConfig>) -> Result<Option<String>> {
142    http_git_protocol_header_value_for_service(config, GitService::UploadPack)
143}
144
145/// Encode the `Git-Protocol` header for a specific smart-HTTP service.
146///
147/// Upstream `remote-curl.c` only negotiates protocol v2 for `git-upload-pack`;
148/// push (`git-receive-pack`) and other RPCs fall back to v0.
149pub fn http_git_protocol_header_value_for_service(
150    config: Option<&GitConfig>,
151    service: GitService,
152) -> Result<Option<String>> {
153    let mut version = http_protocol_version_from_config(config);
154    if matches!(version, Some(ProtocolVersion::V2)) && service != GitService::UploadPack {
155        version = Some(ProtocolVersion::V0);
156    }
157    match version {
158        Some(ProtocolVersion::V0) => Ok(None),
159        Some(version) => encode_git_protocol_header(&GitProtocolHeader {
160            protocol: Some(version),
161            extra_parameters: Vec::new(),
162        }),
163        None => Ok(None),
164    }
165}
166
167/// Build smart-HTTP request headers for an optional credential and `Git-Protocol` value.
168pub fn http_request_headers<'a>(
169    auth: Option<&'a str>,
170    git_protocol: Option<&'a str>,
171) -> Vec<(&'a str, &'a str)> {
172    let mut headers = Vec::with_capacity(2);
173    if let Some(value) = git_protocol {
174        headers.push(("Git-Protocol", value));
175    }
176    if let Some(value) = auth {
177        headers.push(("Authorization", value));
178    }
179    headers
180}
181
182/// Build the `Authorization` header list for an optional credential header value.
183pub fn http_authorization_headers(auth: Option<&str>) -> Vec<(&str, &str)> {
184    http_request_headers(auth, None)
185}
186
187/// Map an HTTP response status to success or a descriptive error for `url`.
188pub fn http_check_status(response: &HttpResponse, url: &str) -> Result<()> {
189    if (200..300).contains(&response.status) {
190        Ok(())
191    } else if response.status == 401 {
192        Err(GitError::Command(format!(
193            "authentication failed for {url}"
194        )))
195    } else {
196        Err(GitError::Command(format!(
197            "unexpected HTTP status {} for {url}",
198            response.status
199        )))
200    }
201}
202
203/// Verify the response `Content-Type` matches `expected` (ignoring parameters).
204pub fn http_validate_content_type(response: &HttpResponse, expected: &str) -> Result<()> {
205    let actual = response
206        .content_type
207        .as_deref()
208        .unwrap_or("")
209        .split(';')
210        .next()
211        .unwrap_or("")
212        .trim();
213    if actual.eq_ignore_ascii_case(expected) {
214        Ok(())
215    } else {
216        Err(GitError::InvalidFormat(format!(
217            "unexpected content type {actual:?}, expected {expected:?}"
218        )))
219    }
220}
221
222/// Result of smart-HTTP service discovery: parsed ref advertisements plus the
223/// protocol v2 handshake when the server negotiated v2 on the info/refs exchange.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct HttpServiceAdvertisements {
226    pub set: RefAdvertisementSet,
227    pub handshake: Option<TransportHandshake>,
228}
229
230/// Parse a smart-HTTP info/refs body into a ref advertisement set for protocol
231/// v0/v1. Protocol v2 discovery responses require a follow-up `ls-refs` RPC; use
232/// [`http_service_advertisements`] instead.
233pub fn http_advertised_refs(
234    format: ObjectFormat,
235    mut response: HttpResponse,
236) -> Result<RefAdvertisementSet> {
237    let discovery = read_service_discovery_response(format, &mut response.body)?;
238    match discovery.payload {
239        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(set),
240        ServiceDiscoveryPayload::ProtocolV2(_) => Err(GitError::Unsupported(
241            "protocol v2 advertisements over HTTP require an ls-refs RPC; use http_service_advertisements".into(),
242        )),
243    }
244}
245
246fn protocol_v2_ls_refs_command_request(
247    format: ObjectFormat,
248    handshake: &TransportHandshake,
249) -> Result<ProtocolV2CommandRequest> {
250    let ls_refs = ProtocolV2LsRefsRequest {
251        peel: true,
252        symrefs: true,
253        unborn: false,
254        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
255    };
256    let mut command = ls_refs.to_command_request()?;
257    let mut options = ProtocolV2CommandOptions::default();
258    if handshake
259        .capabilities
260        .iter()
261        .any(|capability| capability.name == "agent")
262    {
263        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
264    }
265    if handshake
266        .capabilities
267        .iter()
268        .any(|capability| capability.name == "object-format")
269    {
270        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
271        if advertised_format != format {
272            return Err(GitError::InvalidObjectId(format!(
273                "remote repository uses {}, local repository uses {}",
274                advertised_format.name(),
275                format.name()
276            )));
277        }
278        options.object_format = Some(format);
279    }
280    command.capabilities = encode_protocol_v2_command_options(&options)?;
281    validate_protocol_v2_ls_refs_command_request(handshake, &command)?;
282    Ok(command)
283}
284
285fn protocol_v2_fetch_command_request(
286    format: ObjectFormat,
287    handshake: &TransportHandshake,
288    fetch: &ProtocolV2FetchRequest,
289) -> Result<ProtocolV2CommandRequest> {
290    let mut command = fetch.to_command_request()?;
291    let mut options = ProtocolV2CommandOptions::default();
292    if handshake
293        .capabilities
294        .iter()
295        .any(|capability| capability.name == "agent")
296    {
297        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
298    }
299    if handshake
300        .capabilities
301        .iter()
302        .any(|capability| capability.name == "object-format")
303    {
304        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
305        if advertised_format != format {
306            return Err(GitError::InvalidObjectId(format!(
307                "remote repository uses {}, local repository uses {}",
308                advertised_format.name(),
309                format.name()
310            )));
311        }
312        options.object_format = Some(format);
313    }
314    command.capabilities = encode_protocol_v2_command_options(&options)?;
315    validate_protocol_v2_fetch_command_request(handshake, format, &command)?;
316    Ok(command)
317}
318
319fn protocol_v2_fetch_request_from_upload_pack_semantics(
320    wants: Vec<ObjectId>,
321    haves: Vec<ObjectId>,
322    shallow: Vec<ObjectId>,
323    deepen: Option<u32>,
324    deepen_since: Option<i64>,
325    deepen_not: Vec<String>,
326    deepen_relative: bool,
327    filter: Option<&sley_odb::PackObjectFilter>,
328    handshake: &TransportHandshake,
329) -> Result<ProtocolV2FetchRequest> {
330    let v2_features =
331        parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
332    Ok(ProtocolV2FetchRequest {
333        wants,
334        haves,
335        shallow,
336        deepen,
337        deepen_since: deepen_since_u64(deepen_since),
338        deepen_not,
339        deepen_relative,
340        filter: filter.and_then(crate::local::upload_pack_filter_protocol_spec),
341        thin_pack: true,
342        include_tag: true,
343        ofs_delta: true,
344        done: true,
345        wait_for_done: v2_features.wait_for_done,
346        sideband_all: v2_features.sideband_all,
347        ..ProtocolV2FetchRequest::default()
348    })
349}
350
351fn deepen_since_u64(deepen_since: Option<i64>) -> Option<u64> {
352    deepen_since.and_then(|value| u64::try_from(value).ok())
353}
354
355fn build_http_upload_pack_request(
356    wants: Vec<ObjectId>,
357    shallow: Vec<ObjectId>,
358    deepen: Option<u32>,
359    deepen_since: Option<i64>,
360    deepen_not: Vec<String>,
361    filter: Option<&sley_odb::PackObjectFilter>,
362) -> UploadPackRequest {
363    UploadPackRequest {
364        wants,
365        capabilities: upload_pack_request_capabilities(
366            deepen,
367            filter
368                .and_then(crate::local::upload_pack_filter_protocol_spec)
369                .is_some(),
370        ),
371        shallow,
372        deepen,
373        deepen_since: deepen_since_u64(deepen_since),
374        deepen_not,
375        filter: filter.and_then(crate::local::upload_pack_filter_protocol_spec),
376    }
377}
378
379fn upload_pack_request_capabilities(deepen: Option<u32>, filter: bool) -> Vec<Capability> {
380    let mut capabilities = Vec::new();
381    // The v0 upload-pack response reader demuxes a side-band-64k stream, so the
382    // request must negotiate it; otherwise the server streams a bare packfile
383    // and the reader mis-parses the leading `PACK` signature as a pkt-line
384    // length ("invalid pkt-line length byte 0x50"). ofs-delta matches git's
385    // default fetch capabilities.
386    capabilities.push(Capability {
387        name: "side-band-64k".into(),
388        value: None,
389    });
390    capabilities.push(Capability {
391        name: "ofs-delta".into(),
392        value: None,
393    });
394    if deepen.is_some() {
395        capabilities.push(Capability {
396            name: "shallow".into(),
397            value: None,
398        });
399    }
400    if filter {
401        capabilities.push(Capability {
402            name: "filter".into(),
403            value: None,
404        });
405    }
406    capabilities
407}
408
409fn request_replays_shallow_boundary(
410    deepen: Option<u32>,
411    deepen_since: Option<i64>,
412    deepen_not: &[String],
413) -> bool {
414    deepen.is_some() || deepen_since.is_some() || !deepen_not.is_empty()
415}
416
417fn http_protocol_v2_ls_refs_advertisements(
418    client: &UreqHttpClient,
419    remote: &RemoteUrl,
420    format: ObjectFormat,
421    service: GitService,
422    handshake: TransportHandshake,
423    credentials: &mut dyn CredentialProvider,
424    git_protocol: Option<&str>,
425) -> Result<RefAdvertisementSet> {
426    let command = protocol_v2_ls_refs_command_request(format, &handshake)?;
427    let url = http_smart_rpc_url(remote, service)?;
428    let mut body = Vec::new();
429    write_protocol_v2_command_request(&mut body, &command)?;
430    let content_type = smart_http_rpc_request_content_type(service)?;
431    let mut response = http_send_with_auth(remote, credentials, |auth| {
432        client.post(
433            &url,
434            &content_type,
435            &http_request_headers(auth, git_protocol),
436            &body,
437        )
438    })?;
439    http_check_status(&response, &url)?;
440    http_validate_content_type(&response, &smart_http_rpc_result_content_type(service)?)?;
441    read_protocol_v2_ls_refs_response_as_ref_advertisement_set(format, &mut response.body)
442}
443
444/// Fetch and parse the ref advertisements for `service` from the smart-HTTP
445/// info/refs endpoint, authenticating and validating status + content type.
446pub fn http_service_advertisements(
447    client: &UreqHttpClient,
448    remote: &RemoteUrl,
449    format: ObjectFormat,
450    service: GitService,
451    credentials: &mut dyn CredentialProvider,
452    config: Option<&GitConfig>,
453) -> Result<HttpServiceAdvertisements> {
454    let git_protocol = http_git_protocol_header_value_for_service(config, service)?;
455    let url = http_smart_info_refs_url(remote, service)?;
456    let mut response = http_send_with_auth(remote, credentials, |auth| {
457        client.get(&url, &http_request_headers(auth, git_protocol.as_deref()))
458    })?;
459    http_check_status(&response, &url)?;
460    http_validate_content_type(&response, &smart_http_advertisement_content_type(service)?)?;
461    let discovery = read_service_discovery_response(format, &mut response.body)?;
462    match discovery.payload {
463        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(HttpServiceAdvertisements {
464            set,
465            handshake: None,
466        }),
467        ServiceDiscoveryPayload::ProtocolV2(handshake) => {
468            let set = http_protocol_v2_ls_refs_advertisements(
469                client,
470                remote,
471                format,
472                service,
473                handshake.clone(),
474                credentials,
475                git_protocol.as_deref(),
476            )?;
477            Ok(HttpServiceAdvertisements {
478                set,
479                handshake: Some(handshake),
480            })
481        }
482    }
483}
484
485/// The upload-pack ref advertisements and parsed features for `remote`.
486pub fn http_upload_pack_advertisements(
487    client: &UreqHttpClient,
488    remote: &RemoteUrl,
489    format: ObjectFormat,
490    credentials: &mut dyn CredentialProvider,
491    config: Option<&GitConfig>,
492) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
493    let discovered = http_service_advertisements(
494        client,
495        remote,
496        format,
497        GitService::UploadPack,
498        credentials,
499        config,
500    )?;
501    let features = http_upload_pack_features(&discovered.set.refs, discovered.handshake.as_ref())?;
502    Ok((discovered.set.refs, features))
503}
504
505/// Bridge protocol v2 handshake capabilities (filter/shallow/…) and v0/v1 ref
506/// advertisement capabilities into [`UploadPackFeatures`].
507pub fn http_upload_pack_features(
508    advertisements: &[RefAdvertisement],
509    handshake: Option<&TransportHandshake>,
510) -> Result<UploadPackFeatures> {
511    if let Some(handshake) = handshake {
512        let v2 = parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
513        let mut features = UploadPackFeatures {
514            object_format: Some(protocol_v2_object_format(&handshake.capabilities)?),
515            shallow: v2.shallow,
516            deepen_since: v2.shallow,
517            deepen_not: v2.shallow,
518            filter: v2.filter,
519            ..UploadPackFeatures::default()
520        };
521        if let Some(first) = advertisements.first() {
522            let bridged = parse_upload_pack_features(&first.capabilities)?;
523            features.symrefs = bridged.symrefs;
524        }
525        return Ok(features);
526    }
527    Ok(advertisements
528        .first()
529        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
530        .transpose()?
531        .unwrap_or_default())
532}
533
534/// Post an upload-pack RPC `request` + `haves` and return the validated HTTP
535/// response with its body still unread, so the caller can parse the packfile
536/// stream (with or without a leading shallow-info section). Authenticates and
537/// validates status + content type.
538fn http_upload_pack_post(
539    client: &UreqHttpClient,
540    remote: &RemoteUrl,
541    request: &UploadPackRequest,
542    haves: Vec<ObjectId>,
543    credentials: &mut dyn CredentialProvider,
544    git_protocol: Option<&str>,
545) -> Result<HttpResponse> {
546    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
547    let mut body = Vec::new();
548    write_upload_pack_request(&mut body, Some(request))?;
549    write_upload_pack_negotiation_request(
550        &mut body,
551        &UploadPackNegotiationRequest { haves, done: true },
552    )?;
553    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
554    let response = http_send_with_auth(remote, credentials, |auth| {
555        client.post(
556            &url,
557            &content_type,
558            &http_request_headers(auth, git_protocol),
559            &body,
560        )
561    })?;
562    http_check_status(&response, &url)?;
563    http_validate_content_type(
564        &response,
565        &smart_http_rpc_result_content_type(GitService::UploadPack)?,
566    )?;
567    Ok(response)
568}
569
570/// Post a protocol v2 `fetch` RPC with `wants`/`haves`/`shallow`/`deepen` and
571/// read back the sectioned response. Authenticates and validates status + content
572/// type. When the server advertises `sideband-all`, the request and response use
573/// the sideband-all wire form.
574pub fn http_protocol_v2_fetch_response(
575    client: &UreqHttpClient,
576    remote: &RemoteUrl,
577    format: ObjectFormat,
578    handshake: &TransportHandshake,
579    fetch: ProtocolV2FetchRequest,
580    credentials: &mut dyn CredentialProvider,
581    config: Option<&GitConfig>,
582) -> Result<Vec<ProtocolV2FetchResponseSection>> {
583    let git_protocol = http_git_protocol_header_value(config)?;
584    let sideband_all = fetch.sideband_all;
585    let mut response = http_protocol_v2_fetch_post(
586        client,
587        remote,
588        format,
589        handshake,
590        fetch,
591        credentials,
592        git_protocol.as_deref(),
593    )?;
594    if sideband_all {
595        Ok(read_protocol_v2_fetch_sideband_all_response(format, &mut response.body)?.sections)
596    } else {
597        read_protocol_v2_fetch_response(format, &mut response.body)
598    }
599}
600
601fn http_protocol_v2_fetch_post(
602    client: &UreqHttpClient,
603    remote: &RemoteUrl,
604    format: ObjectFormat,
605    handshake: &TransportHandshake,
606    fetch: ProtocolV2FetchRequest,
607    credentials: &mut dyn CredentialProvider,
608    git_protocol: Option<&str>,
609) -> Result<HttpResponse> {
610    let command = protocol_v2_fetch_command_request(format, handshake, &fetch)?;
611    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
612    let mut body = Vec::new();
613    write_protocol_v2_command_request(&mut body, &command)?;
614    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
615    let response = http_send_with_auth(remote, credentials, |auth| {
616        client.post(
617            &url,
618            &content_type,
619            &http_request_headers(auth, git_protocol),
620            &body,
621        )
622    })?;
623    http_check_status(&response, &url)?;
624    http_validate_content_type(
625        &response,
626        &smart_http_rpc_result_content_type(GitService::UploadPack)?,
627    )?;
628    Ok(response)
629}
630
631/// Fetch `wants` from an HTTP upload-pack remote into the repository at `git_dir`,
632/// installing the resulting pack. Objects already present locally are skipped (for
633/// non-shallow fetches); `promisor` selects promisor-pack installation.
634///
635/// When `deepen` is set the fetch is shallow: the request replays `shallow` (the
636/// client's current boundary, read from `$GIT_DIR/shallow`) and asks the server to
637/// truncate history to `deepen` commits. The returned [`ProtocolV2FetchShallowInfo`]
638/// entries are the server's shallow-info updates the caller must fold into
639/// `$GIT_DIR/shallow` (see [`crate::apply_shallow_info`]); they are empty for a
640/// non-deepen fetch.
641pub struct HttpFetchPackRequest<'a> {
642    /// HTTP client used for smart-HTTP RPCs.
643    pub client: &'a UreqHttpClient,
644    /// Local repository `$GIT_DIR`.
645    pub git_dir: &'a Path,
646    /// Local repository object format.
647    pub format: ObjectFormat,
648    /// Resolved HTTP(S) remote.
649    pub remote: &'a RemoteUrl,
650    /// Wanted object ids.
651    pub wants: Vec<ObjectId>,
652    /// Caller-selected negotiation haves. `None` means advertise the default
653    /// local haves.
654    pub haves: Option<Vec<ObjectId>>,
655    /// Existing shallow boundary to replay.
656    pub shallow: Vec<ObjectId>,
657    /// Requested deepen depth, if this is a shallow fetch.
658    pub deepen: Option<u32>,
659    /// Whether to install the response as a promisor pack.
660    pub promisor: bool,
661    /// Maximum raw pack bytes to accept from the remote (`fetch.maxInputSize` /
662    /// `transfer.maxSize`). `None` means unlimited.
663    pub max_input_size: Option<u64>,
664    pub filter: Option<sley_odb::PackObjectFilter>,
665    pub deepen_since: Option<i64>,
666    pub deepen_not: Vec<String>,
667    pub deepen_relative: bool,
668    pub git_protocol: Option<&'a str>,
669    /// Send no `have` lines. Used by a partial clone's checkout-blob top-up
670    /// fetch: the client already has the commit whose tree references the wanted
671    /// blob, so advertising it as a `have` would make the server treat the blob
672    /// as already transferred (reachable from the have) and omit it. Suppressing
673    /// haves forces the server to send the explicitly wanted objects.
674    pub omit_haves: bool,
675}
676
677pub fn install_fetch_pack_via_http_upload_pack(
678    request: HttpFetchPackRequest<'_>,
679    credentials: &mut dyn CredentialProvider,
680    progress: &mut dyn ProgressSink,
681) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
682    if request.wants.is_empty() {
683        return Ok(Vec::new());
684    }
685    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
686    // A deepen request must always reach the server (the shallow boundary may move
687    // even when every wanted object is already present), so only the plain fetch
688    // takes the "everything is local already" shortcut.
689    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
690        && request.filter.is_none()
691        && all_wants_present(&local_db, &request.wants)?
692    {
693        return Ok(Vec::new());
694    }
695    let upload_request = build_http_upload_pack_request(
696        request.wants,
697        request.shallow,
698        request.deepen,
699        request.deepen_since,
700        request.deepen_not,
701        request.filter.as_ref(),
702    );
703    let haves = request_haves(
704        request.git_dir,
705        request.format,
706        request.omit_haves,
707        request.haves.clone(),
708    )?;
709    if request.deepen.is_none() {
710        let mut response = http_upload_pack_post(
711            request.client,
712            request.remote,
713            &upload_request,
714            haves,
715            credentials,
716            request.git_protocol,
717        )?;
718        if request.promisor {
719            install_upload_pack_packfile_promisor_response_from_reader(
720                request.format,
721                &mut response.body,
722                &local_db,
723                request.max_input_size,
724            )?;
725        } else {
726            install_upload_pack_packfile_response_from_reader(
727                request.format,
728                &mut response.body,
729                &ProgressInstaller::new(&local_db, progress),
730                request.max_input_size,
731            )?;
732        }
733        return Ok(Vec::new());
734    }
735
736    let mut response = http_upload_pack_post(
737        request.client,
738        request.remote,
739        &upload_request,
740        haves,
741        credentials,
742        request.git_protocol,
743    )?;
744    let shallow_info = if request.promisor {
745        let (shallow_info, _) = install_upload_pack_shallow_packfile_promisor_response_from_reader(
746            request.format,
747            &mut response.body,
748            &local_db,
749            request.max_input_size,
750        )?;
751        shallow_info
752    } else {
753        let (shallow_info, _) = install_upload_pack_shallow_packfile_response_from_reader(
754            request.format,
755            &mut response.body,
756            &ProgressInstaller::new(&local_db, progress),
757            request.max_input_size,
758        )?;
759        shallow_info
760    };
761    Ok(shallow_info)
762}
763
764pub fn install_fetch_pack_via_http_protocol_v2_fetch(
765    request: HttpFetchPackRequest<'_>,
766    handshake: &TransportHandshake,
767    credentials: &mut dyn CredentialProvider,
768    progress: &mut dyn ProgressSink,
769) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
770    if request.wants.is_empty() {
771        return Ok(Vec::new());
772    }
773    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
774    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
775        && request.filter.is_none()
776        && all_wants_present(&local_db, &request.wants)?
777    {
778        return Ok(Vec::new());
779    }
780    let haves = request_haves(
781        request.git_dir,
782        request.format,
783        request.omit_haves,
784        request.haves.clone(),
785    )?;
786    let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
787        request.wants,
788        haves,
789        request.shallow,
790        request.deepen,
791        request.deepen_since,
792        request.deepen_not,
793        request.deepen_relative,
794        request.filter.as_ref(),
795        handshake,
796    )?;
797    let sideband_all = fetch.sideband_all;
798    let mut response = http_protocol_v2_fetch_post(
799        request.client,
800        request.remote,
801        request.format,
802        handshake,
803        fetch,
804        credentials,
805        request.git_protocol,
806    )?;
807    let (header, _install) = if request.promisor {
808        install_protocol_v2_fetch_promisor_response_from_reader(
809            request.format,
810            &mut response.body,
811            sideband_all,
812            &local_db,
813            request.max_input_size,
814        )?
815    } else {
816        install_protocol_v2_fetch_response_from_reader(
817            request.format,
818            &mut response.body,
819            sideband_all,
820            &ProgressInstaller::new(&local_db, progress),
821            request.max_input_size,
822        )?
823    };
824    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
825}
826
827fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
828    for want in wants {
829        if !db.contains(want)? {
830            return Ok(false);
831        }
832    }
833    Ok(true)
834}
835
836fn request_haves(
837    git_dir: &Path,
838    format: ObjectFormat,
839    omit_haves: bool,
840    custom_haves: Option<Vec<ObjectId>>,
841) -> Result<Vec<ObjectId>> {
842    if omit_haves {
843        Ok(Vec::new())
844    } else if let Some(haves) = custom_haves {
845        Ok(haves)
846    } else {
847        crate::local::local_have_oids(git_dir, format)
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use sley_protocol::{
855        read_protocol_v2_fetch_response, write_protocol_v2_fetch_response,
856        write_protocol_v2_ls_refs_response, ProtocolV2FetchResponseSection,
857        ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRecord, ProtocolVersion, RefAdvertisement,
858    };
859
860    fn sample_v2_handshake() -> TransportHandshake {
861        TransportHandshake {
862            protocol: ProtocolVersion::V2,
863            capabilities: vec![
864                Capability {
865                    name: "ls-refs".into(),
866                    value: Some("peel symrefs".into()),
867                },
868                Capability {
869                    name: "agent".into(),
870                    value: Some("git/2.54.0".into()),
871                },
872                Capability {
873                    name: "object-format".into(),
874                    value: Some("sha1".into()),
875                },
876            ],
877        }
878    }
879
880    #[test]
881    fn protocol_v2_ls_refs_command_request_includes_agent_and_object_format() {
882        let handshake = sample_v2_handshake();
883        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
884            .expect("test operation should succeed");
885        assert_eq!(command.command, "ls-refs");
886        assert_eq!(
887            command.capabilities,
888            vec![
889                Capability {
890                    name: "agent".into(),
891                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
892                },
893                Capability {
894                    name: "object-format".into(),
895                    value: Some("sha1".into()),
896                },
897            ]
898        );
899        assert_eq!(
900            ProtocolV2LsRefsRequest::from_command_request(&command)
901                .expect("test operation should succeed"),
902            ProtocolV2LsRefsRequest {
903                peel: true,
904                symrefs: true,
905                unborn: false,
906                ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into(),],
907            }
908        );
909    }
910
911    #[test]
912    fn protocol_v2_ls_refs_command_request_omits_object_format_when_unadvertised() {
913        let handshake = TransportHandshake {
914            protocol: ProtocolVersion::V2,
915            capabilities: vec![
916                Capability {
917                    name: "ls-refs".into(),
918                    value: None,
919                },
920                Capability {
921                    name: "agent".into(),
922                    value: Some("git/2.54.0".into()),
923                },
924            ],
925        };
926        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
927            .expect("test operation should succeed");
928        assert_eq!(
929            command.capabilities,
930            vec![Capability {
931                name: "agent".into(),
932                value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
933            }]
934        );
935    }
936
937    #[test]
938    fn protocol_v2_ls_refs_round_trip_bridges_into_ref_advertisement_set() {
939        let handshake = sample_v2_handshake();
940        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
941            .expect("test operation should succeed");
942        let head = ObjectId::from_hex(
943            ObjectFormat::Sha1,
944            "1111111111111111111111111111111111111111",
945        )
946        .expect("test operation should succeed");
947        let tag = ObjectId::from_hex(
948            ObjectFormat::Sha1,
949            "2222222222222222222222222222222222222222",
950        )
951        .expect("test operation should succeed");
952        let tag_peeled = ObjectId::from_hex(
953            ObjectFormat::Sha1,
954            "3333333333333333333333333333333333333333",
955        )
956        .expect("test operation should succeed");
957        let records = vec![
958            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
959                oid: head.clone(),
960                name: "HEAD".into(),
961                peeled: None,
962                symref_target: Some("refs/heads/main".into()),
963                attributes: Vec::new(),
964            }),
965            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
966                oid: head.clone(),
967                name: "refs/heads/main".into(),
968                peeled: None,
969                symref_target: None,
970                attributes: Vec::new(),
971            }),
972            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
973                oid: tag.clone(),
974                name: "refs/tags/v1".into(),
975                peeled: Some(tag_peeled.clone()),
976                symref_target: None,
977                attributes: Vec::new(),
978            }),
979        ];
980
981        let mut request_body = Vec::new();
982        write_protocol_v2_command_request(&mut request_body, &command)
983            .expect("test operation should succeed");
984        let mut response_body = Vec::new();
985        write_protocol_v2_ls_refs_response(&mut response_body, &records)
986            .expect("test operation should succeed");
987
988        let set = read_protocol_v2_ls_refs_response_as_ref_advertisement_set(
989            ObjectFormat::Sha1,
990            &mut response_body.as_slice(),
991        )
992        .expect("test operation should succeed");
993        assert_eq!(
994            set,
995            RefAdvertisementSet {
996                protocol: ProtocolVersion::V2,
997                refs: vec![
998                    RefAdvertisement {
999                        oid: head.clone(),
1000                        name: "HEAD".into(),
1001                        capabilities: vec![Capability {
1002                            name: "symref".into(),
1003                            value: Some("HEAD:refs/heads/main".into()),
1004                        }],
1005                    },
1006                    RefAdvertisement {
1007                        oid: head,
1008                        name: "refs/heads/main".into(),
1009                        capabilities: Vec::new(),
1010                    },
1011                    RefAdvertisement {
1012                        oid: tag,
1013                        name: "refs/tags/v1".into(),
1014                        capabilities: Vec::new(),
1015                    },
1016                    RefAdvertisement {
1017                        oid: tag_peeled,
1018                        name: "refs/tags/v1^{}".into(),
1019                        capabilities: Vec::new(),
1020                    },
1021                ],
1022                shallow: Vec::new(),
1023            }
1024        );
1025        assert!(!request_body.is_empty());
1026    }
1027
1028    fn sample_v2_fetch_handshake() -> TransportHandshake {
1029        TransportHandshake {
1030            protocol: ProtocolVersion::V2,
1031            capabilities: vec![
1032                Capability {
1033                    name: "fetch".into(),
1034                    value: Some("shallow sideband-all".into()),
1035                },
1036                Capability {
1037                    name: "agent".into(),
1038                    value: Some("git/2.54.0".into()),
1039                },
1040                Capability {
1041                    name: "object-format".into(),
1042                    value: Some("sha1".into()),
1043                },
1044            ],
1045        }
1046    }
1047
1048    #[test]
1049    fn protocol_v2_fetch_command_request_includes_agent_object_format_and_deepen() {
1050        let handshake = sample_v2_fetch_handshake();
1051        let want = ObjectId::from_hex(
1052            ObjectFormat::Sha1,
1053            "1111111111111111111111111111111111111111",
1054        )
1055        .expect("test operation should succeed");
1056        let have = ObjectId::from_hex(
1057            ObjectFormat::Sha1,
1058            "2222222222222222222222222222222222222222",
1059        )
1060        .expect("test operation should succeed");
1061        let shallow = ObjectId::from_hex(
1062            ObjectFormat::Sha1,
1063            "3333333333333333333333333333333333333333",
1064        )
1065        .expect("test operation should succeed");
1066        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1067            vec![want.clone()],
1068            vec![have.clone()],
1069            vec![shallow.clone()],
1070            Some(3),
1071            None,
1072            Vec::new(),
1073            false,
1074            None,
1075            &handshake,
1076        )
1077        .expect("test operation should succeed");
1078        assert!(fetch.sideband_all);
1079        assert!(fetch.done);
1080        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1081            .expect("test operation should succeed");
1082        assert_eq!(command.command, "fetch");
1083        assert_eq!(
1084            command.capabilities,
1085            vec![
1086                Capability {
1087                    name: "agent".into(),
1088                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1089                },
1090                Capability {
1091                    name: "object-format".into(),
1092                    value: Some("sha1".into()),
1093                },
1094            ]
1095        );
1096        assert_eq!(
1097            ProtocolV2FetchRequest::from_command_request(ObjectFormat::Sha1, &command)
1098                .expect("test operation should succeed"),
1099            ProtocolV2FetchRequest {
1100                wants: vec![want],
1101                haves: vec![have],
1102                shallow: vec![shallow],
1103                deepen: Some(3),
1104                thin_pack: true,
1105                include_tag: true,
1106                ofs_delta: true,
1107                done: true,
1108                sideband_all: true,
1109                ..ProtocolV2FetchRequest::default()
1110            }
1111        );
1112    }
1113
1114    #[test]
1115    fn protocol_v2_fetch_round_trip_extracts_shallow_info_and_packfile_sections() {
1116        let handshake = sample_v2_fetch_handshake();
1117        let want = ObjectId::from_hex(
1118            ObjectFormat::Sha1,
1119            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1120        )
1121        .expect("test operation should succeed");
1122        let shallow = ObjectId::from_hex(
1123            ObjectFormat::Sha1,
1124            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1125        )
1126        .expect("test operation should succeed");
1127        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1128            vec![want],
1129            Vec::new(),
1130            vec![shallow.clone()],
1131            Some(1),
1132            None,
1133            Vec::new(),
1134            false,
1135            None,
1136            &handshake,
1137        )
1138        .expect("test operation should succeed");
1139        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1140            .expect("test operation should succeed");
1141        let mut request_body = Vec::new();
1142        write_protocol_v2_command_request(&mut request_body, &command)
1143            .expect("test operation should succeed");
1144
1145        let sections = vec![
1146            ProtocolV2FetchResponseSection::ShallowInfo(vec![ProtocolV2FetchShallowInfo::Shallow(
1147                shallow,
1148            )]),
1149            ProtocolV2FetchResponseSection::Packfile(vec![b"PACK-test".to_vec()]),
1150        ];
1151        let mut response_body = Vec::new();
1152        write_protocol_v2_fetch_response(&mut response_body, &sections)
1153            .expect("test operation should succeed");
1154        let parsed =
1155            read_protocol_v2_fetch_response(ObjectFormat::Sha1, &mut response_body.as_slice())
1156                .expect("test operation should succeed");
1157        assert_eq!(parsed, sections);
1158        assert_eq!(
1159            parsed.first(),
1160            Some(&ProtocolV2FetchResponseSection::ShallowInfo(vec![
1161                ProtocolV2FetchShallowInfo::Shallow(
1162                    ObjectId::from_hex(
1163                        ObjectFormat::Sha1,
1164                        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1165                    )
1166                    .expect("test operation should succeed")
1167                )
1168            ]))
1169        );
1170        assert!(!request_body.is_empty());
1171    }
1172}