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 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
80pub 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
483pub(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()?; 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
761pub(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
848pub struct SshFetchPackRequest<'a> {
859 pub git_dir: &'a Path,
861 pub format: ObjectFormat,
863 pub remote: &'a RemoteUrl,
865 pub features: &'a UploadPackFeatures,
867 pub wants: Vec<ObjectId>,
869 pub haves: Option<Vec<ObjectId>>,
872 pub shallow: Vec<ObjectId>,
874 pub deepen: Option<u32>,
876 pub promisor: bool,
878 pub command_options: SshTransportOptions,
881 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 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
986fn 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
1000pub 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
1085fn 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}