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 sley_core::{
18    Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
19};
20use sley_fetch::{
21    install_protocol_v2_fetch_response_packfile,
22    install_protocol_v2_fetch_response_promisor_packfile,
23    install_upload_pack_raw_promisor_response, install_upload_pack_raw_response,
24};
25use sley_odb::FileObjectDatabase;
26use sley_protocol::{
27    GitService, ProtocolV2CommandOptions, ProtocolV2CommandRequest, ProtocolV2FetchRequest,
28    ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRequest,
29    RefAdvertisement, RefAdvertisementSet, TransportHandshake, UploadPackFeatures,
30    UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
31    encode_protocol_v2_command_options, parse_protocol_v2_fetch_features,
32    parse_upload_pack_features, protocol_v2_object_format, read_protocol_v2_fetch_response,
33    read_protocol_v2_fetch_sideband_all_response,
34    read_protocol_v2_ls_refs_response_as_ref_advertisement_set,
35    read_upload_pack_raw_packfile_response,
36    read_upload_pack_shallow_info_and_raw_packfile_response, smart_http_advertisement_content_type,
37    smart_http_rpc_request_content_type, smart_http_rpc_result_content_type,
38    validate_protocol_v2_fetch_command_request, validate_protocol_v2_ls_refs_command_request,
39    write_protocol_v2_command_request, write_upload_pack_negotiation_request,
40    write_upload_pack_request,
41};
42use sley_transport::{
43    HttpClient, HttpResponse, RemoteTransport, RemoteUrl, ServiceDiscoveryPayload, UreqHttpClient,
44    git_credential_basic_authorization, http_smart_info_refs_url, http_smart_rpc_url,
45    parse_remote_url, read_service_discovery_response,
46};
47
48use crate::CredentialProvider;
49use crate::credentials::{credential_request_for_url, http_url_credential};
50
51/// Whether an already-resolved remote `url` uses HTTP(S) transport.
52///
53/// Callers that start from a configured remote name or relative source resolve
54/// the URL first (the resolution is repository/process-state dependent and lives
55/// in the caller); this only classifies a concrete URL.
56pub fn remote_url_is_http(url: &str) -> Result<bool> {
57    Ok(matches!(
58        parse_remote_url(url)?.transport,
59        RemoteTransport::Http | RemoteTransport::Https
60    ))
61}
62
63/// Construct the default HTTP client used for smart-HTTP transport.
64pub fn new_http_client() -> UreqHttpClient {
65    UreqHttpClient::new()
66}
67
68/// Perform an HTTP request, retrying once with credential-provider-supplied
69/// authentication if the first attempt returns 401. `perform` is invoked with an
70/// optional `Authorization` header value and must be idempotent (it may run twice).
71/// A successful retry approves the credential with `credentials`; a still-401
72/// retry rejects it.
73pub fn http_send_with_auth(
74    remote: &RemoteUrl,
75    credentials: &mut dyn CredentialProvider,
76    mut perform: impl FnMut(Option<&str>) -> Result<HttpResponse>,
77) -> Result<HttpResponse> {
78    let initial = http_url_credential(remote);
79    let initial_header = match &initial {
80        Some(credential) => git_credential_basic_authorization(credential)?,
81        None => None,
82    };
83    let response = perform(initial_header.as_deref())?;
84    if response.status != 401 {
85        return Ok(response);
86    }
87    let mut request = credential_request_for_url(remote);
88    if request.username.is_none() {
89        request.username = initial.and_then(|credential| credential.username);
90    }
91    let Some(filled) = credentials.fill(request)? else {
92        return Ok(response);
93    };
94    let Some(header) = git_credential_basic_authorization(&filled)? else {
95        return Ok(response);
96    };
97    let retry = perform(Some(&header))?;
98    if retry.status != 401 {
99        credentials.approve(&filled)?;
100    } else {
101        credentials.reject(&filled)?;
102    }
103    Ok(retry)
104}
105
106/// Build the `Authorization` header list for an optional credential header value.
107pub fn http_authorization_headers(auth: Option<&str>) -> Vec<(&str, &str)> {
108    match auth {
109        Some(value) => vec![("Authorization", value)],
110        None => Vec::new(),
111    }
112}
113
114/// Map an HTTP response status to success or a descriptive error for `url`.
115pub fn http_check_status(response: &HttpResponse, url: &str) -> Result<()> {
116    if (200..300).contains(&response.status) {
117        Ok(())
118    } else if response.status == 401 {
119        Err(GitError::Command(format!(
120            "authentication failed for {url}"
121        )))
122    } else {
123        Err(GitError::Command(format!(
124            "unexpected HTTP status {} for {url}",
125            response.status
126        )))
127    }
128}
129
130/// Verify the response `Content-Type` matches `expected` (ignoring parameters).
131pub fn http_validate_content_type(response: &HttpResponse, expected: &str) -> Result<()> {
132    let actual = response
133        .content_type
134        .as_deref()
135        .unwrap_or("")
136        .split(';')
137        .next()
138        .unwrap_or("")
139        .trim();
140    if actual.eq_ignore_ascii_case(expected) {
141        Ok(())
142    } else {
143        Err(GitError::InvalidFormat(format!(
144            "unexpected content type {actual:?}, expected {expected:?}"
145        )))
146    }
147}
148
149/// Result of smart-HTTP service discovery: parsed ref advertisements plus the
150/// protocol v2 handshake when the server negotiated v2 on the info/refs exchange.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct HttpServiceAdvertisements {
153    pub set: RefAdvertisementSet,
154    pub handshake: Option<TransportHandshake>,
155}
156
157/// Parse a smart-HTTP info/refs body into a ref advertisement set for protocol
158/// v0/v1. Protocol v2 discovery responses require a follow-up `ls-refs` RPC; use
159/// [`http_service_advertisements`] instead.
160pub fn http_advertised_refs(
161    format: ObjectFormat,
162    mut response: HttpResponse,
163) -> Result<RefAdvertisementSet> {
164    let discovery = read_service_discovery_response(format, &mut response.body)?;
165    match discovery.payload {
166        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(set),
167        ServiceDiscoveryPayload::ProtocolV2(_) => Err(GitError::Unsupported(
168            "protocol v2 advertisements over HTTP require an ls-refs RPC; use http_service_advertisements".into(),
169        )),
170    }
171}
172
173fn protocol_v2_ls_refs_command_request(
174    format: ObjectFormat,
175    handshake: &TransportHandshake,
176) -> Result<ProtocolV2CommandRequest> {
177    let ls_refs = ProtocolV2LsRefsRequest {
178        peel: true,
179        symrefs: true,
180        unborn: false,
181        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
182    };
183    let mut command = ls_refs.to_command_request()?;
184    let mut options = ProtocolV2CommandOptions::default();
185    if handshake
186        .capabilities
187        .iter()
188        .any(|capability| capability.name == "agent")
189    {
190        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
191    }
192    if handshake
193        .capabilities
194        .iter()
195        .any(|capability| capability.name == "object-format")
196    {
197        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
198        if advertised_format != format {
199            return Err(GitError::InvalidObjectId(format!(
200                "remote repository uses {}, local repository uses {}",
201                advertised_format.name(),
202                format.name()
203            )));
204        }
205        options.object_format = Some(format);
206    }
207    command.capabilities = encode_protocol_v2_command_options(&options)?;
208    validate_protocol_v2_ls_refs_command_request(handshake, &command)?;
209    Ok(command)
210}
211
212fn protocol_v2_fetch_command_request(
213    format: ObjectFormat,
214    handshake: &TransportHandshake,
215    fetch: &ProtocolV2FetchRequest,
216) -> Result<ProtocolV2CommandRequest> {
217    let mut command = fetch.to_command_request()?;
218    let mut options = ProtocolV2CommandOptions::default();
219    if handshake
220        .capabilities
221        .iter()
222        .any(|capability| capability.name == "agent")
223    {
224        options.agent = Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}"));
225    }
226    if handshake
227        .capabilities
228        .iter()
229        .any(|capability| capability.name == "object-format")
230    {
231        let advertised_format = protocol_v2_object_format(&handshake.capabilities)?;
232        if advertised_format != format {
233            return Err(GitError::InvalidObjectId(format!(
234                "remote repository uses {}, local repository uses {}",
235                advertised_format.name(),
236                format.name()
237            )));
238        }
239        options.object_format = Some(format);
240    }
241    command.capabilities = encode_protocol_v2_command_options(&options)?;
242    validate_protocol_v2_fetch_command_request(handshake, format, &command)?;
243    Ok(command)
244}
245
246fn protocol_v2_fetch_request_from_upload_pack_semantics(
247    wants: Vec<ObjectId>,
248    haves: Vec<ObjectId>,
249    shallow: Vec<ObjectId>,
250    deepen: Option<u32>,
251    handshake: &TransportHandshake,
252) -> Result<ProtocolV2FetchRequest> {
253    let sideband_all = parse_protocol_v2_fetch_features(&handshake.capabilities)?
254        .map(|features| features.sideband_all)
255        .unwrap_or(false);
256    Ok(ProtocolV2FetchRequest {
257        wants,
258        haves,
259        shallow,
260        deepen,
261        done: true,
262        sideband_all,
263        ..ProtocolV2FetchRequest::default()
264    })
265}
266
267fn shallow_info_from_protocol_v2_fetch_sections(
268    sections: &[ProtocolV2FetchResponseSection],
269) -> Vec<ProtocolV2FetchShallowInfo> {
270    let mut shallow_info = Vec::new();
271    for section in sections {
272        if let ProtocolV2FetchResponseSection::ShallowInfo(entries) = section {
273            shallow_info.extend(entries.clone());
274        }
275    }
276    shallow_info
277}
278
279fn http_protocol_v2_ls_refs_advertisements(
280    client: &UreqHttpClient,
281    remote: &RemoteUrl,
282    format: ObjectFormat,
283    service: GitService,
284    handshake: TransportHandshake,
285    credentials: &mut dyn CredentialProvider,
286) -> Result<RefAdvertisementSet> {
287    let command = protocol_v2_ls_refs_command_request(format, &handshake)?;
288    let url = http_smart_rpc_url(remote, service)?;
289    let mut body = Vec::new();
290    write_protocol_v2_command_request(&mut body, &command)?;
291    let content_type = smart_http_rpc_request_content_type(service)?;
292    let mut response = http_send_with_auth(remote, credentials, |auth| {
293        client.post(
294            &url,
295            &content_type,
296            &http_authorization_headers(auth),
297            &body,
298        )
299    })?;
300    http_check_status(&response, &url)?;
301    http_validate_content_type(&response, &smart_http_rpc_result_content_type(service)?)?;
302    read_protocol_v2_ls_refs_response_as_ref_advertisement_set(format, &mut response.body)
303}
304
305/// Fetch and parse the ref advertisements for `service` from the smart-HTTP
306/// info/refs endpoint, authenticating and validating status + content type.
307pub fn http_service_advertisements(
308    client: &UreqHttpClient,
309    remote: &RemoteUrl,
310    format: ObjectFormat,
311    service: GitService,
312    credentials: &mut dyn CredentialProvider,
313) -> Result<HttpServiceAdvertisements> {
314    let url = http_smart_info_refs_url(remote, service)?;
315    let mut response = http_send_with_auth(remote, credentials, |auth| {
316        client.get(&url, &http_authorization_headers(auth))
317    })?;
318    http_check_status(&response, &url)?;
319    http_validate_content_type(&response, &smart_http_advertisement_content_type(service)?)?;
320    let discovery = read_service_discovery_response(format, &mut response.body)?;
321    match discovery.payload {
322        ServiceDiscoveryPayload::AdvertisedRefs(set) => Ok(HttpServiceAdvertisements {
323            set,
324            handshake: None,
325        }),
326        ServiceDiscoveryPayload::ProtocolV2(handshake) => {
327            let set = http_protocol_v2_ls_refs_advertisements(
328                client,
329                remote,
330                format,
331                service,
332                handshake.clone(),
333                credentials,
334            )?;
335            Ok(HttpServiceAdvertisements {
336                set,
337                handshake: Some(handshake),
338            })
339        }
340    }
341}
342
343/// The upload-pack ref advertisements and parsed features for `remote`.
344pub fn http_upload_pack_advertisements(
345    client: &UreqHttpClient,
346    remote: &RemoteUrl,
347    format: ObjectFormat,
348    credentials: &mut dyn CredentialProvider,
349) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
350    let discovered =
351        http_service_advertisements(client, remote, format, GitService::UploadPack, credentials)?;
352    let features = upload_pack_features_from_advertisements(&discovered.set.refs)?;
353    Ok((discovered.set.refs, features))
354}
355
356fn upload_pack_features_from_advertisements(
357    advertisements: &[RefAdvertisement],
358) -> Result<UploadPackFeatures> {
359    Ok(advertisements
360        .first()
361        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
362        .transpose()?
363        .unwrap_or_default())
364}
365
366/// Post an upload-pack RPC `request` + `haves` and return the validated HTTP
367/// response with its body still unread, so the caller can parse the packfile
368/// stream (with or without a leading shallow-info section). Authenticates and
369/// validates status + content type.
370fn http_upload_pack_post(
371    client: &UreqHttpClient,
372    remote: &RemoteUrl,
373    request: &UploadPackRequest,
374    haves: Vec<ObjectId>,
375    credentials: &mut dyn CredentialProvider,
376) -> Result<HttpResponse> {
377    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
378    let mut body = Vec::new();
379    write_upload_pack_request(&mut body, Some(request))?;
380    write_upload_pack_negotiation_request(
381        &mut body,
382        &UploadPackNegotiationRequest { haves, done: true },
383    )?;
384    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
385    let response = http_send_with_auth(remote, credentials, |auth| {
386        client.post(
387            &url,
388            &content_type,
389            &http_authorization_headers(auth),
390            &body,
391        )
392    })?;
393    http_check_status(&response, &url)?;
394    http_validate_content_type(
395        &response,
396        &smart_http_rpc_result_content_type(GitService::UploadPack)?,
397    )?;
398    Ok(response)
399}
400
401/// Post an upload-pack RPC `request` + `haves` and read back the raw packfile
402/// response, authenticating and validating status + content type. For a plain
403/// (non-deepen) request; see [`http_upload_pack_shallow_fetch_response`] for the
404/// deepen case where the response carries a leading shallow-info section.
405pub fn http_upload_pack_fetch_response(
406    client: &UreqHttpClient,
407    remote: &RemoteUrl,
408    format: ObjectFormat,
409    request: UploadPackRequest,
410    haves: Vec<ObjectId>,
411    credentials: &mut dyn CredentialProvider,
412) -> Result<UploadPackRawPackfileResponse> {
413    let mut response = http_upload_pack_post(client, remote, &request, haves, credentials)?;
414    read_upload_pack_raw_packfile_response(format, &mut response.body)
415}
416
417/// Post a deepen upload-pack RPC `request` + `haves` and read back the shallow-info
418/// section plus the raw packfile response. Use this when `request` carries a
419/// `shallow`/`deepen`/`deepen-since`/`deepen-not` argument: git always prefixes the
420/// response with a shallow-info section (possibly empty) in that case. The returned
421/// [`ProtocolV2FetchShallowInfo`] entries are the server's `shallow`/`unshallow`
422/// updates for `$GIT_DIR/shallow`.
423pub fn http_upload_pack_shallow_fetch_response(
424    client: &UreqHttpClient,
425    remote: &RemoteUrl,
426    format: ObjectFormat,
427    request: UploadPackRequest,
428    haves: Vec<ObjectId>,
429    credentials: &mut dyn CredentialProvider,
430) -> Result<(
431    Vec<ProtocolV2FetchShallowInfo>,
432    UploadPackRawPackfileResponse,
433)> {
434    let mut response = http_upload_pack_post(client, remote, &request, haves, credentials)?;
435    read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut response.body)
436}
437
438/// Post a protocol v2 `fetch` RPC with `wants`/`haves`/`shallow`/`deepen` and
439/// read back the sectioned response. Authenticates and validates status + content
440/// type. When the server advertises `sideband-all`, the request and response use
441/// the sideband-all wire form.
442pub fn http_protocol_v2_fetch_response(
443    client: &UreqHttpClient,
444    remote: &RemoteUrl,
445    format: ObjectFormat,
446    handshake: &TransportHandshake,
447    fetch: ProtocolV2FetchRequest,
448    credentials: &mut dyn CredentialProvider,
449) -> Result<Vec<ProtocolV2FetchResponseSection>> {
450    let command = protocol_v2_fetch_command_request(format, handshake, &fetch)?;
451    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
452    let mut body = Vec::new();
453    write_protocol_v2_command_request(&mut body, &command)?;
454    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
455    let mut response = http_send_with_auth(remote, credentials, |auth| {
456        client.post(
457            &url,
458            &content_type,
459            &http_authorization_headers(auth),
460            &body,
461        )
462    })?;
463    http_check_status(&response, &url)?;
464    http_validate_content_type(
465        &response,
466        &smart_http_rpc_result_content_type(GitService::UploadPack)?,
467    )?;
468    if fetch.sideband_all {
469        Ok(read_protocol_v2_fetch_sideband_all_response(format, &mut response.body)?.sections)
470    } else {
471        read_protocol_v2_fetch_response(format, &mut response.body)
472    }
473}
474
475/// Fetch `wants` from an HTTP upload-pack remote into the repository at `git_dir`,
476/// installing the resulting pack. Objects already present locally are skipped (for
477/// non-shallow fetches); `promisor` selects promisor-pack installation.
478///
479/// When `deepen` is set the fetch is shallow: the request replays `shallow` (the
480/// client's current boundary, read from `$GIT_DIR/shallow`) and asks the server to
481/// truncate history to `deepen` commits. The returned [`ProtocolV2FetchShallowInfo`]
482/// entries are the server's shallow-info updates the caller must fold into
483/// `$GIT_DIR/shallow` (see [`crate::apply_shallow_info`]); they are empty for a
484/// non-deepen fetch.
485pub struct HttpFetchPackRequest<'a> {
486    /// HTTP client used for smart-HTTP RPCs.
487    pub client: &'a UreqHttpClient,
488    /// Local repository `$GIT_DIR`.
489    pub git_dir: &'a Path,
490    /// Local repository object format.
491    pub format: ObjectFormat,
492    /// Resolved HTTP(S) remote.
493    pub remote: &'a RemoteUrl,
494    /// Wanted object ids.
495    pub wants: Vec<ObjectId>,
496    /// Existing shallow boundary to replay.
497    pub shallow: Vec<ObjectId>,
498    /// Requested deepen depth, if this is a shallow fetch.
499    pub deepen: Option<u32>,
500    /// Whether to install the response as a promisor pack.
501    pub promisor: bool,
502}
503
504pub fn install_fetch_pack_via_http_upload_pack(
505    request: HttpFetchPackRequest<'_>,
506    credentials: &mut dyn CredentialProvider,
507) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
508    if request.wants.is_empty() {
509        return Ok(Vec::new());
510    }
511    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
512    // A deepen request must always reach the server (the shallow boundary may move
513    // even when every wanted object is already present), so only the plain fetch
514    // takes the "everything is local already" shortcut.
515    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
516        return Ok(Vec::new());
517    }
518    let upload_request = UploadPackRequest {
519        wants: request.wants,
520        capabilities: shallow_request_capabilities(request.deepen),
521        shallow: request.shallow,
522        deepen: request.deepen,
523        ..UploadPackRequest::default()
524    };
525    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
526    let (shallow_info, response) = if request.deepen.is_some() {
527        http_upload_pack_shallow_fetch_response(
528            request.client,
529            request.remote,
530            request.format,
531            upload_request,
532            haves,
533            credentials,
534        )?
535    } else {
536        let response = http_upload_pack_fetch_response(
537            request.client,
538            request.remote,
539            request.format,
540            upload_request,
541            haves,
542            credentials,
543        )?;
544        (Vec::new(), response)
545    };
546    if request.promisor {
547        install_upload_pack_raw_promisor_response(&response, &local_db)?;
548    } else {
549        install_upload_pack_raw_response(&response, &local_db)?;
550    }
551    Ok(shallow_info)
552}
553
554pub fn install_fetch_pack_via_http_protocol_v2_fetch(
555    request: HttpFetchPackRequest<'_>,
556    handshake: &TransportHandshake,
557    credentials: &mut dyn CredentialProvider,
558) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
559    if request.wants.is_empty() {
560        return Ok(Vec::new());
561    }
562    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
563    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
564        return Ok(Vec::new());
565    }
566    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
567    let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
568        request.wants,
569        haves,
570        request.shallow,
571        request.deepen,
572        handshake,
573    )?;
574    let sections = http_protocol_v2_fetch_response(
575        request.client,
576        request.remote,
577        request.format,
578        handshake,
579        fetch,
580        credentials,
581    )?;
582    let shallow_info = shallow_info_from_protocol_v2_fetch_sections(&sections);
583    if request.promisor {
584        install_protocol_v2_fetch_response_promisor_packfile(&sections, &local_db)?;
585    } else {
586        install_protocol_v2_fetch_response_packfile(&sections, &local_db)?;
587    }
588    Ok(shallow_info)
589}
590
591fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
592    for want in wants {
593        if !db.contains(want)? {
594            return Ok(false);
595        }
596    }
597    Ok(true)
598}
599
600/// The want-line capabilities to advertise for a fetch: the `shallow` capability
601/// when a deepen is requested (git's upload-pack expects clients sending deepen to
602/// negotiate shallow), otherwise none — preserving the existing plain-fetch wire
603/// form exactly.
604fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
605    if deepen.is_some() {
606        vec![Capability {
607            name: "shallow".into(),
608            value: None,
609        }]
610    } else {
611        Vec::new()
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use sley_protocol::{
619        ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRecord,
620        ProtocolVersion, RefAdvertisement, read_protocol_v2_fetch_response,
621        write_protocol_v2_fetch_response, write_protocol_v2_ls_refs_response,
622    };
623
624    fn sample_v2_handshake() -> TransportHandshake {
625        TransportHandshake {
626            protocol: ProtocolVersion::V2,
627            capabilities: vec![
628                Capability {
629                    name: "ls-refs".into(),
630                    value: Some("peel symrefs".into()),
631                },
632                Capability {
633                    name: "agent".into(),
634                    value: Some("git/2.54.0".into()),
635                },
636                Capability {
637                    name: "object-format".into(),
638                    value: Some("sha1".into()),
639                },
640            ],
641        }
642    }
643
644    #[test]
645    fn protocol_v2_ls_refs_command_request_includes_agent_and_object_format() {
646        let handshake = sample_v2_handshake();
647        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
648            .expect("test operation should succeed");
649        assert_eq!(command.command, "ls-refs");
650        assert_eq!(
651            command.capabilities,
652            vec![
653                Capability {
654                    name: "agent".into(),
655                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
656                },
657                Capability {
658                    name: "object-format".into(),
659                    value: Some("sha1".into()),
660                },
661            ]
662        );
663        assert_eq!(
664            ProtocolV2LsRefsRequest::from_command_request(&command)
665                .expect("test operation should succeed"),
666            ProtocolV2LsRefsRequest {
667                peel: true,
668                symrefs: true,
669                unborn: false,
670                ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into(),],
671            }
672        );
673    }
674
675    #[test]
676    fn protocol_v2_ls_refs_command_request_omits_object_format_when_unadvertised() {
677        let handshake = TransportHandshake {
678            protocol: ProtocolVersion::V2,
679            capabilities: vec![
680                Capability {
681                    name: "ls-refs".into(),
682                    value: None,
683                },
684                Capability {
685                    name: "agent".into(),
686                    value: Some("git/2.54.0".into()),
687                },
688            ],
689        };
690        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
691            .expect("test operation should succeed");
692        assert_eq!(
693            command.capabilities,
694            vec![Capability {
695                name: "agent".into(),
696                value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
697            }]
698        );
699    }
700
701    #[test]
702    fn protocol_v2_ls_refs_round_trip_bridges_into_ref_advertisement_set() {
703        let handshake = sample_v2_handshake();
704        let command = protocol_v2_ls_refs_command_request(ObjectFormat::Sha1, &handshake)
705            .expect("test operation should succeed");
706        let head = ObjectId::from_hex(
707            ObjectFormat::Sha1,
708            "1111111111111111111111111111111111111111",
709        )
710        .expect("test operation should succeed");
711        let tag = ObjectId::from_hex(
712            ObjectFormat::Sha1,
713            "2222222222222222222222222222222222222222",
714        )
715        .expect("test operation should succeed");
716        let tag_peeled = ObjectId::from_hex(
717            ObjectFormat::Sha1,
718            "3333333333333333333333333333333333333333",
719        )
720        .expect("test operation should succeed");
721        let records = vec![
722            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
723                oid: head.clone(),
724                name: "HEAD".into(),
725                peeled: None,
726                symref_target: Some("refs/heads/main".into()),
727                attributes: Vec::new(),
728            }),
729            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
730                oid: head.clone(),
731                name: "refs/heads/main".into(),
732                peeled: None,
733                symref_target: None,
734                attributes: Vec::new(),
735            }),
736            ProtocolV2LsRefsRecord::Ref(sley_protocol::ProtocolV2LsRefsRef {
737                oid: tag.clone(),
738                name: "refs/tags/v1".into(),
739                peeled: Some(tag_peeled.clone()),
740                symref_target: None,
741                attributes: Vec::new(),
742            }),
743        ];
744
745        let mut request_body = Vec::new();
746        write_protocol_v2_command_request(&mut request_body, &command)
747            .expect("test operation should succeed");
748        let mut response_body = Vec::new();
749        write_protocol_v2_ls_refs_response(&mut response_body, &records)
750            .expect("test operation should succeed");
751
752        let set = read_protocol_v2_ls_refs_response_as_ref_advertisement_set(
753            ObjectFormat::Sha1,
754            &mut response_body.as_slice(),
755        )
756        .expect("test operation should succeed");
757        assert_eq!(
758            set,
759            RefAdvertisementSet {
760                protocol: ProtocolVersion::V2,
761                refs: vec![
762                    RefAdvertisement {
763                        oid: head.clone(),
764                        name: "HEAD".into(),
765                        capabilities: vec![Capability {
766                            name: "symref".into(),
767                            value: Some("HEAD:refs/heads/main".into()),
768                        }],
769                    },
770                    RefAdvertisement {
771                        oid: head,
772                        name: "refs/heads/main".into(),
773                        capabilities: Vec::new(),
774                    },
775                    RefAdvertisement {
776                        oid: tag,
777                        name: "refs/tags/v1".into(),
778                        capabilities: Vec::new(),
779                    },
780                    RefAdvertisement {
781                        oid: tag_peeled,
782                        name: "refs/tags/v1^{}".into(),
783                        capabilities: Vec::new(),
784                    },
785                ],
786                shallow: Vec::new(),
787            }
788        );
789        assert!(!request_body.is_empty());
790    }
791
792    fn sample_v2_fetch_handshake() -> TransportHandshake {
793        TransportHandshake {
794            protocol: ProtocolVersion::V2,
795            capabilities: vec![
796                Capability {
797                    name: "fetch".into(),
798                    value: Some("shallow sideband-all".into()),
799                },
800                Capability {
801                    name: "agent".into(),
802                    value: Some("git/2.54.0".into()),
803                },
804                Capability {
805                    name: "object-format".into(),
806                    value: Some("sha1".into()),
807                },
808            ],
809        }
810    }
811
812    #[test]
813    fn protocol_v2_fetch_command_request_includes_agent_object_format_and_deepen() {
814        let handshake = sample_v2_fetch_handshake();
815        let want = ObjectId::from_hex(
816            ObjectFormat::Sha1,
817            "1111111111111111111111111111111111111111",
818        )
819        .expect("test operation should succeed");
820        let have = ObjectId::from_hex(
821            ObjectFormat::Sha1,
822            "2222222222222222222222222222222222222222",
823        )
824        .expect("test operation should succeed");
825        let shallow = ObjectId::from_hex(
826            ObjectFormat::Sha1,
827            "3333333333333333333333333333333333333333",
828        )
829        .expect("test operation should succeed");
830        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
831            vec![want.clone()],
832            vec![have.clone()],
833            vec![shallow.clone()],
834            Some(3),
835            &handshake,
836        )
837        .expect("test operation should succeed");
838        assert!(fetch.sideband_all);
839        assert!(fetch.done);
840        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
841            .expect("test operation should succeed");
842        assert_eq!(command.command, "fetch");
843        assert_eq!(
844            command.capabilities,
845            vec![
846                Capability {
847                    name: "agent".into(),
848                    value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
849                },
850                Capability {
851                    name: "object-format".into(),
852                    value: Some("sha1".into()),
853                },
854            ]
855        );
856        assert_eq!(
857            ProtocolV2FetchRequest::from_command_request(ObjectFormat::Sha1, &command)
858                .expect("test operation should succeed"),
859            ProtocolV2FetchRequest {
860                wants: vec![want],
861                haves: vec![have],
862                shallow: vec![shallow],
863                deepen: Some(3),
864                done: true,
865                sideband_all: true,
866                ..ProtocolV2FetchRequest::default()
867            }
868        );
869    }
870
871    #[test]
872    fn protocol_v2_fetch_round_trip_extracts_shallow_info_and_packfile_sections() {
873        let handshake = sample_v2_fetch_handshake();
874        let want = ObjectId::from_hex(
875            ObjectFormat::Sha1,
876            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
877        )
878        .expect("test operation should succeed");
879        let shallow = ObjectId::from_hex(
880            ObjectFormat::Sha1,
881            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
882        )
883        .expect("test operation should succeed");
884        let fetch = protocol_v2_fetch_request_from_upload_pack_semantics(
885            vec![want],
886            Vec::new(),
887            vec![shallow.clone()],
888            Some(1),
889            &handshake,
890        )
891        .expect("test operation should succeed");
892        let command = protocol_v2_fetch_command_request(ObjectFormat::Sha1, &handshake, &fetch)
893            .expect("test operation should succeed");
894        let mut request_body = Vec::new();
895        write_protocol_v2_command_request(&mut request_body, &command)
896            .expect("test operation should succeed");
897
898        let sections = vec![
899            ProtocolV2FetchResponseSection::ShallowInfo(vec![ProtocolV2FetchShallowInfo::Shallow(
900                shallow,
901            )]),
902            ProtocolV2FetchResponseSection::Packfile(vec![b"PACK-test".to_vec()]),
903        ];
904        let mut response_body = Vec::new();
905        write_protocol_v2_fetch_response(&mut response_body, &sections)
906            .expect("test operation should succeed");
907        let parsed =
908            read_protocol_v2_fetch_response(ObjectFormat::Sha1, &mut response_body.as_slice())
909                .expect("test operation should succeed");
910        assert_eq!(parsed, sections);
911        assert_eq!(
912            shallow_info_from_protocol_v2_fetch_sections(&parsed),
913            vec![ProtocolV2FetchShallowInfo::Shallow(
914                ObjectId::from_hex(
915                    ObjectFormat::Sha1,
916                    "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
917                )
918                .expect("test operation should succeed")
919            )]
920        );
921        assert!(!request_body.is_empty());
922    }
923}