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<C: HttpClient + ?Sized>(
418    client: &C,
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<C: HttpClient + ?Sized>(
447    client: &C,
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<C: HttpClient + ?Sized>(
487    client: &C,
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<C: HttpClient + ?Sized>(
539    client: &C,
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<C: HttpClient + ?Sized>(
575    client: &C,
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<C: HttpClient + ?Sized>(
602    client: &C,
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, C: HttpClient + ?Sized> {
642    /// HTTP client used for smart-HTTP RPCs. Generic over [`HttpClient`] so a host
643    /// can inject a network-policy-enforcing client (e.g. an SSRF guard); the
644    /// default fetch/clone path uses [`UreqHttpClient`].
645    pub client: &'a C,
646    /// Local repository `$GIT_DIR`.
647    pub git_dir: &'a Path,
648    /// Local repository object format.
649    pub format: ObjectFormat,
650    /// Resolved HTTP(S) remote.
651    pub remote: &'a RemoteUrl,
652    /// Wanted object ids.
653    pub wants: Vec<ObjectId>,
654    /// Caller-selected negotiation haves. `None` means advertise the default
655    /// local haves.
656    pub haves: Option<Vec<ObjectId>>,
657    /// Existing shallow boundary to replay.
658    pub shallow: Vec<ObjectId>,
659    /// Requested deepen depth, if this is a shallow fetch.
660    pub deepen: Option<u32>,
661    /// Whether to install the response as a promisor pack.
662    pub promisor: bool,
663    /// Maximum raw pack bytes to accept from the remote (`fetch.maxInputSize` /
664    /// `transfer.maxSize`). `None` means unlimited.
665    pub max_input_size: Option<u64>,
666    pub filter: Option<sley_odb::PackObjectFilter>,
667    pub deepen_since: Option<i64>,
668    pub deepen_not: Vec<String>,
669    pub deepen_relative: bool,
670    pub git_protocol: Option<&'a str>,
671    /// Send no `have` lines. Used by a partial clone's checkout-blob top-up
672    /// fetch: the client already has the commit whose tree references the wanted
673    /// blob, so advertising it as a `have` would make the server treat the blob
674    /// as already transferred (reachable from the have) and omit it. Suppressing
675    /// haves forces the server to send the explicitly wanted objects.
676    pub omit_haves: bool,
677}
678
679pub fn install_fetch_pack_via_http_upload_pack<C: HttpClient + ?Sized>(
680    request: HttpFetchPackRequest<'_, C>,
681    credentials: &mut dyn CredentialProvider,
682    progress: &mut dyn ProgressSink,
683) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
684    if request.wants.is_empty() {
685        return Ok(Vec::new());
686    }
687    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
688    // A deepen request must always reach the server (the shallow boundary may move
689    // even when every wanted object is already present), so only the plain fetch
690    // takes the "everything is local already" shortcut.
691    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
692        && request.filter.is_none()
693        && all_wants_present(&local_db, &request.wants)?
694    {
695        return Ok(Vec::new());
696    }
697    let upload_request = build_http_upload_pack_request(
698        request.wants,
699        request.shallow,
700        request.deepen,
701        request.deepen_since,
702        request.deepen_not,
703        request.filter.as_ref(),
704    );
705    let haves = request_haves(
706        request.git_dir,
707        request.format,
708        request.omit_haves,
709        request.haves.clone(),
710    )?;
711    if request.deepen.is_none() {
712        let mut response = http_upload_pack_post(
713            request.client,
714            request.remote,
715            &upload_request,
716            haves,
717            credentials,
718            request.git_protocol,
719        )?;
720        if request.promisor {
721            install_upload_pack_packfile_promisor_response_from_reader(
722                request.format,
723                &mut response.body,
724                &local_db,
725                request.max_input_size,
726            )?;
727        } else {
728            install_upload_pack_packfile_response_from_reader(
729                request.format,
730                &mut response.body,
731                &ProgressInstaller::new(&local_db, progress),
732                request.max_input_size,
733            )?;
734        }
735        return Ok(Vec::new());
736    }
737
738    let mut response = http_upload_pack_post(
739        request.client,
740        request.remote,
741        &upload_request,
742        haves,
743        credentials,
744        request.git_protocol,
745    )?;
746    let shallow_info = if request.promisor {
747        let (shallow_info, _) = install_upload_pack_shallow_packfile_promisor_response_from_reader(
748            request.format,
749            &mut response.body,
750            &local_db,
751            request.max_input_size,
752        )?;
753        shallow_info
754    } else {
755        let (shallow_info, _) = install_upload_pack_shallow_packfile_response_from_reader(
756            request.format,
757            &mut response.body,
758            &ProgressInstaller::new(&local_db, progress),
759            request.max_input_size,
760        )?;
761        shallow_info
762    };
763    Ok(shallow_info)
764}
765
766pub fn install_fetch_pack_via_http_protocol_v2_fetch<C: HttpClient + ?Sized>(
767    request: HttpFetchPackRequest<'_, C>,
768    handshake: &TransportHandshake,
769    credentials: &mut dyn CredentialProvider,
770    progress: &mut dyn ProgressSink,
771) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
772    if request.wants.is_empty() {
773        return Ok(Vec::new());
774    }
775    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
776    if !request_replays_shallow_boundary(request.deepen, request.deepen_since, &request.deepen_not)
777        && request.filter.is_none()
778        && all_wants_present(&local_db, &request.wants)?
779    {
780        return Ok(Vec::new());
781    }
782    let haves = request_haves(
783        request.git_dir,
784        request.format,
785        request.omit_haves,
786        request.haves.clone(),
787    )?;
788    let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
789        request.wants,
790        haves,
791        request.shallow,
792        request.deepen,
793        request.deepen_since,
794        request.deepen_not,
795        request.deepen_relative,
796        request.filter.as_ref(),
797        handshake,
798    )?;
799    let sideband_all = fetch.sideband_all;
800    let mut response = http_protocol_v2_fetch_post(
801        request.client,
802        request.remote,
803        request.format,
804        handshake,
805        fetch,
806        credentials,
807        request.git_protocol,
808    )?;
809    let (header, _install) = if request.promisor {
810        install_protocol_v2_fetch_promisor_response_from_reader(
811            request.format,
812            &mut response.body,
813            sideband_all,
814            &local_db,
815            request.max_input_size,
816        )?
817    } else {
818        install_protocol_v2_fetch_response_from_reader(
819            request.format,
820            &mut response.body,
821            sideband_all,
822            &ProgressInstaller::new(&local_db, progress),
823            request.max_input_size,
824        )?
825    };
826    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
827}
828
829fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
830    for want in wants {
831        if !db.contains(want)? {
832            return Ok(false);
833        }
834    }
835    Ok(true)
836}
837
838fn request_haves(
839    git_dir: &Path,
840    format: ObjectFormat,
841    omit_haves: bool,
842    custom_haves: Option<Vec<ObjectId>>,
843) -> Result<Vec<ObjectId>> {
844    if omit_haves {
845        Ok(Vec::new())
846    } else if let Some(haves) = custom_haves {
847        Ok(haves)
848    } else {
849        crate::local::local_have_oids(git_dir, format)
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use sley_protocol::{
857        read_protocol_v2_fetch_response, write_protocol_v2_fetch_response,
858        write_protocol_v2_ls_refs_response, ProtocolV2FetchResponseSection,
859        ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRecord, ProtocolVersion, RefAdvertisement,
860    };
861
862    /// An [`HttpClient`] double that records POST dials and returns a canned
863    /// upload-pack RPC result. Proves the smart-HTTP pack-fetch POST is driven by
864    /// the injected client, not a crate-constructed ureq one.
865    struct PostRecorder {
866        result_content_type: String,
867        post_calls: std::sync::atomic::AtomicUsize,
868    }
869
870    impl HttpClient for PostRecorder {
871        fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
872            Err(GitError::Command("recording client received an unexpected GET".into()))
873        }
874
875        fn post(
876            &self,
877            _url: &str,
878            _content_type: &str,
879            _headers: &[(&str, &str)],
880            _body: &[u8],
881        ) -> Result<HttpResponse> {
882            self.post_calls
883                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
884            Ok(HttpResponse {
885                status: 200,
886                content_type: Some(self.result_content_type.clone()),
887                body: Box::new(std::io::Cursor::new(Vec::new())),
888            })
889        }
890    }
891
892    #[test]
893    fn http_upload_pack_post_uses_injected_client() {
894        let recorder = PostRecorder {
895            result_content_type: smart_http_rpc_result_content_type(GitService::UploadPack)
896                .expect("content type"),
897            post_calls: std::sync::atomic::AtomicUsize::new(0),
898        };
899        let remote = parse_remote_url("http://example.invalid/repo.git").expect("url");
900        let want = ObjectId::from_hex(
901            ObjectFormat::Sha1,
902            "1111111111111111111111111111111111111111",
903        )
904        .expect("oid");
905        let request = UploadPackRequest {
906            wants: vec![want],
907            capabilities: Vec::new(),
908            shallow: Vec::new(),
909            deepen: None,
910            deepen_since: None,
911            deepen_not: Vec::new(),
912            filter: None,
913        };
914        let mut credentials = crate::NoCredentials;
915        let response = http_upload_pack_post(
916            &recorder,
917            &remote,
918            &request,
919            Vec::new(),
920            &mut credentials,
921            None,
922        )
923        .expect("post via injected client should succeed");
924        assert_eq!(response.status, 200);
925        assert_eq!(
926            recorder
927                .post_calls
928                .load(std::sync::atomic::Ordering::Relaxed),
929            1,
930            "the injected client must own the pack-fetch POST dial"
931        );
932    }
933
934    fn sample_v2_handshake() -> TransportHandshake {
935        TransportHandshake {
936            protocol: ProtocolVersion::V2,
937            capabilities: vec![
938                Capability {
939                    name: "ls-refs".into(),
940                    value: Some("peel symrefs".into()),
941                },
942                Capability {
943                    name: "agent".into(),
944                    value: Some("git/2.54.0".into()),
945                },
946                Capability {
947                    name: "object-format".into(),
948                    value: Some("sha1".into()),
949                },
950            ],
951        }
952    }
953
954    #[test]
955    fn protocol_v2_ls_refs_command_request_includes_agent_and_object_format() {
956        let handshake = sample_v2_handshake();
957        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
958            .expect("test operation should succeed");
959        assert_eq!(command.command, "ls-refs");
960        assert_eq!(
961            command.capabilities,
962            vec![
963                Capability {
964                    name: "agent".into(),
965                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
966                },
967                Capability {
968                    name: "object-format".into(),
969                    value: Some("sha1".into()),
970                },
971            ]
972        );
973        assert_eq!(
974            ProtocolV2LsRefsRequest::from_command_request(&command)
975                .expect("test operation should succeed"),
976            ProtocolV2LsRefsRequest {
977                peel: true,
978                symrefs: true,
979                unborn: false,
980                ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into(),],
981            }
982        );
983    }
984
985    #[test]
986    fn protocol_v2_ls_refs_command_request_omits_object_format_when_unadvertised() {
987        let handshake = TransportHandshake {
988            protocol: ProtocolVersion::V2,
989            capabilities: vec![
990                Capability {
991                    name: "ls-refs".into(),
992                    value: None,
993                },
994                Capability {
995                    name: "agent".into(),
996                    value: Some("git/2.54.0".into()),
997                },
998            ],
999        };
1000        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
1001            .expect("test operation should succeed");
1002        assert_eq!(
1003            command.capabilities,
1004            vec![Capability {
1005                name: "agent".into(),
1006                value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1007            }]
1008        );
1009    }
1010
1011    #[test]
1012    fn protocol_v2_ls_refs_round_trip_bridges_into_ref_advertisement_set() {
1013        let handshake = sample_v2_handshake();
1014        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
1015            .expect("test operation should succeed");
1016        let head = ObjectId::from_hex(
1017            ObjectFormat::Sha1,
1018            "1111111111111111111111111111111111111111",
1019        )
1020        .expect("test operation should succeed");
1021        let tag = ObjectId::from_hex(
1022            ObjectFormat::Sha1,
1023            "2222222222222222222222222222222222222222",
1024        )
1025        .expect("test operation should succeed");
1026        let tag_peeled = ObjectId::from_hex(
1027            ObjectFormat::Sha1,
1028            "3333333333333333333333333333333333333333",
1029        )
1030        .expect("test operation should succeed");
1031        let records = vec![
1032            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1033                oid: head.clone(),
1034                name: "HEAD".into(),
1035                peeled: None,
1036                symref_target: Some("refs/heads/main".into()),
1037                attributes: Vec::new(),
1038            }),
1039            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1040                oid: head.clone(),
1041                name: "refs/heads/main".into(),
1042                peeled: None,
1043                symref_target: None,
1044                attributes: Vec::new(),
1045            }),
1046            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
1047                oid: tag.clone(),
1048                name: "refs/tags/v1".into(),
1049                peeled: Some(tag_peeled.clone()),
1050                symref_target: None,
1051                attributes: Vec::new(),
1052            }),
1053        ];
1054
1055        let mut request_body = Vec::new();
1056        write_protocol_v2_command_request(&mut request_body, &command)
1057            .expect("test operation should succeed");
1058        let mut response_body = Vec::new();
1059        write_protocol_v2_ls_refs_response(&mut response_body, &records)
1060            .expect("test operation should succeed");
1061
1062        let set = read_protocol_v2_ls_refs_response_as_ref_advertisement_set(
1063            ObjectFormat::Sha1,
1064            &mut response_body.as_slice(),
1065        )
1066        .expect("test operation should succeed");
1067        assert_eq!(
1068            set,
1069            RefAdvertisementSet {
1070                protocol: ProtocolVersion::V2,
1071                refs: vec![
1072                    RefAdvertisement {
1073                        oid: head.clone(),
1074                        name: "HEAD".into(),
1075                        capabilities: vec![Capability {
1076                            name: "symref".into(),
1077                            value: Some("HEAD:refs/heads/main".into()),
1078                        }],
1079                    },
1080                    RefAdvertisement {
1081                        oid: head,
1082                        name: "refs/heads/main".into(),
1083                        capabilities: Vec::new(),
1084                    },
1085                    RefAdvertisement {
1086                        oid: tag,
1087                        name: "refs/tags/v1".into(),
1088                        capabilities: Vec::new(),
1089                    },
1090                    RefAdvertisement {
1091                        oid: tag_peeled,
1092                        name: "refs/tags/v1^{}".into(),
1093                        capabilities: Vec::new(),
1094                    },
1095                ],
1096                shallow: Vec::new(),
1097            }
1098        );
1099        assert!(!request_body.is_empty());
1100    }
1101
1102    fn sample_v2_fetch_handshake() -> TransportHandshake {
1103        TransportHandshake {
1104            protocol: ProtocolVersion::V2,
1105            capabilities: vec![
1106                Capability {
1107                    name: "fetch".into(),
1108                    value: Some("shallow sideband-all".into()),
1109                },
1110                Capability {
1111                    name: "agent".into(),
1112                    value: Some("git/2.54.0".into()),
1113                },
1114                Capability {
1115                    name: "object-format".into(),
1116                    value: Some("sha1".into()),
1117                },
1118            ],
1119        }
1120    }
1121
1122    #[test]
1123    fn protocol_v2_fetch_command_request_includes_agent_object_format_and_deepen() {
1124        let handshake = sample_v2_fetch_handshake();
1125        let want = ObjectId::from_hex(
1126            ObjectFormat::Sha1,
1127            "1111111111111111111111111111111111111111",
1128        )
1129        .expect("test operation should succeed");
1130        let have = ObjectId::from_hex(
1131            ObjectFormat::Sha1,
1132            "2222222222222222222222222222222222222222",
1133        )
1134        .expect("test operation should succeed");
1135        let shallow = ObjectId::from_hex(
1136            ObjectFormat::Sha1,
1137            "3333333333333333333333333333333333333333",
1138        )
1139        .expect("test operation should succeed");
1140        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1141            vec![want.clone()],
1142            vec![have.clone()],
1143            vec![shallow.clone()],
1144            Some(3),
1145            None,
1146            Vec::new(),
1147            false,
1148            None,
1149            &handshake,
1150        )
1151        .expect("test operation should succeed");
1152        assert!(fetch.sideband_all);
1153        assert!(fetch.done);
1154        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1155            .expect("test operation should succeed");
1156        assert_eq!(command.command, "fetch");
1157        assert_eq!(
1158            command.capabilities,
1159            vec![
1160                Capability {
1161                    name: "agent".into(),
1162                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1163                },
1164                Capability {
1165                    name: "object-format".into(),
1166                    value: Some("sha1".into()),
1167                },
1168            ]
1169        );
1170        assert_eq!(
1171            ProtocolV2FetchRequest::from_command_request(ObjectFormat::Sha1, &command)
1172                .expect("test operation should succeed"),
1173            ProtocolV2FetchRequest {
1174                wants: vec![want],
1175                haves: vec![have],
1176                shallow: vec![shallow],
1177                deepen: Some(3),
1178                thin_pack: true,
1179                include_tag: true,
1180                ofs_delta: true,
1181                done: true,
1182                sideband_all: true,
1183                ..ProtocolV2FetchRequest::default()
1184            }
1185        );
1186    }
1187
1188    #[test]
1189    fn protocol_v2_fetch_round_trip_extracts_shallow_info_and_packfile_sections() {
1190        let handshake = sample_v2_fetch_handshake();
1191        let want = ObjectId::from_hex(
1192            ObjectFormat::Sha1,
1193            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1194        )
1195        .expect("test operation should succeed");
1196        let shallow = ObjectId::from_hex(
1197            ObjectFormat::Sha1,
1198            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1199        )
1200        .expect("test operation should succeed");
1201        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
1202            vec![want],
1203            Vec::new(),
1204            vec![shallow.clone()],
1205            Some(1),
1206            None,
1207            Vec::new(),
1208            false,
1209            None,
1210            &handshake,
1211        )
1212        .expect("test operation should succeed");
1213        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
1214            .expect("test operation should succeed");
1215        let mut request_body = Vec::new();
1216        write_protocol_v2_command_request(&mut request_body, &command)
1217            .expect("test operation should succeed");
1218
1219        let sections = vec![
1220            ProtocolV2FetchResponseSection::ShallowInfo(vec![ProtocolV2FetchShallowInfo::Shallow(
1221                shallow,
1222            )]),
1223            ProtocolV2FetchResponseSection::Packfile(vec![b"PACK-test".to_vec()]),
1224        ];
1225        let mut response_body = Vec::new();
1226        write_protocol_v2_fetch_response(&mut response_body, &sections)
1227            .expect("test operation should succeed");
1228        let parsed =
1229            read_protocol_v2_fetch_response(ObjectFormat::Sha1, &mut response_body.as_slice())
1230                .expect("test operation should succeed");
1231        assert_eq!(parsed, sections);
1232        assert_eq!(
1233            parsed.first(),
1234            Some(&ProtocolV2FetchResponseSection::ShallowInfo(vec![
1235                ProtocolV2FetchShallowInfo::Shallow(
1236                    ObjectId::from_hex(
1237                        ObjectFormat::Sha1,
1238                        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1239                    )
1240                    .expect("test operation should succeed")
1241                )
1242            ]))
1243        );
1244        assert!(!request_body.is_empty());
1245    }
1246}