Skip to main content

sley_remote/
ssh.rs

1//! Callable SSH transport plumbing and fetch/push/ls-remote orchestration.
2//!
3//! These drive the transport-agnostic protocol codecs ([`sley_protocol`]) over an
4//! `ssh` subprocess spawned via [`sley_transport::ssh_process_command`]. Like the
5//! HTTP and local paths, everything is taken as explicit parameters — the
6//! already-resolved [`RemoteUrl`], the [`ObjectFormat`], `git_dir`, the repository
7//! [`GitConfig`], and the seam objects ([`CredentialProvider`], [`ProgressSink`]) —
8//! so they never read process-global state, parse arguments, or print. SSH does not
9//! authenticate at this layer (it delegates to the `ssh` program), so the
10//! credential seam is accepted for uniformity but unused.
11//!
12//! The `ssh` program is chosen by [`ssh_program`], which reads the `GIT_SSH`
13//! environment variable (git's standard mechanism) and falls back to `ssh`; this is
14//! the one piece of ambient state the SSH transport inherently depends on.
15//!
16//! SSH mirrors the HTTP path ([`crate::http`]): two ref advertisements are read
17//! from the spawned process's stdout (the second, re-advertised set in the RPC
18//! stream is skipped), then the upload-pack/receive-pack request is written to its
19//! stdin and the packfile/report read back from stdout. The ref-map / `FETCH_HEAD`
20//! / prune helpers and the push-planning helpers are shared with the other
21//! transports via [`crate::fetch`] and [`crate::push`].
22
23use std::collections::HashMap;
24use std::env;
25use std::io::{Read, Write};
26use std::path::Path;
27use std::process::{Child, ChildStdin, ChildStdout, Command as ProcessCommand, Stdio};
28
29use sley_config::GitConfig;
30use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
31use sley_fetch::{
32    install_upload_pack_raw_promisor_response_from_reader,
33    install_upload_pack_raw_response_from_reader,
34    install_upload_pack_shallow_raw_promisor_response_from_reader,
35    install_upload_pack_shallow_raw_response_from_reader,
36};
37use sley_odb::FileObjectDatabase;
38use sley_protocol::write_pkt_line_payload;
39use sley_protocol::{
40    GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
41    ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
42    UploadPackNegotiationRequest, UploadPackRequest, parse_receive_pack_features,
43    parse_upload_pack_features, read_receive_pack_report_status, read_ref_advertisement_set,
44    write_upload_pack_negotiation_request, write_upload_pack_request,
45};
46use sley_refs::FileRefStore;
47use sley_transport::{
48    RemoteTransport, RemoteUrl, SshCommandVariant, SshIpVersion, ssh_process_args_with_ip,
49};
50
51use crate::{PushOutcome, PushRequest};
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub struct SshTransportOptions {
55    pub variant: Option<SshCommandVariant>,
56    pub ip_version: Option<SshIpVersion>,
57}
58
59pub fn ssh_transport_options_from_config(config: &GitConfig) -> SshTransportOptions {
60    SshTransportOptions {
61        variant: config
62            .get("ssh", None, "variant")
63            .and_then(ssh_variant_from_config_value),
64        ip_version: None,
65    }
66}
67
68fn ssh_variant_from_config_value(value: &str) -> Option<SshCommandVariant> {
69    match value.to_ascii_lowercase().as_str() {
70        "ssh" => Some(SshCommandVariant::OpenSsh),
71        "plink" | "putty" => Some(SshCommandVariant::Plink),
72        "tortoiseplink" => Some(SshCommandVariant::TortoisePlink),
73        "simple" => Some(SshCommandVariant::Simple),
74        "auto" => None,
75        _ => None,
76    }
77}
78
79/// The `ssh` program to spawn for SSH transport: `GIT_SSH_COMMAND`'s first shell
80/// word, then `GIT_SSH`, then `ssh`.
81pub fn ssh_program() -> String {
82    match ssh_program_and_prefix_args() {
83        Ok((program, _)) => program,
84        Err(_) => env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into()),
85    }
86}
87
88fn ssh_process_command_for_remote(
89    remote: &RemoteUrl,
90    service: GitService,
91    options: SshTransportOptions,
92) -> Result<ServiceProcessCommand> {
93    if remote.transport == RemoteTransport::Ext {
94        return ext_process_command_for_remote(remote, service);
95    }
96    let (program, mut args) = ssh_program_and_prefix_args()?;
97    let variant = ssh_command_variant(&program, options.variant);
98    args.extend(ssh_process_args_with_ip(
99        remote,
100        service,
101        variant,
102        options.ip_version,
103    )?);
104    Ok(ServiceProcessCommand {
105        program,
106        args,
107        env: Vec::new(),
108        git_request: None,
109    })
110}
111
112struct ServiceProcessCommand {
113    program: String,
114    args: Vec<String>,
115    env: Vec<(String, String)>,
116    git_request: Option<ExtGitRequest>,
117}
118
119struct ExtGitRequest {
120    repo: String,
121    vhost: Option<String>,
122}
123
124fn ext_process_command_for_remote(
125    remote: &RemoteUrl,
126    service: GitService,
127) -> Result<ServiceProcessCommand> {
128    let parsed = parse_remote_ext_command(&remote.path, service)?;
129    let Some((program, args)) = parsed.argv.split_first() else {
130        return Err(GitError::InvalidFormat(
131            "ext remote command is empty".into(),
132        ));
133    };
134    Ok(ServiceProcessCommand {
135        program: program.clone(),
136        args: args.to_vec(),
137        env: vec![
138            ("GIT_EXT_SERVICE".into(), service.as_str().into()),
139            (
140                "GIT_EXT_SERVICE_NOPREFIX".into(),
141                service
142                    .as_str()
143                    .strip_prefix("git-")
144                    .unwrap_or(service.as_str())
145                    .into(),
146            ),
147        ],
148        git_request: parsed.git_request.map(|repo| ExtGitRequest {
149            repo,
150            vhost: parsed.git_request_vhost,
151        }),
152    })
153}
154
155struct ParsedRemoteExtCommand {
156    argv: Vec<String>,
157    git_request: Option<String>,
158    git_request_vhost: Option<String>,
159}
160
161fn parse_remote_ext_command(command: &str, service: GitService) -> Result<ParsedRemoteExtCommand> {
162    let service_name = service.as_str();
163    let service_noprefix = service_name.strip_prefix("git-").unwrap_or(service_name);
164    let mut rest = command;
165    let mut argv = Vec::new();
166    let mut git_request = None;
167    let mut git_request_vhost = None;
168
169    while !rest.is_empty() {
170        let (arg, next) = parse_remote_ext_arg(rest, service_name, service_noprefix)?;
171        rest = next;
172        match arg {
173            RemoteExtArg::Arg(value) => argv.push(value),
174            RemoteExtArg::GitRequest(value) => git_request = Some(value),
175            RemoteExtArg::GitRequestVhost(value) => git_request_vhost = Some(value),
176        }
177    }
178
179    Ok(ParsedRemoteExtCommand {
180        argv,
181        git_request,
182        git_request_vhost,
183    })
184}
185
186enum RemoteExtArg {
187    Arg(String),
188    GitRequest(String),
189    GitRequestVhost(String),
190}
191
192fn parse_remote_ext_arg<'a>(
193    input: &'a str,
194    service: &str,
195    service_noprefix: &str,
196) -> Result<(RemoteExtArg, &'a str)> {
197    let bytes = input.as_bytes();
198    let mut end = 0;
199    let mut escaped = false;
200    let mut special = None::<u8>;
201    while end < bytes.len() && (escaped || bytes[end] != b' ') {
202        if escaped {
203            match bytes[end] {
204                b' ' | b'%' | b's' | b'S' => {}
205                b'G' | b'V' if end == 1 => special = Some(bytes[end]),
206                other => {
207                    return Err(GitError::InvalidFormat(format!(
208                        "Bad remote-ext placeholder '%{}'",
209                        other as char
210                    )));
211                }
212            }
213            escaped = false;
214        } else {
215            escaped = bytes[end] == b'%';
216        }
217        end += 1;
218    }
219    if escaped && end == bytes.len() {
220        return Err(GitError::InvalidFormat(
221            "remote-ext command has incomplete placeholder".into(),
222        ));
223    }
224    let mut next = &input[end..];
225    if next.starts_with(' ') {
226        next = &next[1..];
227    }
228
229    let body = if special.is_some() {
230        &input[2..end]
231    } else {
232        &input[..end]
233    };
234    let expanded = expand_remote_ext_arg(body, service, service_noprefix)?;
235    let arg = match special {
236        Some(b'G') => RemoteExtArg::GitRequest(expanded),
237        Some(b'V') => RemoteExtArg::GitRequestVhost(expanded),
238        Some(_) => unreachable!("validated remote-ext special"),
239        None => RemoteExtArg::Arg(expanded),
240    };
241    Ok((arg, next))
242}
243
244fn expand_remote_ext_arg(input: &str, service: &str, service_noprefix: &str) -> Result<String> {
245    let mut out = String::new();
246    let bytes = input.as_bytes();
247    let mut pos = 0;
248    while pos < bytes.len() {
249        if bytes[pos] != b'%' {
250            out.push(bytes[pos] as char);
251            pos += 1;
252            continue;
253        }
254        pos += 1;
255        if pos == bytes.len() {
256            return Err(GitError::InvalidFormat(
257                "remote-ext command has incomplete placeholder".into(),
258            ));
259        }
260        match bytes[pos] {
261            b' ' => out.push(' '),
262            b'%' => out.push('%'),
263            b's' => out.push_str(service_noprefix),
264            b'S' => out.push_str(service),
265            other => {
266                return Err(GitError::InvalidFormat(format!(
267                    "Bad remote-ext placeholder '%{}'",
268                    other as char
269                )));
270            }
271        }
272        pos += 1;
273    }
274    Ok(out)
275}
276
277fn ssh_command_variant(
278    program: &str,
279    config_variant: Option<SshCommandVariant>,
280) -> SshCommandVariant {
281    if let Ok(variant) = env::var("GIT_SSH_VARIANT") {
282        return match variant.as_str() {
283            "auto" => detect_ssh_command_variant(program),
284            "ssh" => SshCommandVariant::OpenSsh,
285            "simple" => SshCommandVariant::Simple,
286            "plink" | "putty" => SshCommandVariant::Plink,
287            "tortoiseplink" => SshCommandVariant::TortoisePlink,
288            _ => detect_ssh_command_variant(program),
289        };
290    }
291    config_variant.unwrap_or_else(|| detect_ssh_command_variant(program))
292}
293
294fn detect_ssh_command_variant(program: &str) -> SshCommandVariant {
295    let basename = Path::new(&program)
296        .file_name()
297        .and_then(|value| value.to_str())
298        .unwrap_or(program)
299        .to_ascii_lowercase();
300    match basename.as_str() {
301        "plink" | "plink.exe" => SshCommandVariant::Plink,
302        "tortoiseplink" | "tortoiseplink.exe" => SshCommandVariant::TortoisePlink,
303        "simple" => SshCommandVariant::Simple,
304        "uplink" => {
305            if ssh_supports_openssh_config_probe(program) {
306                SshCommandVariant::OpenSsh
307            } else {
308                SshCommandVariant::Simple
309            }
310        }
311        _ => SshCommandVariant::OpenSsh,
312    }
313}
314
315fn ssh_supports_openssh_config_probe(program: &str) -> bool {
316    ProcessCommand::new(program)
317        .arg("-G")
318        .arg("example.com")
319        .stdin(Stdio::null())
320        .stdout(Stdio::null())
321        .stderr(Stdio::null())
322        .status()
323        .map(|status| status.success())
324        .unwrap_or(false)
325}
326
327fn ssh_program_and_prefix_args() -> Result<(String, Vec<String>)> {
328    if let Ok(command) = env::var("GIT_SSH_COMMAND") {
329        let words = split_shell_words(&command)?;
330        let Some((program, args)) = words.split_first() else {
331            return Err(GitError::Command("GIT_SSH_COMMAND is empty".into()));
332        };
333        return Ok((program.clone(), args.to_vec()));
334    }
335    Ok((
336        env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into()),
337        Vec::new(),
338    ))
339}
340
341fn split_shell_words(command: &str) -> Result<Vec<String>> {
342    let mut words = Vec::new();
343    let mut current = String::new();
344    let mut chars = command.chars().peekable();
345    let mut quote = None::<char>;
346    while let Some(ch) = chars.next() {
347        if let Some(quote_ch) = quote {
348            if ch == quote_ch {
349                quote = None;
350            } else if ch == '\\' && quote_ch == '"' {
351                if let Some(next) = chars.next() {
352                    current.push(next);
353                }
354            } else {
355                current.push(ch);
356            }
357            continue;
358        }
359        match ch {
360            '\'' | '"' => quote = Some(ch),
361            '\\' => {
362                if let Some(next) = chars.next() {
363                    current.push(next);
364                }
365            }
366            ch if ch.is_whitespace() => {
367                if !current.is_empty() {
368                    words.push(std::mem::take(&mut current));
369                }
370            }
371            _ => current.push(ch),
372        }
373    }
374    if quote.is_some() {
375        return Err(GitError::Command(
376            "unclosed quote in GIT_SSH_COMMAND".into(),
377        ));
378    }
379    if !current.is_empty() {
380        words.push(current);
381    }
382    Ok(words)
383}
384
385fn spawn_service_process(
386    remote: &RemoteUrl,
387    service: GitService,
388    keep_stdin: bool,
389    options: SshTransportOptions,
390) -> Result<(Child, Option<ChildStdin>, ChildStdout)> {
391    let command = ssh_process_command_for_remote(remote, service, options)?;
392    let mut process = ProcessCommand::new(&command.program);
393    process
394        .args(&command.args)
395        .envs(command.env)
396        .env_remove("GIT_EXEC_PATH")
397        .stdin(if keep_stdin || command.git_request.is_some() {
398            Stdio::piped()
399        } else {
400            Stdio::null()
401        })
402        .stdout(Stdio::piped())
403        .stderr(Stdio::piped());
404    let mut child = process.spawn()?;
405    let mut stdin = child.stdin.take();
406    if let Some(request) = &command.git_request {
407        let Some(input) = stdin.as_mut() else {
408            return Err(GitError::Command("remote-ext stdin was not piped".into()));
409        };
410        write_remote_ext_git_request(input, service, request)?;
411    }
412    if !keep_stdin {
413        drop(stdin.take());
414    }
415    let stdout = child
416        .stdout
417        .take()
418        .ok_or_else(|| GitError::Command("service stdout was not piped".into()))?;
419    Ok((child, stdin, stdout))
420}
421
422fn write_remote_ext_git_request(
423    writer: &mut impl Write,
424    service: GitService,
425    request: &ExtGitRequest,
426) -> Result<()> {
427    let mut payload = Vec::new();
428    payload.extend_from_slice(service.as_str().as_bytes());
429    payload.push(b' ');
430    payload.extend_from_slice(request.repo.as_bytes());
431    payload.push(0);
432    if let Some(vhost) = &request.vhost {
433        payload.extend_from_slice(b"host=");
434        payload.extend_from_slice(vhost.as_bytes());
435        payload.push(0);
436    }
437    write_pkt_line_payload(writer, &payload)
438}
439
440/// Push to a resolved SSH `remote` from the repository at `git_dir`.
441///
442/// Performs the work the CLI's `push_ssh_repository` did, sharing the
443/// push-planning helpers with the HTTP and local transports: advertises the
444/// remote's refs over `ssh`, plans the receive-pack commands for `refspecs`,
445/// rejects non-fast-forward updates (unless forced), builds the pack of objects
446/// the remote lacks, sends the receive-pack request, and validates the
447/// report-status. `credentials` is accepted for seam uniformity but unused. The
448/// "To <remote>" summary and set-upstream config stay with the caller, driven from
449/// [`PushOutcome::commands`].
450pub(crate) struct SshPushRequest<'a> {
451    pub git_dir: &'a Path,
452    pub common_git_dir: &'a Path,
453    pub format: ObjectFormat,
454    pub remote: &'a RemoteUrl,
455    pub refspecs: &'a [String],
456    pub force: bool,
457}
458
459pub(crate) struct SshPushCommandsRequest<'a> {
460    pub common_git_dir: &'a Path,
461    pub format: ObjectFormat,
462    pub remote: &'a RemoteUrl,
463    pub command_forces: Vec<(ReceivePackCommand, bool)>,
464    pub pack_objects: Vec<ObjectId>,
465}
466
467pub(crate) struct SshPushPlan {
468    pub(crate) commands: Vec<ReceivePackCommand>,
469    pub(crate) pack_objects: Vec<ObjectId>,
470    child: Child,
471    stdin: Option<ChildStdin>,
472    stdout: ChildStdout,
473    features: ReceivePackFeatures,
474    advertisements: Vec<RefAdvertisement>,
475    remote: RemoteUrl,
476}
477
478pub(crate) fn plan_push_ssh(request: SshPushRequest<'_>) -> Result<SshPushPlan> {
479    let SshPushRequest {
480        git_dir,
481        common_git_dir,
482        format,
483        remote,
484        refspecs,
485        force,
486    } = request;
487    if !matches!(
488        remote.transport,
489        RemoteTransport::Ssh | RemoteTransport::Ext
490    ) {
491        return Err(GitError::InvalidFormat(
492            "SSH receive-pack requires an SSH remote".into(),
493        ));
494    }
495    let (child, stdin, mut stdout) = spawn_service_process(
496        remote,
497        GitService::ReceivePack,
498        true,
499        SshTransportOptions::default(),
500    )?;
501    let stdin =
502        stdin.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
503
504    let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
505    let features = advertisement_set
506        .refs
507        .first()
508        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
509        .transpose()?
510        .unwrap_or_default();
511    if let Some(remote_format) = features.object_format {
512        if remote_format != format {
513            return Err(GitError::InvalidObjectId(format!(
514                "remote repository uses {}, local repository uses {}",
515                remote_format.name(),
516                format.name()
517            )));
518        }
519    } else if format != ObjectFormat::Sha1 {
520        return Err(GitError::InvalidObjectId(format!(
521            "remote repository did not advertise object-format for {} push",
522            format.name()
523        )));
524    }
525
526    let local_store = FileRefStore::new(git_dir, format);
527    let mut local_refs = crate::push::local_push_source_refs(&local_store, format)?;
528    crate::push::add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
529    let command_forces = crate::push::plan_push_command_forces(
530        format,
531        &local_refs,
532        &advertisement_set.refs,
533        refspecs,
534        force,
535    )?;
536    let commands = command_forces
537        .iter()
538        .map(|(command, _)| command.clone())
539        .collect::<Vec<_>>();
540    if commands.is_empty() {
541        drop(stdin);
542        return Ok(SshPushPlan {
543            commands,
544            pack_objects: Vec::new(),
545            child,
546            stdin: None,
547            stdout,
548            features,
549            advertisements: advertisement_set.refs,
550            remote: remote.clone(),
551        });
552    }
553
554    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
555    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
556    Ok(SshPushPlan {
557        commands,
558        pack_objects: Vec::new(),
559        child,
560        stdin: Some(stdin),
561        stdout,
562        features,
563        advertisements: advertisement_set.refs,
564        remote: remote.clone(),
565    })
566}
567
568pub(crate) fn plan_push_ssh_commands(request: SshPushCommandsRequest<'_>) -> Result<SshPushPlan> {
569    let SshPushCommandsRequest {
570        common_git_dir,
571        format,
572        remote,
573        command_forces,
574        pack_objects,
575    } = request;
576    if !matches!(
577        remote.transport,
578        RemoteTransport::Ssh | RemoteTransport::Ext
579    ) {
580        return Err(GitError::InvalidFormat(
581            "SSH receive-pack requires an SSH remote".into(),
582        ));
583    }
584    let (child, stdin, mut stdout) = spawn_service_process(
585        remote,
586        GitService::ReceivePack,
587        true,
588        SshTransportOptions::default(),
589    )?;
590    let stdin =
591        stdin.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
592
593    let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
594    let features = advertisement_set
595        .refs
596        .first()
597        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
598        .transpose()?
599        .unwrap_or_default();
600    if let Some(remote_format) = features.object_format {
601        if remote_format != format {
602            return Err(GitError::InvalidObjectId(format!(
603                "remote repository uses {}, local repository uses {}",
604                remote_format.name(),
605                format.name()
606            )));
607        }
608    } else if format != ObjectFormat::Sha1 {
609        return Err(GitError::InvalidObjectId(format!(
610            "remote repository did not advertise object-format for {} push",
611            format.name()
612        )));
613    }
614
615    let commands = command_forces
616        .iter()
617        .map(|(command, _)| command.clone())
618        .collect::<Vec<_>>();
619
620    if commands.is_empty() {
621        drop(stdin);
622        return Ok(SshPushPlan {
623            commands,
624            pack_objects,
625            child,
626            stdin: None,
627            stdout,
628            features,
629            advertisements: advertisement_set.refs,
630            remote: remote.clone(),
631        });
632    }
633
634    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
635    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
636    Ok(SshPushPlan {
637        commands,
638        pack_objects,
639        child,
640        stdin: Some(stdin),
641        stdout,
642        features,
643        advertisements: advertisement_set.refs,
644        remote: remote.clone(),
645    })
646}
647
648pub(crate) fn execute_push_ssh_plan(
649    request: PushRequest<'_>,
650    mut plan: SshPushPlan,
651) -> Result<PushOutcome> {
652    if plan.commands.is_empty() {
653        return Ok(PushOutcome::default());
654    }
655    let mut stdin = plan
656        .stdin
657        .take()
658        .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not available".into()))?;
659    let commands = plan.commands.clone();
660    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
661    crate::pack::write_receive_pack_body(
662        &crate::pack::PushPackRequest {
663            local_db: &local_db,
664            format: request.format,
665            commands: &commands,
666            pack_objects: &plan.pack_objects,
667            remote_advertisements: &plan.advertisements,
668            features: &plan.features,
669            options: ReceivePackPushRequestOptions {
670                report_status: plan.features.report_status,
671                ofs_delta: plan.features.ofs_delta,
672                quiet: request.options.quiet && plan.features.quiet,
673                object_format: plan
674                    .features
675                    .object_format
676                    .filter(|_| request.format != ObjectFormat::Sha1),
677                ..ReceivePackPushRequestOptions::default()
678            },
679            thin: false,
680        },
681        &mut stdin,
682    )?;
683    drop(stdin);
684
685    let report = if plan.features.report_status {
686        let report = read_receive_pack_report_status(&mut plan.stdout)?;
687        crate::push::validate_receive_pack_report(&report)?;
688        Some(report)
689    } else {
690        let mut sink = Vec::new();
691        plan.stdout.read_to_end(&mut sink)?;
692        None
693    };
694    let output = plan.child.wait_with_output()?;
695    if !output.status.success() {
696        return Err(GitError::Command(format!(
697            "ssh receive-pack failed for {}: {}",
698            ssh_remote_display(&plan.remote),
699            String::from_utf8_lossy(&output.stderr).trim()
700        )));
701    }
702
703    Ok(PushOutcome { commands, report })
704}
705
706/// List the advertised refs for a resolved SSH `remote`, mirroring the records the
707/// HTTP/local paths return ([`crate::ls_remote::LsRemoteRecord`]): advertise over
708/// `ssh`, then apply the `--heads`/`--tags`/`--refs` class filters and the
709/// caller-supplied `matches` predicate. Returns the records and the object format
710/// in effect (currently SHA-1 only).
711pub(crate) fn ls_remote_ssh(
712    remote: &RemoteUrl,
713    filter: &crate::ls_remote::LsRemoteFilter,
714    matches: &dyn Fn(&str) -> bool,
715) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
716    if !matches!(
717        remote.transport,
718        RemoteTransport::Ssh | RemoteTransport::Ext
719    ) {
720        return Err(GitError::InvalidFormat(
721            "SSH upload-pack requires an SSH remote".into(),
722        ));
723    }
724    let (child, _stdin, mut stdout) = spawn_service_process(
725        remote,
726        GitService::UploadPack,
727        false,
728        SshTransportOptions::default(),
729    )?;
730    let set_result = read_ref_advertisement_set(ObjectFormat::Sha1, &mut stdout);
731    let output = child.wait_with_output()?;
732    let set = match set_result {
733        Ok(set) => set,
734        Err(_) if !output.status.success() => {
735            return Err(GitError::Command(format!(
736                "ssh upload-pack failed for {}: {}",
737                ssh_remote_display(remote),
738                String::from_utf8_lossy(&output.stderr).trim()
739            )));
740        }
741        Err(err) => return Err(err),
742    };
743    let features = set
744        .refs
745        .first()
746        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
747        .transpose()?
748        .unwrap_or_default();
749    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
750    if format != ObjectFormat::Sha1 {
751        return Err(GitError::Unsupported(format!(
752            "ssh ls-remote currently supports SHA-1 advertisements, got {}",
753            format.name()
754        )));
755    }
756    let symrefs = features
757        .symrefs
758        .iter()
759        .filter_map(|symref| symref.split_once(':'))
760        .map(|(name, target)| (name.to_string(), target.to_string()))
761        .collect::<HashMap<_, _>>();
762    let mut records = Vec::new();
763    for advertisement in set.refs {
764        if advertisement.oid.is_null() {
765            continue;
766        }
767        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
768        {
769            continue;
770        }
771        let is_head = advertisement.name.starts_with("refs/heads/");
772        let is_tag = advertisement.name.starts_with("refs/tags/");
773        if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
774        {
775            continue;
776        }
777        if !matches(&advertisement.name) {
778            continue;
779        }
780        records.push(crate::ls_remote::LsRemoteRecord {
781            oid: advertisement.oid,
782            symref: symrefs.get(&advertisement.name).cloned(),
783            name: advertisement.name,
784        });
785    }
786    Ok((records, format))
787}
788
789/// Fetch `wants` from an SSH upload-pack remote into the repository at `git_dir`,
790/// installing the resulting pack. Objects already present locally are skipped (for
791/// non-shallow fetches); `promisor` selects promisor-pack installation.
792///
793/// When `deepen` is set the fetch is shallow: the request replays `shallow` (the
794/// client's current boundary from `$GIT_DIR/shallow`) and asks the server to
795/// truncate history to `deepen` commits. The returned [`ProtocolV2FetchShallowInfo`]
796/// entries are the server's shallow-info updates the caller must fold into
797/// `$GIT_DIR/shallow` (see [`crate::apply_shallow_info`]); they are empty for a
798/// non-deepen fetch.
799pub struct SshFetchPackRequest<'a> {
800    /// Local repository `$GIT_DIR`.
801    pub git_dir: &'a Path,
802    /// Local repository object format.
803    pub format: ObjectFormat,
804    /// Resolved SSH remote.
805    pub remote: &'a RemoteUrl,
806    /// Upload-pack features advertised by the remote.
807    pub features: &'a UploadPackFeatures,
808    /// Wanted object ids.
809    pub wants: Vec<ObjectId>,
810    /// Existing shallow boundary to replay.
811    pub shallow: Vec<ObjectId>,
812    /// Requested deepen depth, if this is a shallow fetch.
813    pub deepen: Option<u32>,
814    /// Whether to install the response as a promisor pack.
815    pub promisor: bool,
816    /// SSH command-line shape (variant and `-4`/`-6`), usually derived by the
817    /// caller from effective config and command-line flags.
818    pub command_options: SshTransportOptions,
819}
820
821pub fn install_fetch_pack_via_ssh_upload_pack(
822    request: SshFetchPackRequest<'_>,
823) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
824    if request.wants.is_empty() {
825        return Ok(Vec::new());
826    }
827    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
828    // A deepen request must always reach the server (the shallow boundary may move
829    // even when every wanted object is already present), so only the plain fetch
830    // takes the "everything is local already" shortcut.
831    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
832        return Ok(Vec::new());
833    }
834    let upload_request = UploadPackRequest {
835        wants: request.wants,
836        capabilities: ssh_shallow_request_capabilities(request.deepen),
837        shallow: request.shallow,
838        deepen: request.deepen,
839        ..UploadPackRequest::default()
840    };
841    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
842    let (child, stdin, mut stdout) = spawn_service_process(
843        request.remote,
844        GitService::UploadPack,
845        true,
846        request.command_options,
847    )?;
848    let mut stdin =
849        stdin.ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
850
851    read_ref_advertisement_set(request.format, &mut stdout)?;
852    write_upload_pack_request(&mut stdin, Some(&upload_request))?;
853    write_upload_pack_negotiation_request(
854        &mut stdin,
855        &UploadPackNegotiationRequest { haves, done: true },
856    )?;
857    drop(stdin);
858
859    let shallow_info = if request.deepen.is_some() {
860        if request.promisor {
861            let (shallow_info, _) = install_upload_pack_shallow_raw_promisor_response_from_reader(
862                request.format,
863                &mut stdout,
864                &local_db,
865            )?;
866            shallow_info
867        } else {
868            let (shallow_info, _) = install_upload_pack_shallow_raw_response_from_reader(
869                request.format,
870                &mut stdout,
871                &local_db,
872            )?;
873            shallow_info
874        }
875    } else {
876        if request.promisor {
877            install_upload_pack_raw_promisor_response_from_reader(
878                request.format,
879                &mut stdout,
880                &local_db,
881            )?;
882        } else {
883            install_upload_pack_raw_response_from_reader(request.format, &mut stdout, &local_db)?;
884        }
885        Vec::new()
886    };
887    let output = child.wait_with_output()?;
888    if !output.status.success() {
889        return Err(GitError::Command(format!(
890            "ssh upload-pack failed for {}: {}",
891            ssh_remote_display(request.remote),
892            String::from_utf8_lossy(&output.stderr).trim()
893        )));
894    }
895    Ok(shallow_info)
896}
897
898fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
899    for want in wants {
900        if !db.contains(want)? {
901            return Ok(false);
902        }
903    }
904    Ok(true)
905}
906
907/// The want-line capabilities for an SSH fetch: the `shallow` capability when a
908/// deepen is requested, otherwise none (preserving the existing plain-fetch wire
909/// form exactly).
910fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
911    if deepen.is_some() {
912        vec![Capability {
913            name: "shallow".into(),
914            value: None,
915        }]
916    } else {
917        Vec::new()
918    }
919}
920
921/// The upload-pack ref advertisements and parsed features for SSH `remote`.
922pub fn ssh_upload_pack_advertisements(
923    remote: &RemoteUrl,
924    format: ObjectFormat,
925) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
926    ssh_upload_pack_advertisements_with_options(remote, format, SshTransportOptions::default())
927}
928
929pub fn ssh_upload_pack_advertisements_with_options(
930    remote: &RemoteUrl,
931    format: ObjectFormat,
932    options: SshTransportOptions,
933) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
934    if !matches!(
935        remote.transport,
936        RemoteTransport::Ssh | RemoteTransport::Ext
937    ) {
938        return Err(GitError::InvalidFormat(
939            "SSH upload-pack requires an SSH remote".into(),
940        ));
941    }
942    let (child, _stdin, mut stdout) =
943        spawn_service_process(remote, GitService::UploadPack, false, options)?;
944    let set_result = read_ref_advertisement_set(format, &mut stdout);
945    let output = child.wait_with_output()?;
946    let set = match set_result {
947        Ok(set) => set,
948        Err(_) if !output.status.success() => {
949            return Err(GitError::Command(format!(
950                "ssh upload-pack failed for {}: {}",
951                ssh_remote_display(remote),
952                String::from_utf8_lossy(&output.stderr).trim()
953            )));
954        }
955        Err(err) => return Err(err),
956    };
957    let features = set
958        .refs
959        .first()
960        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
961        .transpose()?
962        .unwrap_or_default();
963    Ok((set.refs, features))
964}
965
966/// A human-readable rendering of an SSH `remote` for error messages. The CLI built
967/// these messages from the resolved URL string; the library only has the parsed
968/// [`RemoteUrl`], so reconstruct the `user@host[:port]/path` (or `host:path` SCP)
969/// form for the diagnostic text.
970fn ssh_remote_display(remote: &RemoteUrl) -> String {
971    if remote.transport == RemoteTransport::Ext {
972        return format!("ext::{}", remote.path);
973    }
974    let host = remote.host.as_deref().unwrap_or("");
975    let mut out = String::new();
976    if let Some(user) = &remote.user {
977        out.push_str(user);
978        out.push('@');
979    }
980    out.push_str(host);
981    if let Some(port) = remote.port {
982        out.push(':');
983        out.push_str(&port.to_string());
984    }
985    if !remote.path.is_empty() {
986        if !out.is_empty() {
987            out.push(':');
988        }
989        out.push_str(&remote.path);
990    }
991    out
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    #[test]
999    fn remote_ext_parser_keeps_percent_escaped_spaces_inside_arguments() {
1000        let parsed = parse_remote_ext_command("sh -c %S% ..", GitService::UploadPack)
1001            .expect("remote-ext command parses");
1002
1003        assert_eq!(parsed.argv, vec!["sh", "-c", "git-upload-pack .."],);
1004        assert_eq!(parsed.git_request, None);
1005        assert_eq!(parsed.git_request_vhost, None);
1006    }
1007
1008    #[test]
1009    fn remote_ext_parser_expands_service_without_git_prefix() {
1010        let parsed = parse_remote_ext_command("helper %s %%", GitService::ReceivePack)
1011            .expect("remote-ext command parses");
1012
1013        assert_eq!(parsed.argv, vec!["helper", "receive-pack", "%"]);
1014    }
1015
1016    #[test]
1017    fn remote_ext_parser_extracts_git_daemon_request_arguments() {
1018        let parsed =
1019            parse_remote_ext_command("fake-daemon %G/two.git %Vhost", GitService::UploadPack)
1020                .expect("remote-ext command parses");
1021
1022        assert_eq!(parsed.argv, vec!["fake-daemon"]);
1023        assert_eq!(parsed.git_request.as_deref(), Some("/two.git"));
1024        assert_eq!(parsed.git_request_vhost.as_deref(), Some("host"));
1025    }
1026}