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,
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;
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) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
681    if request.wants.is_empty() {
682        return Ok(Vec::new());
683    }
684    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
685    // A deepen request must always reach the server (the shallow boundary may move
686    // even when every wanted object is already present), so only the plain fetch
687    // takes the "everything is local already" shortcut.
688    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
689        && request.filter.is_none()
690        && all_wants_present(&local_db, &request.wants)?
691    {
692        return Ok(Vec::new());
693    }
694    let upload_request = build_http_upload_pack_request(
695        request.wants,
696        request.shallow,
697        request.deepen,
698        request.deepen_since,
699        request.deepen_not,
700        request.filter.as_ref(),
701    );
702    let haves = request_haves(
703        request.git_dir,
704        request.format,
705        request.omit_haves,
706        request.haves.clone(),
707    )?;
708    if request.deepen.is_none() {
709        let mut response = http_upload_pack_post(
710            request.client,
711            request.remote,
712            &upload_request,
713            haves,
714            credentials,
715            request.git_protocol,
716        )?;
717        if request.promisor {
718            install_upload_pack_packfile_promisor_response_from_reader(
719                request.format,
720                &mut response.body,
721                &local_db,
722                request.max_input_size,
723            )?;
724        } else {
725            install_upload_pack_packfile_response_from_reader(
726                request.format,
727                &mut response.body,
728                &local_db,
729                request.max_input_size,
730            )?;
731        }
732        return Ok(Vec::new());
733    }
734
735    let mut response = http_upload_pack_post(
736        request.client,
737        request.remote,
738        &upload_request,
739        haves,
740        credentials,
741        request.git_protocol,
742    )?;
743    let shallow_info = if request.promisor {
744        let (shallow_info, _) = install_upload_pack_shallow_packfile_promisor_response_from_reader(
745            request.format,
746            &mut response.body,
747            &local_db,
748            request.max_input_size,
749        )?;
750        shallow_info
751    } else {
752        let (shallow_info, _) = install_upload_pack_shallow_packfile_response_from_reader(
753            request.format,
754            &mut response.body,
755            &local_db,
756            request.max_input_size,
757        )?;
758        shallow_info
759    };
760    Ok(shallow_info)
761}
762
763pub fn install_fetch_pack_via_http_protocol_v2_fetch(
764    request: HttpFetchPackRequest<'_>,
765    handshake: &TransportHandshake,
766    credentials: &mut dyn CredentialProvider,
767) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
768    if request.wants.is_empty() {
769        return Ok(Vec::new());
770    }
771    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
772    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
773        && request.filter.is_none()
774        && all_wants_present(&local_db, &request.wants)?
775    {
776        return Ok(Vec::new());
777    }
778    let haves = request_haves(
779        request.git_dir,
780        request.format,
781        request.omit_haves,
782        request.haves.clone(),
783    )?;
784    let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
785        request.wants,
786        haves,
787        request.shallow,
788        request.deepen,
789        request.deepen_since,
790        request.deepen_not,
791        request.deepen_relative,
792        request.filter.as_ref(),
793        handshake,
794    )?;
795    let sideband_all = fetch.sideband_all;
796    let mut response = http_protocol_v2_fetch_post(
797        request.client,
798        request.remote,
799        request.format,
800        handshake,
801        fetch,
802        credentials,
803        request.git_protocol,
804    )?;
805    let (header, _install) = if request.promisor {
806        install_protocol_v2_fetch_promisor_response_from_reader(
807            request.format,
808            &mut response.body,
809            sideband_all,
810            &local_db,
811            request.max_input_size,
812        )?
813    } else {
814        install_protocol_v2_fetch_response_from_reader(
815            request.format,
816            &mut response.body,
817            sideband_all,
818            &local_db,
819            request.max_input_size,
820        )?
821    };
822    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
823}
824
825fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
826    for want in wants {
827        if !db.contains(want)? {
828            return Ok(false);
829        }
830    }
831    Ok(true)
832}
833
834fn request_haves(
835    git_dir: &Path,
836    format: ObjectFormat,
837    omit_haves: bool,
838    custom_haves: Option<Vec<ObjectId>>,
839) -> Result<Vec<ObjectId>> {
840    if omit_haves {
841        Ok(Vec::new())
842    } else if let Some(haves) = custom_haves {
843        Ok(haves)
844    } else {
845        crate::local::local_have_oids(git_dir, format)
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use sley_protocol::{
853        read_protocol_v2_fetch_response, write_protocol_v2_fetch_response,
854        write_protocol_v2_ls_refs_response, ProtocolV2FetchResponseSection,
855        ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRecord, ProtocolVersion, RefAdvertisement,
856    };
857
858    fn sample_v2_handshake() -> TransportHandshake {
859        TransportHandshake {
860            protocol: ProtocolVersion::V2,
861            capabilities: vec![
862                Capability {
863                    name: "ls-refs".into(),
864                    value: Some("peel symrefs".into()),
865                },
866                Capability {
867                    name: "agent".into(),
868                    value: Some("git/2.54.0".into()),
869                },
870                Capability {
871                    name: "object-format".into(),
872                    value: Some("sha1".into()),
873                },
874            ],
875        }
876    }
877
878    #[test]
879    fn protocol_v2_ls_refs_command_request_includes_agent_and_object_format() {
880        let handshake = sample_v2_handshake();
881        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
882            .expect("test operation should succeed");
883        assert_eq!(command.command, "ls-refs");
884        assert_eq!(
885            command.capabilities,
886            vec![
887                Capability {
888                    name: "agent".into(),
889                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
890                },
891                Capability {
892                    name: "object-format".into(),
893                    value: Some("sha1".into()),
894                },
895            ]
896        );
897        assert_eq!(
898            ProtocolV2LsRefsRequest::from_command_request(&command)
899                .expect("test operation should succeed"),
900            ProtocolV2LsRefsRequest {
901                peel: true,
902                symrefs: true,
903                unborn: false,
904                ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into(),],
905            }
906        );
907    }
908
909    #[test]
910    fn protocol_v2_ls_refs_command_request_omits_object_format_when_unadvertised() {
911        let handshake = TransportHandshake {
912            protocol: ProtocolVersion::V2,
913            capabilities: vec![
914                Capability {
915                    name: "ls-refs".into(),
916                    value: None,
917                },
918                Capability {
919                    name: "agent".into(),
920                    value: Some("git/2.54.0".into()),
921                },
922            ],
923        };
924        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
925            .expect("test operation should succeed");
926        assert_eq!(
927            command.capabilities,
928            vec![Capability {
929                name: "agent".into(),
930                value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
931            }]
932        );
933    }
934
935    #[test]
936    fn protocol_v2_ls_refs_round_trip_bridges_into_ref_advertisement_set() {
937        let handshake = sample_v2_handshake();
938        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
939            .expect("test operation should succeed");
940        let head = ObjectId::from_hex(
941            ObjectFormat::Sha1,
942            "1111111111111111111111111111111111111111",
943        )
944        .expect("test operation should succeed");
945        let tag = ObjectId::from_hex(
946            ObjectFormat::Sha1,
947            "2222222222222222222222222222222222222222",
948        )
949        .expect("test operation should succeed");
950        let tag_peeled = ObjectId::from_hex(
951            ObjectFormat::Sha1,
952            "3333333333333333333333333333333333333333",
953        )
954        .expect("test operation should succeed");
955        let records = vec![
956            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
957                oid: head.clone(),
958                name: "HEAD".into(),
959                peeled: None,
960                symref_target: Some("refs/heads/main".into()),
961                attributes: Vec::new(),
962            }),
963            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
964                oid: head.clone(),
965                name: "refs/heads/main".into(),
966                peeled: None,
967                symref_target: None,
968                attributes: Vec::new(),
969            }),
970            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
971                oid: tag.clone(),
972                name: "refs/tags/v1".into(),
973                peeled: Some(tag_peeled.clone()),
974                symref_target: None,
975                attributes: Vec::new(),
976            }),
977        ];
978
979        let mut request_body = Vec::new();
980        write_protocol_v2_command_request(&mut request_body, &command)
981            .expect("test operation should succeed");
982        let mut response_body = Vec::new();
983        write_protocol_v2_ls_refs_response(&mut response_body, &records)
984            .expect("test operation should succeed");
985
986        let set = read_protocol_v2_ls_refs_response_as_ref_advertisement_set(
987            ObjectFormat::Sha1,
988            &mut response_body.as_slice(),
989        )
990        .expect("test operation should succeed");
991        assert_eq!(
992            set,
993            RefAdvertisementSet {
994                protocol: ProtocolVersion::V2,
995                refs: vec![
996                    RefAdvertisement {
997                        oid: head.clone(),
998                        name: "HEAD".into(),
999                        capabilities: vec![Capability {
1000                            name: "symref".into(),
1001                            value: Some("HEAD:refs/heads/main".into()),
1002                        }],
1003                    },
1004                    RefAdvertisement {
1005                        oid: head,
1006                        name: "refs/heads/main".into(),
1007                        capabilities: Vec::new(),
1008                    },
1009                    RefAdvertisement {
1010                        oid: tag,
1011                        name: "refs/tags/v1".into(),
1012                        capabilities: Vec::new(),
1013                    },
1014                    RefAdvertisement {
1015                        oid: tag_peeled,
1016                        name: "refs/tags/v1^{}".into(),
1017                        capabilities: Vec::new(),
1018                    },
1019                ],
1020                shallow: Vec::new(),
1021            }
1022        );
1023        assert!(!request_body.is_empty());
1024    }
1025
1026    fn sample_v2_fetch_handshake() -> TransportHandshake {
1027        TransportHandshake {
1028            protocol: ProtocolVersion::V2,
1029            capabilities: vec![
1030                Capability {
1031                    name: "fetch".into(),
1032                    value: Some("shallow sideband-all".into()),
1033                },
1034                Capability {
1035                    name: "agent".into(),
1036                    value: Some("git/2.54.0".into()),
1037                },
1038                Capability {
1039                    name: "object-format".into(),
1040                    value: Some("sha1".into()),
1041                },
1042            ],
1043        }
1044    }
1045
1046    #[test]
1047    fn protocol_v2_fetch_command_request_includes_agent_object_format_and_deepen() {
1048        let handshake = sample_v2_fetch_handshake();
1049        let want = ObjectId::from_hex(
1050            ObjectFormat::Sha1,
1051            "1111111111111111111111111111111111111111",
1052        )
1053        .expect("test operation should succeed");
1054        let have = ObjectId::from_hex(
1055            ObjectFormat::Sha1,
1056            "2222222222222222222222222222222222222222",
1057        )
1058        .expect("test operation should succeed");
1059        let shallow = ObjectId::from_hex(
1060            ObjectFormat::Sha1,
1061            "3333333333333333333333333333333333333333",
1062        )
1063        .expect("test operation should succeed");
1064        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1065            vec![want.clone()],
1066            vec![have.clone()],
1067            vec![shallow.clone()],
1068            Some(3),
1069            None,
1070            Vec::new(),
1071            false,
1072            None,
1073            &handshake,
1074        )
1075        .expect("test operation should succeed");
1076        assert!(fetch.sideband_all);
1077        assert!(fetch.done);
1078        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1079            .expect("test operation should succeed");
1080        assert_eq!(command.command, "fetch");
1081        assert_eq!(
1082            command.capabilities,
1083            vec![
1084                Capability {
1085                    name: "agent".into(),
1086                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1087                },
1088                Capability {
1089                    name: "object-format".into(),
1090                    value: Some("sha1".into()),
1091                },
1092            ]
1093        );
1094        assert_eq!(
1095            ProtocolV2FetchRequest::from_command_request(ObjectFormat::Sha1, &command)
1096                .expect("test operation should succeed"),
1097            ProtocolV2FetchRequest {
1098                wants: vec![want],
1099                haves: vec![have],
1100                shallow: vec![shallow],
1101                deepen: Some(3),
1102                thin_pack: true,
1103                include_tag: true,
1104                ofs_delta: true,
1105                done: true,
1106                sideband_all: true,
1107                ..ProtocolV2FetchRequest::default()
1108            }
1109        );
1110    }
1111
1112    #[test]
1113    fn protocol_v2_fetch_round_trip_extracts_shallow_info_and_packfile_sections() {
1114        let handshake = sample_v2_fetch_handshake();
1115        let want = ObjectId::from_hex(
1116            ObjectFormat::Sha1,
1117            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1118        )
1119        .expect("test operation should succeed");
1120        let shallow = ObjectId::from_hex(
1121            ObjectFormat::Sha1,
1122            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1123        )
1124        .expect("test operation should succeed");
1125        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1126            vec![want],
1127            Vec::new(),
1128            vec![shallow.clone()],
1129            Some(1),
1130            None,
1131            Vec::new(),
1132            false,
1133            None,
1134            &handshake,
1135        )
1136        .expect("test operation should succeed");
1137        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1138            .expect("test operation should succeed");
1139        let mut request_body = Vec::new();
1140        write_protocol_v2_command_request(&mut request_body, &command)
1141            .expect("test operation should succeed");
1142
1143        let sections = vec![
1144            ProtocolV2FetchResponseSection::ShallowInfo(vec![ProtocolV2FetchShallowInfo::Shallow(
1145                shallow,
1146            )]),
1147            ProtocolV2FetchResponseSection::Packfile(vec![b"PACK-test".to_vec()]),
1148        ];
1149        let mut response_body = Vec::new();
1150        write_protocol_v2_fetch_response(&mut response_body, &sections)
1151            .expect("test operation should succeed");
1152        let parsed =
1153            read_protocol_v2_fetch_response(ObjectFormat::Sha1, &mut response_body.as_slice())
1154                .expect("test operation should succeed");
1155        assert_eq!(parsed, sections);
1156        assert_eq!(
1157            parsed.first(),
1158            Some(&ProtocolV2FetchResponseSection::ShallowInfo(vec![
1159                ProtocolV2FetchShallowInfo::Shallow(
1160                    ObjectId::from_hex(
1161                        ObjectFormat::Sha1,
1162                        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1163                    )
1164                    .expect("test operation should succeed")
1165                )
1166            ]))
1167        );
1168        assert!(!request_body.is_empty());
1169    }
1170}