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