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 the same upload-pack / receive-pack protocol v0 streams used by
5//! SSH. Authentication and protocol-v2 negotiation are intentionally out of
6//! scope for this transport.
7
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::net::{Shutdown, TcpStream};
11use std::path::Path;
12
13use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
14use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
15use sley_odb::{FileObjectDatabase, build_reachable_pack, collect_reachable_object_ids};
16use sley_protocol::{
17    GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
18    ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
19    UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
20    build_receive_pack_push_request, parse_receive_pack_features, parse_refspec,
21    parse_upload_pack_features, plan_push_commands, read_receive_pack_report_status,
22    read_ref_advertisement_set, read_upload_pack_raw_packfile_response,
23    read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
24    write_upload_pack_negotiation_request, write_upload_pack_request,
25};
26use sley_refs::FileRefStore;
27use sley_transport::{RemoteTransport, RemoteUrl, ServiceRequest, write_service_request};
28
29use crate::{PushOutcome, PushRequest};
30
31const GIT_DAEMON_PORT: u16 = 9418;
32
33pub(crate) struct GitPushRequest<'a> {
34    pub git_dir: &'a Path,
35    pub common_git_dir: &'a Path,
36    pub format: ObjectFormat,
37    pub remote: &'a RemoteUrl,
38    pub refspecs: &'a [String],
39    pub force: bool,
40}
41
42pub(crate) struct GitPushCommandsRequest<'a> {
43    pub common_git_dir: &'a Path,
44    pub format: ObjectFormat,
45    pub remote: &'a RemoteUrl,
46    pub command_forces: Vec<(ReceivePackCommand, bool)>,
47    pub pack_objects: Vec<ObjectId>,
48}
49
50pub(crate) struct GitPushPlan {
51    pub(crate) commands: Vec<ReceivePackCommand>,
52    pub(crate) pack_objects: Vec<ObjectId>,
53    stream: Option<TcpStream>,
54    features: ReceivePackFeatures,
55    advertisements: Vec<RefAdvertisement>,
56}
57
58pub struct GitFetchPackRequest<'a> {
59    pub git_dir: &'a Path,
60    pub format: ObjectFormat,
61    pub remote: &'a RemoteUrl,
62    pub features: &'a UploadPackFeatures,
63    pub wants: Vec<ObjectId>,
64    pub shallow: Vec<ObjectId>,
65    pub deepen: Option<u32>,
66    pub promisor: bool,
67}
68
69pub(crate) fn ls_remote_git(
70    remote: &RemoteUrl,
71    filter: &crate::ls_remote::LsRemoteFilter,
72    matches: &dyn Fn(&str) -> bool,
73) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
74    let mut stream = connect_git_service(remote, GitService::UploadPack)?;
75    let set = read_ref_advertisement_set(ObjectFormat::Sha1, &mut stream)?;
76    let features = set
77        .refs
78        .first()
79        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
80        .transpose()?
81        .unwrap_or_default();
82    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
83    if format != ObjectFormat::Sha1 {
84        return Err(GitError::Unsupported(format!(
85            "git:// ls-remote currently supports SHA-1 advertisements, got {}",
86            format.name()
87        )));
88    }
89    let symrefs = features
90        .symrefs
91        .iter()
92        .filter_map(|symref| symref.split_once(':'))
93        .map(|(name, target)| (name.to_string(), target.to_string()))
94        .collect::<HashMap<_, _>>();
95    let mut records = Vec::new();
96    for advertisement in set.refs {
97        if advertisement.oid.is_null() {
98            continue;
99        }
100        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
101        {
102            continue;
103        }
104        let is_head = advertisement.name.starts_with("refs/heads/");
105        let is_tag = advertisement.name.starts_with("refs/tags/");
106        if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
107        {
108            continue;
109        }
110        if !matches(&advertisement.name) {
111            continue;
112        }
113        records.push(crate::ls_remote::LsRemoteRecord {
114            oid: advertisement.oid,
115            symref: symrefs.get(&advertisement.name).cloned(),
116            name: advertisement.name,
117        });
118    }
119    Ok((records, format))
120}
121
122pub fn git_upload_pack_advertisements(
123    remote: &RemoteUrl,
124    format: ObjectFormat,
125) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
126    let mut stream = connect_git_service(remote, GitService::UploadPack)?;
127    let set = read_ref_advertisement_set(format, &mut stream)?;
128    let features = set
129        .refs
130        .first()
131        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
132        .transpose()?
133        .unwrap_or_default();
134    Ok((set.refs, features))
135}
136
137pub fn install_fetch_pack_via_git_upload_pack(
138    request: GitFetchPackRequest<'_>,
139) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
140    if request.wants.is_empty() {
141        return Ok(Vec::new());
142    }
143    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
144    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
145        return Ok(Vec::new());
146    }
147    let upload_request = UploadPackRequest {
148        wants: request.wants,
149        capabilities: shallow_request_capabilities(request.deepen),
150        shallow: request.shallow,
151        deepen: request.deepen,
152        ..UploadPackRequest::default()
153    };
154    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
155    let (shallow_info, response) = if request.deepen.is_some() {
156        git_upload_pack_shallow_fetch_response(
157            request.remote,
158            request.format,
159            request.features,
160            upload_request,
161            haves,
162        )?
163    } else {
164        let response = git_upload_pack_fetch_response(
165            request.remote,
166            request.format,
167            request.features,
168            upload_request,
169            haves,
170        )?;
171        (Vec::new(), response)
172    };
173    if request.promisor {
174        install_upload_pack_raw_promisor_response(&response, &local_db)?;
175    } else {
176        install_upload_pack_raw_response(&response, &local_db)?;
177    }
178    Ok(shallow_info)
179}
180
181pub fn git_upload_pack_fetch_response(
182    remote: &RemoteUrl,
183    format: ObjectFormat,
184    _features: &UploadPackFeatures,
185    request: UploadPackRequest,
186    haves: Vec<ObjectId>,
187) -> Result<UploadPackRawPackfileResponse> {
188    let (_shallow, response) =
189        git_upload_pack_fetch_response_inner(remote, format, request, haves, false)?;
190    Ok(response)
191}
192
193pub fn git_upload_pack_shallow_fetch_response(
194    remote: &RemoteUrl,
195    format: ObjectFormat,
196    _features: &UploadPackFeatures,
197    request: UploadPackRequest,
198    haves: Vec<ObjectId>,
199) -> Result<(
200    Vec<ProtocolV2FetchShallowInfo>,
201    UploadPackRawPackfileResponse,
202)> {
203    git_upload_pack_fetch_response_inner(remote, format, request, haves, true)
204}
205
206fn git_upload_pack_fetch_response_inner(
207    remote: &RemoteUrl,
208    format: ObjectFormat,
209    request: UploadPackRequest,
210    haves: Vec<ObjectId>,
211    expect_shallow_info: bool,
212) -> Result<(
213    Vec<ProtocolV2FetchShallowInfo>,
214    UploadPackRawPackfileResponse,
215)> {
216    let mut stream = connect_git_service(remote, GitService::UploadPack)?;
217    read_ref_advertisement_set(format, &mut stream)?;
218    write_upload_pack_request(&mut stream, Some(&request))?;
219    write_upload_pack_negotiation_request(
220        &mut stream,
221        &UploadPackNegotiationRequest { haves, done: true },
222    )?;
223    stream.flush()?;
224    let _ = stream.shutdown(Shutdown::Write);
225    if expect_shallow_info {
226        read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stream)
227    } else {
228        Ok((
229            Vec::new(),
230            read_upload_pack_raw_packfile_response(format, &mut stream)?,
231        ))
232    }
233}
234
235pub(crate) fn plan_push_git(request: GitPushRequest<'_>) -> Result<GitPushPlan> {
236    let GitPushRequest {
237        git_dir,
238        common_git_dir,
239        format,
240        remote,
241        refspecs,
242        force,
243    } = request;
244    let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
245
246    let local_store = FileRefStore::new(git_dir, format);
247    let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
248    let parsed_refspecs = refspecs
249        .iter()
250        .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
251        .collect::<Result<Vec<_>>>()?;
252    let mut command_forces = Vec::new();
253    for refspec in &parsed_refspecs {
254        for command in plan_push_commands(
255            format,
256            &local_refs,
257            &advertisements,
258            std::slice::from_ref(refspec),
259        )? {
260            command_forces.push((command, force || refspec.force));
261        }
262    }
263    let commands = command_forces
264        .iter()
265        .map(|(command, _)| command.clone())
266        .collect::<Vec<_>>();
267    if commands.is_empty() {
268        let _ = stream.shutdown(Shutdown::Both);
269        return Ok(GitPushPlan {
270            commands,
271            pack_objects: Vec::new(),
272            stream: None,
273            features,
274            advertisements,
275        });
276    }
277
278    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
279    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
280    Ok(GitPushPlan {
281        commands,
282        pack_objects: Vec::new(),
283        stream: Some(stream),
284        features,
285        advertisements,
286    })
287}
288
289pub(crate) fn plan_push_git_commands(request: GitPushCommandsRequest<'_>) -> Result<GitPushPlan> {
290    let GitPushCommandsRequest {
291        common_git_dir,
292        format,
293        remote,
294        command_forces,
295        pack_objects,
296    } = request;
297    let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
298    let commands = command_forces
299        .iter()
300        .map(|(command, _)| command.clone())
301        .collect::<Vec<_>>();
302    if commands.is_empty() {
303        let _ = stream.shutdown(Shutdown::Both);
304        return Ok(GitPushPlan {
305            commands,
306            pack_objects,
307            stream: None,
308            features,
309            advertisements,
310        });
311    }
312
313    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
314    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
315    Ok(GitPushPlan {
316        commands,
317        pack_objects,
318        stream: Some(stream),
319        features,
320        advertisements,
321    })
322}
323
324pub(crate) fn execute_push_git_plan(
325    request: PushRequest<'_>,
326    mut plan: GitPushPlan,
327) -> Result<PushOutcome> {
328    if plan.commands.is_empty() {
329        return Ok(PushOutcome::default());
330    }
331    let mut stream = plan
332        .stream
333        .take()
334        .ok_or_else(|| GitError::Command("git:// receive-pack stream was not available".into()))?;
335    let commands = plan.commands.clone();
336    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
337    let remote_excluded_tips =
338        crate::remote_advertisement_tips_known_to_local(&local_db, &plan.advertisements)?;
339    let remote_excluded =
340        collect_reachable_object_ids(&local_db, request.format, remote_excluded_tips)?;
341    let starts = crate::pack::push_pack_roots(&commands, &plan.pack_objects);
342    let packfile = build_reachable_pack(&local_db, request.format, starts, &remote_excluded)?
343        .map(|pack| pack.pack)
344        .unwrap_or_default();
345    let receive_request = build_receive_pack_push_request(
346        &plan.features,
347        commands.clone(),
348        packfile,
349        ReceivePackPushRequestOptions {
350            report_status: plan.features.report_status,
351            ofs_delta: plan.features.ofs_delta,
352            quiet: request.options.quiet && plan.features.quiet,
353            object_format: plan
354                .features
355                .object_format
356                .filter(|_| request.format != ObjectFormat::Sha1),
357            ..ReceivePackPushRequestOptions::default()
358        },
359    )?;
360    write_receive_pack_push_request(&mut stream, &receive_request)?;
361    stream.flush()?;
362    let _ = stream.shutdown(Shutdown::Write);
363
364    let report = if plan.features.report_status {
365        let report = read_receive_pack_report_status(&mut stream)?;
366        crate::push::validate_receive_pack_report(&report)?;
367        Some(report)
368    } else {
369        let mut sink = Vec::new();
370        stream.read_to_end(&mut sink)?;
371        None
372    };
373    Ok(PushOutcome { commands, report })
374}
375
376fn receive_pack_advertisements(
377    remote: &RemoteUrl,
378    format: ObjectFormat,
379) -> Result<(TcpStream, Vec<RefAdvertisement>, ReceivePackFeatures)> {
380    let mut stream = connect_git_service(remote, GitService::ReceivePack)?;
381    let advertisement_set = read_ref_advertisement_set(format, &mut stream)?;
382    let features = advertisement_set
383        .refs
384        .first()
385        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
386        .transpose()?
387        .unwrap_or_default();
388    if let Some(remote_format) = features.object_format {
389        if remote_format != format {
390            return Err(GitError::InvalidObjectId(format!(
391                "remote repository uses {}, local repository uses {}",
392                remote_format.name(),
393                format.name()
394            )));
395        }
396    } else if format != ObjectFormat::Sha1 {
397        return Err(GitError::InvalidObjectId(format!(
398            "remote repository did not advertise object-format for {} push",
399            format.name()
400        )));
401    }
402    Ok((stream, advertisement_set.refs, features))
403}
404
405fn connect_git_service(remote: &RemoteUrl, service: GitService) -> Result<TcpStream> {
406    if remote.transport != RemoteTransport::Git {
407        return Err(GitError::InvalidFormat(
408            "git:// service requires a git remote".into(),
409        ));
410    }
411    let host = remote
412        .host
413        .as_deref()
414        .ok_or_else(|| GitError::InvalidFormat("git:// remote is missing a host".into()))?;
415    let port = remote.port.unwrap_or(GIT_DAEMON_PORT);
416    let mut stream = TcpStream::connect((host, port))?;
417    let request = ServiceRequest {
418        service,
419        path: remote.path.clone(),
420        host: Some(git_host_parameter(remote, host, port)),
421        parameters: Vec::new(),
422        protocol: None,
423        extra_parameters: Vec::new(),
424    };
425    write_service_request(&mut stream, &request)?;
426    stream.flush()?;
427    Ok(stream)
428}
429
430fn git_host_parameter(remote: &RemoteUrl, host: &str, port: u16) -> String {
431    match remote.port {
432        Some(_) if host.contains(':') && !host.starts_with('[') => format!("[{host}]:{port}"),
433        Some(_) => format!("{host}:{port}"),
434        None => host.to_string(),
435    }
436}
437
438fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
439    if deepen.is_some() {
440        vec![Capability {
441            name: "shallow".into(),
442            value: None,
443        }]
444    } else {
445        Vec::new()
446    }
447}
448
449fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
450    for want in wants {
451        if !db.contains(want)? {
452            return Ok(false);
453        }
454    }
455    Ok(true)
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::net::TcpListener;
462    use std::thread;
463
464    use sley_protocol::{ProtocolVersion, RefAdvertisement, RefAdvertisementSet};
465    use sley_transport::read_service_request;
466
467    #[test]
468    fn ls_remote_git_sends_daemon_request_and_reads_advertisements() {
469        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind daemon");
470        let port = listener.local_addr().expect("local addr").port();
471        let tip = ObjectId::from_hex(
472            ObjectFormat::Sha1,
473            "1111111111111111111111111111111111111111",
474        )
475        .expect("oid");
476        let server = thread::spawn(move || {
477            let (mut stream, _) = listener.accept().expect("accept daemon client");
478            let request = read_service_request(&mut stream).expect("service request");
479            assert_eq!(request.service, GitService::UploadPack);
480            assert_eq!(request.path, "/repo.git");
481            let expected_host = format!("127.0.0.1:{port}");
482            assert_eq!(request.host.as_deref(), Some(expected_host.as_str()));
483            sley_protocol::write_ref_advertisement_set(
484                &mut stream,
485                &RefAdvertisementSet {
486                    protocol: ProtocolVersion::V0,
487                    refs: vec![
488                        RefAdvertisement {
489                            oid: tip,
490                            name: "HEAD".into(),
491                            capabilities: vec![Capability {
492                                name: "symref".into(),
493                                value: Some("HEAD:refs/heads/main".into()),
494                            }],
495                        },
496                        RefAdvertisement {
497                            oid: tip,
498                            name: "refs/heads/main".into(),
499                            capabilities: Vec::new(),
500                        },
501                    ],
502                    shallow: Vec::new(),
503                },
504            )
505            .expect("write advertisements");
506        });
507        let remote = RemoteUrl {
508            transport: RemoteTransport::Git,
509            user: None,
510            password: None,
511            host: Some("127.0.0.1".into()),
512            port: Some(port),
513            path: "/repo.git".into(),
514        };
515        let (records, format) = ls_remote_git(
516            &remote,
517            &crate::ls_remote::LsRemoteFilter::default(),
518            &|_| true,
519        )
520        .expect("ls-remote");
521
522        server.join().expect("server thread");
523        assert_eq!(format, ObjectFormat::Sha1);
524        assert_eq!(records.len(), 2);
525        assert_eq!(records[0].name, "HEAD");
526        assert_eq!(records[0].symref.as_deref(), Some("refs/heads/main"));
527        assert_eq!(records[1].name, "refs/heads/main");
528        assert_eq!(records[1].oid, tip);
529    }
530}