1use 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 pub protocol: Option<ProtocolVersion>,
60}
61
62#[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
92pub 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
513pub(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()?; 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
800pub(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
888pub struct SshFetchPackRequest<'a> {
899 pub git_dir: &'a Path,
901 pub format: ObjectFormat,
903 pub remote: &'a RemoteUrl,
905 pub features: &'a UploadPackFeatures,
907 pub wants: Vec<ObjectId>,
909 pub haves: Option<Vec<ObjectId>>,
912 pub shallow: Vec<ObjectId>,
914 pub deepen: Option<u32>,
916 pub promisor: bool,
918 pub command_options: SshTransportOptions,
921 pub upload_pack_command: Option<&'a str>,
923 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 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
1029fn 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
1043pub 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
1059pub 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
1108pub 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
1194fn 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}