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