Skip to main content

sley_remote/
git.rs

1//! Native `git://` transport over a raw TCP stream.
2//!
3//! This is the anonymous git-daemon protocol: send one service request pkt-line,
4//! then speak upload-pack / receive-pack over the selected wire protocol.
5
6use std::collections::HashMap;
7use std::io::{Read, Write};
8use std::net::{Shutdown, TcpStream};
9use std::path::Path;
10
11use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
12use sley_fetch::{
13    install_protocol_v2_fetch_promisor_response_from_reader,
14    install_protocol_v2_fetch_response_from_reader,
15    install_upload_pack_raw_promisor_response_from_reader,
16    install_upload_pack_raw_response_from_reader,
17    install_upload_pack_shallow_raw_promisor_response_from_reader,
18    install_upload_pack_shallow_raw_response_from_reader,
19    shallow_info_from_protocol_v2_fetch_header,
20};
21use sley_odb::FileObjectDatabase;
22use sley_protocol::{
23    GitService, ProtocolV2CommandOptions, ProtocolV2FetchRequest, ProtocolV2FetchShallowInfo,
24    ProtocolV2LsRefsRequest, ProtocolVersion, ReceivePackCommand, ReceivePackFeatures,
25    ReceivePackPushRequestOptions, RefAdvertisement, TransportHandshake, UploadPackFeatures,
26    UploadPackNegotiationRequest, UploadPackRequest, parse_protocol_v2_fetch_features,
27    parse_receive_pack_features, parse_refspec, parse_upload_pack_features, plan_push_commands,
28    protocol_v2_ls_refs_records_to_ref_advertisement_set, protocol_v2_object_format,
29    read_protocol_v2_advertisement, read_protocol_v2_ls_refs_response,
30    read_receive_pack_report_status, read_ref_advertisement_set, write_protocol_v2_command_request,
31    write_protocol_v2_fetch_request, write_upload_pack_negotiation_request,
32    write_upload_pack_request,
33};
34use sley_refs::FileRefStore;
35use sley_transport::{RemoteTransport, RemoteUrl, ServiceRequest, write_service_request};
36
37use crate::{PushOutcome, PushRequest};
38
39const GIT_DAEMON_PORT: u16 = 9418;
40
41pub(crate) struct GitPushRequest<'a> {
42    pub git_dir: &'a Path,
43    pub common_git_dir: &'a Path,
44    pub format: ObjectFormat,
45    pub remote: &'a RemoteUrl,
46    pub refspecs: &'a [String],
47    pub force: bool,
48}
49
50pub(crate) struct GitPushCommandsRequest<'a> {
51    pub common_git_dir: &'a Path,
52    pub format: ObjectFormat,
53    pub remote: &'a RemoteUrl,
54    pub command_forces: Vec<(ReceivePackCommand, bool)>,
55    pub pack_objects: Vec<ObjectId>,
56}
57
58pub(crate) struct GitPushPlan {
59    pub(crate) commands: Vec<ReceivePackCommand>,
60    pub(crate) pack_objects: Vec<ObjectId>,
61    stream: Option<TcpStream>,
62    features: ReceivePackFeatures,
63    advertisements: Vec<RefAdvertisement>,
64}
65
66pub struct GitFetchPackRequest<'a> {
67    pub git_dir: &'a Path,
68    pub format: ObjectFormat,
69    pub remote: &'a RemoteUrl,
70    pub features: &'a UploadPackFeatures,
71    pub wants: Vec<ObjectId>,
72    pub shallow: Vec<ObjectId>,
73    pub deepen: Option<u32>,
74    pub promisor: bool,
75    pub protocol_v2: bool,
76}
77
78pub struct GitUploadPackAdvertisements {
79    pub refs: Vec<RefAdvertisement>,
80    pub features: UploadPackFeatures,
81    pub protocol_v2: bool,
82}
83
84pub(crate) fn ls_remote_git(
85    remote: &RemoteUrl,
86    filter: &crate::ls_remote::LsRemoteFilter,
87    matches: &dyn Fn(&str) -> bool,
88    protocol_v2: bool,
89) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
90    let set = if protocol_v2 {
91        git_protocol_v2_ls_refs(remote, ObjectFormat::Sha1)?
92    } else {
93        let mut stream = connect_git_service(remote, GitService::UploadPack, None)?;
94        read_ref_advertisement_set(ObjectFormat::Sha1, &mut stream)?
95    };
96    let features = set
97        .refs
98        .first()
99        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
100        .transpose()?
101        .unwrap_or_default();
102    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
103    if format != ObjectFormat::Sha1 {
104        return Err(GitError::Unsupported(format!(
105            "git:// ls-remote currently supports SHA-1 advertisements, got {}",
106            format.name()
107        )));
108    }
109    let symrefs = features
110        .symrefs
111        .iter()
112        .filter_map(|symref| symref.split_once(':'))
113        .map(|(name, target)| (name.to_string(), target.to_string()))
114        .collect::<HashMap<_, _>>();
115    let mut records = Vec::new();
116    for advertisement in set.refs {
117        if advertisement.oid.is_null() {
118            continue;
119        }
120        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
121        {
122            continue;
123        }
124        let is_head = advertisement.name.starts_with("refs/heads/");
125        let is_tag = advertisement.name.starts_with("refs/tags/");
126        if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
127        {
128            continue;
129        }
130        if !matches(&advertisement.name) {
131            continue;
132        }
133        records.push(crate::ls_remote::LsRemoteRecord {
134            oid: advertisement.oid,
135            symref: symrefs.get(&advertisement.name).cloned(),
136            name: advertisement.name,
137        });
138    }
139    Ok((records, format))
140}
141
142pub fn git_upload_pack_advertisements(
143    remote: &RemoteUrl,
144    format: ObjectFormat,
145) -> Result<GitUploadPackAdvertisements> {
146    git_upload_pack_advertisements_with_protocol(remote, format, false)
147}
148
149pub fn git_upload_pack_advertisements_with_protocol(
150    remote: &RemoteUrl,
151    format: ObjectFormat,
152    protocol_v2: bool,
153) -> Result<GitUploadPackAdvertisements> {
154    if protocol_v2 {
155        let mut stream =
156            connect_git_service(remote, GitService::UploadPack, Some(ProtocolVersion::V2))?;
157        let handshake = read_protocol_v2_advertisement(&mut stream)?;
158        let object_format = protocol_v2_object_format(&handshake.capabilities)?;
159        if object_format != format {
160            return Err(GitError::InvalidObjectId(format!(
161                "remote repository uses {}, local repository uses {}",
162                object_format.name(),
163                format.name()
164            )));
165        }
166        let set = git_protocol_v2_ls_refs_on_stream(format, &mut stream)?;
167        let features = upload_pack_features_from_v2(&handshake, &set.refs)?;
168        return Ok(GitUploadPackAdvertisements {
169            refs: set.refs,
170            features,
171            protocol_v2: true,
172        });
173    }
174
175    let mut stream = connect_git_service(remote, GitService::UploadPack, None)?;
176    let set = read_ref_advertisement_set(format, &mut stream)?;
177    let features = set
178        .refs
179        .first()
180        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
181        .transpose()?
182        .unwrap_or_default();
183    Ok(GitUploadPackAdvertisements {
184        refs: set.refs,
185        features,
186        protocol_v2: false,
187    })
188}
189
190pub fn install_fetch_pack_via_git_upload_pack(
191    request: GitFetchPackRequest<'_>,
192) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
193    if request.wants.is_empty() {
194        return Ok(Vec::new());
195    }
196    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
197    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
198        return Ok(Vec::new());
199    }
200    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
201    if request.protocol_v2 {
202        return git_protocol_v2_fetch_into_repository(&request, haves, &local_db);
203    }
204
205    let upload_request = UploadPackRequest {
206        wants: request.wants,
207        capabilities: shallow_request_capabilities(request.deepen),
208        shallow: request.shallow,
209        deepen: request.deepen,
210        ..UploadPackRequest::default()
211    };
212    let mut stream = connect_git_service(request.remote, GitService::UploadPack, None)?;
213    read_ref_advertisement_set(request.format, &mut stream)?;
214    write_upload_pack_request(&mut stream, Some(&upload_request))?;
215    write_upload_pack_negotiation_request(
216        &mut stream,
217        &UploadPackNegotiationRequest { haves, done: true },
218    )?;
219    stream.flush()?;
220    let _ = stream.shutdown(Shutdown::Write);
221    if request.deepen.is_some() {
222        let shallow_info = if request.promisor {
223            let (shallow_info, _) = install_upload_pack_shallow_raw_promisor_response_from_reader(
224                request.format,
225                &mut stream,
226                &local_db,
227            )?;
228            shallow_info
229        } else {
230            let (shallow_info, _) = install_upload_pack_shallow_raw_response_from_reader(
231                request.format,
232                &mut stream,
233                &local_db,
234            )?;
235            shallow_info
236        };
237        return Ok(shallow_info);
238    }
239    if request.promisor {
240        install_upload_pack_raw_promisor_response_from_reader(
241            request.format,
242            &mut stream,
243            &local_db,
244        )?;
245    } else {
246        install_upload_pack_raw_response_from_reader(request.format, &mut stream, &local_db)?;
247    }
248    Ok(Vec::new())
249}
250
251pub(crate) fn plan_push_git(request: GitPushRequest<'_>) -> Result<GitPushPlan> {
252    let GitPushRequest {
253        git_dir,
254        common_git_dir,
255        format,
256        remote,
257        refspecs,
258        force,
259    } = request;
260    let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
261
262    let local_store = FileRefStore::new(git_dir, format);
263    let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
264    let parsed_refspecs = refspecs
265        .iter()
266        .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
267        .collect::<Result<Vec<_>>>()?;
268    let mut command_forces = Vec::new();
269    for refspec in &parsed_refspecs {
270        for command in plan_push_commands(
271            format,
272            &local_refs,
273            &advertisements,
274            std::slice::from_ref(refspec),
275        )? {
276            command_forces.push((command, force || refspec.force));
277        }
278    }
279    let commands = command_forces
280        .iter()
281        .map(|(command, _)| command.clone())
282        .collect::<Vec<_>>();
283    if commands.is_empty() {
284        let _ = stream.shutdown(Shutdown::Both);
285        return Ok(GitPushPlan {
286            commands,
287            pack_objects: Vec::new(),
288            stream: None,
289            features,
290            advertisements,
291        });
292    }
293
294    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
295    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
296    Ok(GitPushPlan {
297        commands,
298        pack_objects: Vec::new(),
299        stream: Some(stream),
300        features,
301        advertisements,
302    })
303}
304
305pub(crate) fn plan_push_git_commands(request: GitPushCommandsRequest<'_>) -> Result<GitPushPlan> {
306    let GitPushCommandsRequest {
307        common_git_dir,
308        format,
309        remote,
310        command_forces,
311        pack_objects,
312    } = request;
313    let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
314    let commands = command_forces
315        .iter()
316        .map(|(command, _)| command.clone())
317        .collect::<Vec<_>>();
318    if commands.is_empty() {
319        let _ = stream.shutdown(Shutdown::Both);
320        return Ok(GitPushPlan {
321            commands,
322            pack_objects,
323            stream: None,
324            features,
325            advertisements,
326        });
327    }
328
329    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
330    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
331    Ok(GitPushPlan {
332        commands,
333        pack_objects,
334        stream: Some(stream),
335        features,
336        advertisements,
337    })
338}
339
340pub(crate) fn execute_push_git_plan(
341    request: PushRequest<'_>,
342    mut plan: GitPushPlan,
343) -> Result<PushOutcome> {
344    if plan.commands.is_empty() {
345        return Ok(PushOutcome::default());
346    }
347    let mut stream = plan
348        .stream
349        .take()
350        .ok_or_else(|| GitError::Command("git:// receive-pack stream was not available".into()))?;
351    let commands = plan.commands.clone();
352    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
353    crate::pack::write_receive_pack_body(
354        &crate::pack::PushPackRequest {
355            local_db: &local_db,
356            format: request.format,
357            commands: &commands,
358            pack_objects: &plan.pack_objects,
359            remote_advertisements: &plan.advertisements,
360            features: &plan.features,
361            options: ReceivePackPushRequestOptions {
362                report_status: plan.features.report_status,
363                ofs_delta: plan.features.ofs_delta,
364                quiet: request.options.quiet && plan.features.quiet,
365                object_format: plan
366                    .features
367                    .object_format
368                    .filter(|_| request.format != ObjectFormat::Sha1),
369                ..ReceivePackPushRequestOptions::default()
370            },
371            thin: false,
372        },
373        &mut stream,
374    )?;
375    stream.flush()?;
376    let _ = stream.shutdown(Shutdown::Write);
377
378    let report = if plan.features.report_status {
379        let report = read_receive_pack_report_status(&mut stream)?;
380        crate::push::validate_receive_pack_report(&report)?;
381        Some(report)
382    } else {
383        let mut sink = Vec::new();
384        stream.read_to_end(&mut sink)?;
385        None
386    };
387    Ok(PushOutcome { commands, report })
388}
389
390fn receive_pack_advertisements(
391    remote: &RemoteUrl,
392    format: ObjectFormat,
393) -> Result<(TcpStream, Vec<RefAdvertisement>, ReceivePackFeatures)> {
394    let mut stream = connect_git_service(remote, GitService::ReceivePack, None)?;
395    let advertisement_set = read_ref_advertisement_set(format, &mut stream)?;
396    let features = advertisement_set
397        .refs
398        .first()
399        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
400        .transpose()?
401        .unwrap_or_default();
402    if let Some(remote_format) = features.object_format {
403        if remote_format != format {
404            return Err(GitError::InvalidObjectId(format!(
405                "remote repository uses {}, local repository uses {}",
406                remote_format.name(),
407                format.name()
408            )));
409        }
410    } else if format != ObjectFormat::Sha1 {
411        return Err(GitError::InvalidObjectId(format!(
412            "remote repository did not advertise object-format for {} push",
413            format.name()
414        )));
415    }
416    Ok((stream, advertisement_set.refs, features))
417}
418
419fn connect_git_service(
420    remote: &RemoteUrl,
421    service: GitService,
422    protocol: Option<ProtocolVersion>,
423) -> Result<TcpStream> {
424    if remote.transport != RemoteTransport::Git {
425        return Err(GitError::InvalidFormat(
426            "git:// service requires a git remote".into(),
427        ));
428    }
429    let host = remote
430        .host
431        .as_deref()
432        .ok_or_else(|| GitError::InvalidFormat("git:// remote is missing a host".into()))?;
433    let port = remote.port.unwrap_or(GIT_DAEMON_PORT);
434    let mut stream = TcpStream::connect((host, port))?;
435    let request = ServiceRequest {
436        service,
437        path: remote.path.clone(),
438        host: Some(git_host_parameter(remote, host, port)),
439        parameters: Vec::new(),
440        protocol,
441        extra_parameters: Vec::new(),
442    };
443    write_service_request(&mut stream, &request)?;
444    stream.flush()?;
445    Ok(stream)
446}
447
448fn git_protocol_v2_command_options(format: ObjectFormat) -> Vec<Capability> {
449    sley_protocol::encode_protocol_v2_command_options(&ProtocolV2CommandOptions {
450        object_format: Some(format),
451        ..ProtocolV2CommandOptions::default()
452    })
453    .unwrap_or_default()
454}
455
456fn git_protocol_v2_ls_refs(
457    remote: &RemoteUrl,
458    format: ObjectFormat,
459) -> Result<sley_protocol::RefAdvertisementSet> {
460    let mut stream =
461        connect_git_service(remote, GitService::UploadPack, Some(ProtocolVersion::V2))?;
462    let handshake = read_protocol_v2_advertisement(&mut stream)?;
463    let object_format = protocol_v2_object_format(&handshake.capabilities)?;
464    if object_format != format {
465        return Err(GitError::InvalidObjectId(format!(
466            "remote repository uses {}, local repository uses {}",
467            object_format.name(),
468            format.name()
469        )));
470    }
471    git_protocol_v2_ls_refs_on_stream(format, &mut stream)
472}
473
474fn git_protocol_v2_ls_refs_on_stream(
475    format: ObjectFormat,
476    stream: &mut TcpStream,
477) -> Result<sley_protocol::RefAdvertisementSet> {
478    let mut request = ProtocolV2LsRefsRequest {
479        peel: true,
480        symrefs: true,
481        unborn: true,
482        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
483    }
484    .to_command_request()?;
485    request.capabilities = git_protocol_v2_command_options(format);
486    write_protocol_v2_command_request(stream, &request)?;
487    stream.flush()?;
488    let records = read_protocol_v2_ls_refs_response(format, stream)?;
489    protocol_v2_ls_refs_records_to_ref_advertisement_set(&records)
490}
491
492fn upload_pack_features_from_v2(
493    handshake: &TransportHandshake,
494    refs: &[RefAdvertisement],
495) -> Result<UploadPackFeatures> {
496    let v2 = parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
497    let mut features = UploadPackFeatures {
498        object_format: Some(protocol_v2_object_format(&handshake.capabilities)?),
499        shallow: v2.shallow,
500        deepen_since: v2.shallow,
501        deepen_not: v2.shallow,
502        filter: v2.filter,
503        ..UploadPackFeatures::default()
504    };
505    if let Some(first) = refs.first() {
506        let bridged = parse_upload_pack_features(&first.capabilities)?;
507        features.symrefs = bridged.symrefs;
508    }
509    Ok(features)
510}
511
512fn git_protocol_v2_fetch_into_repository(
513    request: &GitFetchPackRequest<'_>,
514    haves: Vec<ObjectId>,
515    local_db: &FileObjectDatabase,
516) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
517    let mut stream = connect_git_service(
518        request.remote,
519        GitService::UploadPack,
520        Some(ProtocolVersion::V2),
521    )?;
522    let handshake = read_protocol_v2_advertisement(&mut stream)?;
523    let v2_features =
524        parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
525    let fetch = ProtocolV2FetchRequest {
526        wants: request.wants.clone(),
527        haves,
528        shallow: request.shallow.clone(),
529        deepen: request.deepen,
530        thin_pack: true,
531        include_tag: true,
532        ofs_delta: true,
533        done: true,
534        wait_for_done: v2_features.wait_for_done,
535        ..ProtocolV2FetchRequest::default()
536    };
537    write_protocol_v2_fetch_request(&mut stream, &fetch)?;
538    stream.flush()?;
539    let _ = stream.shutdown(Shutdown::Write);
540    let (header, _install) = if request.promisor {
541        install_protocol_v2_fetch_promisor_response_from_reader(
542            request.format,
543            &mut stream,
544            false,
545            local_db,
546        )?
547    } else {
548        install_protocol_v2_fetch_response_from_reader(
549            request.format,
550            &mut stream,
551            false,
552            local_db,
553        )?
554    };
555    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
556}
557
558fn git_host_parameter(remote: &RemoteUrl, host: &str, port: u16) -> String {
559    match remote.port {
560        Some(_) if host.contains(':') && !host.starts_with('[') => format!("[{host}]:{port}"),
561        Some(_) => format!("{host}:{port}"),
562        None => host.to_string(),
563    }
564}
565
566fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
567    if deepen.is_some() {
568        vec![Capability {
569            name: "shallow".into(),
570            value: None,
571        }]
572    } else {
573        Vec::new()
574    }
575}
576
577fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
578    for want in wants {
579        if !db.contains(want)? {
580            return Ok(false);
581        }
582    }
583    Ok(true)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use std::net::TcpListener;
590    use std::thread;
591
592    use sley_protocol::{ProtocolVersion, RefAdvertisement, RefAdvertisementSet};
593    use sley_transport::read_service_request;
594
595    #[test]
596    fn ls_remote_git_sends_daemon_request_and_reads_advertisements() {
597        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind daemon");
598        let port = listener.local_addr().expect("local addr").port();
599        let tip = ObjectId::from_hex(
600            ObjectFormat::Sha1,
601            "1111111111111111111111111111111111111111",
602        )
603        .expect("oid");
604        let server = thread::spawn(move || {
605            let (mut stream, _) = listener.accept().expect("accept daemon client");
606            let request = read_service_request(&mut stream).expect("service request");
607            assert_eq!(request.service, GitService::UploadPack);
608            assert_eq!(request.path, "/repo.git");
609            let expected_host = format!("127.0.0.1:{port}");
610            assert_eq!(request.host.as_deref(), Some(expected_host.as_str()));
611            sley_protocol::write_ref_advertisement_set(
612                &mut stream,
613                &RefAdvertisementSet {
614                    protocol: ProtocolVersion::V0,
615                    refs: vec![
616                        RefAdvertisement {
617                            oid: tip,
618                            name: "HEAD".into(),
619                            capabilities: vec![Capability {
620                                name: "symref".into(),
621                                value: Some("HEAD:refs/heads/main".into()),
622                            }],
623                        },
624                        RefAdvertisement {
625                            oid: tip,
626                            name: "refs/heads/main".into(),
627                            capabilities: Vec::new(),
628                        },
629                    ],
630                    shallow: Vec::new(),
631                },
632            )
633            .expect("write advertisements");
634        });
635        let remote = RemoteUrl {
636            transport: RemoteTransport::Git,
637            user: None,
638            password: None,
639            host: Some("127.0.0.1".into()),
640            port: Some(port),
641            path: "/repo.git".into(),
642        };
643        let (records, format) = ls_remote_git(
644            &remote,
645            &crate::ls_remote::LsRemoteFilter::default(),
646            &|_| true,
647            false,
648        )
649        .expect("ls-remote");
650
651        server.join().expect("server thread");
652        assert_eq!(format, ObjectFormat::Sha1);
653        assert_eq!(records.len(), 2);
654        assert_eq!(records[0].name, "HEAD");
655        assert_eq!(records[0].symref.as_deref(), Some("refs/heads/main"));
656        assert_eq!(records[1].name, "refs/heads/main");
657        assert_eq!(records[1].oid, tip);
658    }
659}