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