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