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,
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::{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) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
889 if request.wants.is_empty() {
890 return Ok(Vec::new());
891 }
892 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
893 if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
897 return Ok(Vec::new());
898 }
899 let upload_request = UploadPackRequest {
900 wants: request.wants,
901 capabilities: ssh_shallow_request_capabilities(request.deepen),
902 shallow: request.shallow,
903 deepen: request.deepen,
904 ..UploadPackRequest::default()
905 };
906 let haves = request
907 .haves
908 .clone()
909 .map(Ok)
910 .unwrap_or_else(|| crate::local::local_have_oids(request.git_dir, request.format))?;
911 let (mut child, stdin, mut stdout, stderr_drain) = spawn_service_process(
912 request.remote,
913 GitService::UploadPack,
914 true,
915 request.command_options,
916 )?;
917 let mut stdin =
918 stdin.ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
919
920 read_ref_advertisement_set(request.format, &mut stdout)?;
921 write_upload_pack_request(&mut stdin, Some(&upload_request))?;
922 write_upload_pack_negotiation_request(
923 &mut stdin,
924 &UploadPackNegotiationRequest { haves, done: true },
925 )?;
926 drop(stdin);
927
928 let shallow_info = if request.deepen.is_some() {
929 if request.promisor {
930 let (shallow_info, _) = install_upload_pack_shallow_raw_promisor_response_from_reader(
931 request.format,
932 &mut stdout,
933 &local_db,
934 request.max_input_size,
935 )?;
936 shallow_info
937 } else {
938 let (shallow_info, _) = install_upload_pack_shallow_raw_response_from_reader(
939 request.format,
940 &mut stdout,
941 &local_db,
942 request.max_input_size,
943 )?;
944 shallow_info
945 }
946 } else {
947 if request.promisor {
948 install_upload_pack_raw_promisor_response_from_reader(
949 request.format,
950 &mut stdout,
951 &local_db,
952 request.max_input_size,
953 )?;
954 } else {
955 install_upload_pack_raw_response_from_reader(request.format, &mut stdout, &local_db, request.max_input_size)?;
956 }
957 Vec::new()
958 };
959 let status = child.wait()?;
960 let stderr = stderr_drain.finish();
961 if !status.success() {
962 return Err(GitError::Command(format!(
963 "ssh upload-pack failed for {}: {}",
964 ssh_remote_display(request.remote),
965 ssh_command_failure_message("ssh upload-pack", &stderr, status)
966 )));
967 }
968 Ok(shallow_info)
969}
970
971fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
972 for want in wants {
973 if !db.contains(want)? {
974 return Ok(false);
975 }
976 }
977 Ok(true)
978}
979
980fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
984 if deepen.is_some() {
985 vec![Capability {
986 name: "shallow".into(),
987 value: None,
988 }]
989 } else {
990 Vec::new()
991 }
992}
993
994pub fn ssh_upload_pack_advertisements(
996 remote: &RemoteUrl,
997 format: ObjectFormat,
998) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
999 ssh_upload_pack_advertisements_with_options(remote, format, SshTransportOptions::default())
1000}
1001
1002pub fn ssh_upload_pack_advertisements_with_options(
1003 remote: &RemoteUrl,
1004 format: ObjectFormat,
1005 options: SshTransportOptions,
1006) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
1007 if !matches!(
1008 remote.transport,
1009 RemoteTransport::Ssh | RemoteTransport::Ext
1010 ) {
1011 return Err(GitError::InvalidFormat(
1012 "SSH upload-pack requires an SSH remote".into(),
1013 ));
1014 }
1015 let (mut child, _stdin, mut stdout, stderr_drain) =
1016 spawn_service_process(remote, GitService::UploadPack, false, options)?;
1017 let set_result = read_ref_advertisement_set(format, &mut stdout);
1018 let status = child.wait()?;
1019 let stderr = stderr_drain.finish();
1020 let set = match set_result {
1021 Ok(set) => set,
1022 Err(err) if is_ssh_protocol_v2_advertisement_error(&err) => {
1023 return Err(ssh_protocol_v2_unsupported_error());
1024 }
1025 Err(_) if !status.success() => {
1026 return Err(GitError::Command(format!(
1027 "ssh upload-pack failed for {}: {}",
1028 ssh_remote_display(remote),
1029 ssh_command_failure_message("ssh upload-pack", &stderr, status)
1030 )));
1031 }
1032 Err(err) => return Err(err),
1033 };
1034 let features = set
1035 .refs
1036 .first()
1037 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
1038 .transpose()?
1039 .unwrap_or_default();
1040 Ok((set.refs, features))
1041}
1042
1043
1044pub fn validate_ssh_fetch_options(
1045 filter: Option<&sley_odb::PackObjectFilter>,
1046 deepen_since: Option<i64>,
1047 deepen_not: &[String],
1048) -> Result<()> {
1049 if filter.is_some() {
1050 return Err(GitError::Unsupported(
1051 "partial clone --filter over SSH is not supported; use HTTPS".into(),
1052 ));
1053 }
1054 if deepen_since.is_some() {
1055 return Err(GitError::Unsupported(
1056 "shallow fetch --shallow-since over SSH is not supported; use HTTPS".into(),
1057 ));
1058 }
1059 if !deepen_not.is_empty() {
1060 return Err(GitError::Unsupported(
1061 "shallow fetch --shallow-exclude over SSH is not supported; use HTTPS".into(),
1062 ));
1063 }
1064 Ok(())
1065}
1066
1067fn is_ssh_protocol_v2_advertisement_error(err: &GitError) -> bool {
1068 matches!(err, GitError::InvalidFormat(message)
1069 if message.contains("unsupported advertised ref protocol version"))
1070}
1071
1072fn ssh_protocol_v2_unsupported_error() -> GitError {
1073 GitError::Unsupported(
1074 "protocol v2 over SSH is not supported; use HTTPS or configure the remote for upload-pack v0/v1"
1075 .into(),
1076 )
1077}
1078
1079fn ssh_remote_display(remote: &RemoteUrl) -> String {
1084 if remote.transport == RemoteTransport::Ext {
1085 return format!("ext::{}", remote.path);
1086 }
1087 let host = remote.host.as_deref().unwrap_or("");
1088 let mut out = String::new();
1089 if let Some(user) = &remote.user {
1090 out.push_str(user);
1091 out.push('@');
1092 }
1093 out.push_str(host);
1094 if let Some(port) = remote.port {
1095 out.push(':');
1096 out.push_str(&port.to_string());
1097 }
1098 if !remote.path.is_empty() {
1099 if !out.is_empty() {
1100 out.push(':');
1101 }
1102 out.push_str(&remote.path);
1103 }
1104 out
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110
1111 #[test]
1112 fn remote_ext_parser_keeps_percent_escaped_spaces_inside_arguments() {
1113 let parsed = parse_remote_ext_command("sh -c %S% ..", GitService::UploadPack)
1114 .expect("remote-ext command parses");
1115
1116 assert_eq!(parsed.argv, vec!["sh", "-c", "git-upload-pack .."],);
1117 assert_eq!(parsed.git_request, None);
1118 assert_eq!(parsed.git_request_vhost, None);
1119 }
1120
1121 #[test]
1122 fn remote_ext_parser_expands_service_without_git_prefix() {
1123 let parsed = parse_remote_ext_command("helper %s %%", GitService::ReceivePack)
1124 .expect("remote-ext command parses");
1125
1126 assert_eq!(parsed.argv, vec!["helper", "receive-pack", "%"]);
1127 }
1128
1129 #[test]
1130 fn remote_ext_parser_extracts_git_daemon_request_arguments() {
1131 let parsed =
1132 parse_remote_ext_command("fake-daemon %G/two.git %Vhost", GitService::UploadPack)
1133 .expect("remote-ext command parses");
1134
1135 assert_eq!(parsed.argv, vec!["fake-daemon"]);
1136 assert_eq!(parsed.git_request.as_deref(), Some("/two.git"));
1137 assert_eq!(parsed.git_request_vhost.as_deref(), Some("host"));
1138 }
1139}