1use std::collections::HashMap;
24use std::env;
25use std::io::{Read, Write};
26use std::path::Path;
27use std::process::{Child, ChildStdin, ChildStdout, Command as ProcessCommand, Stdio};
28
29use sley_config::GitConfig;
30use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
31use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
32use sley_odb::FileObjectDatabase;
33use sley_protocol::write_pkt_line_payload;
34use sley_protocol::{
35 GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
36 ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
37 UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
38 build_receive_pack_push_request, parse_receive_pack_features, parse_upload_pack_features,
39 read_receive_pack_report_status, read_ref_advertisement_set,
40 read_upload_pack_raw_packfile_response,
41 read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
42 write_upload_pack_negotiation_request, write_upload_pack_request,
43};
44use sley_refs::FileRefStore;
45use sley_transport::{
46 RemoteTransport, RemoteUrl, SshCommandVariant, SshIpVersion, ssh_process_args_with_ip,
47};
48
49use crate::{PushOutcome, PushRequest};
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct SshTransportOptions {
53 pub variant: Option<SshCommandVariant>,
54 pub ip_version: Option<SshIpVersion>,
55}
56
57pub fn ssh_transport_options_from_config(config: &GitConfig) -> SshTransportOptions {
58 SshTransportOptions {
59 variant: config
60 .get("ssh", None, "variant")
61 .and_then(ssh_variant_from_config_value),
62 ip_version: None,
63 }
64}
65
66fn ssh_variant_from_config_value(value: &str) -> Option<SshCommandVariant> {
67 match value.to_ascii_lowercase().as_str() {
68 "ssh" => Some(SshCommandVariant::OpenSsh),
69 "plink" | "putty" => Some(SshCommandVariant::Plink),
70 "tortoiseplink" => Some(SshCommandVariant::TortoisePlink),
71 "simple" => Some(SshCommandVariant::Simple),
72 "auto" => None,
73 _ => None,
74 }
75}
76
77pub fn ssh_program() -> String {
80 match ssh_program_and_prefix_args() {
81 Ok((program, _)) => program,
82 Err(_) => env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into()),
83 }
84}
85
86fn ssh_process_command_for_remote(
87 remote: &RemoteUrl,
88 service: GitService,
89 options: SshTransportOptions,
90) -> Result<ServiceProcessCommand> {
91 if remote.transport == RemoteTransport::Ext {
92 return ext_process_command_for_remote(remote, service);
93 }
94 let (program, mut args) = ssh_program_and_prefix_args()?;
95 let variant = ssh_command_variant(&program, options.variant);
96 args.extend(ssh_process_args_with_ip(
97 remote,
98 service,
99 variant,
100 options.ip_version,
101 )?);
102 Ok(ServiceProcessCommand {
103 program,
104 args,
105 env: Vec::new(),
106 git_request: None,
107 })
108}
109
110struct ServiceProcessCommand {
111 program: String,
112 args: Vec<String>,
113 env: Vec<(String, String)>,
114 git_request: Option<ExtGitRequest>,
115}
116
117struct ExtGitRequest {
118 repo: String,
119 vhost: Option<String>,
120}
121
122fn ext_process_command_for_remote(
123 remote: &RemoteUrl,
124 service: GitService,
125) -> Result<ServiceProcessCommand> {
126 let parsed = parse_remote_ext_command(&remote.path, service)?;
127 let Some((program, args)) = parsed.argv.split_first() else {
128 return Err(GitError::InvalidFormat(
129 "ext remote command is empty".into(),
130 ));
131 };
132 Ok(ServiceProcessCommand {
133 program: program.clone(),
134 args: args.to_vec(),
135 env: vec![
136 ("GIT_EXT_SERVICE".into(), service.as_str().into()),
137 (
138 "GIT_EXT_SERVICE_NOPREFIX".into(),
139 service
140 .as_str()
141 .strip_prefix("git-")
142 .unwrap_or(service.as_str())
143 .into(),
144 ),
145 ],
146 git_request: parsed.git_request.map(|repo| ExtGitRequest {
147 repo,
148 vhost: parsed.git_request_vhost,
149 }),
150 })
151}
152
153struct ParsedRemoteExtCommand {
154 argv: Vec<String>,
155 git_request: Option<String>,
156 git_request_vhost: Option<String>,
157}
158
159fn parse_remote_ext_command(command: &str, service: GitService) -> Result<ParsedRemoteExtCommand> {
160 let service_name = service.as_str();
161 let service_noprefix = service_name.strip_prefix("git-").unwrap_or(service_name);
162 let mut rest = command;
163 let mut argv = Vec::new();
164 let mut git_request = None;
165 let mut git_request_vhost = None;
166
167 while !rest.is_empty() {
168 let (arg, next) = parse_remote_ext_arg(rest, service_name, service_noprefix)?;
169 rest = next;
170 match arg {
171 RemoteExtArg::Arg(value) => argv.push(value),
172 RemoteExtArg::GitRequest(value) => git_request = Some(value),
173 RemoteExtArg::GitRequestVhost(value) => git_request_vhost = Some(value),
174 }
175 }
176
177 Ok(ParsedRemoteExtCommand {
178 argv,
179 git_request,
180 git_request_vhost,
181 })
182}
183
184enum RemoteExtArg {
185 Arg(String),
186 GitRequest(String),
187 GitRequestVhost(String),
188}
189
190fn parse_remote_ext_arg<'a>(
191 input: &'a str,
192 service: &str,
193 service_noprefix: &str,
194) -> Result<(RemoteExtArg, &'a str)> {
195 let bytes = input.as_bytes();
196 let mut end = 0;
197 let mut escaped = false;
198 let mut special = None::<u8>;
199 while end < bytes.len() && (escaped || bytes[end] != b' ') {
200 if escaped {
201 match bytes[end] {
202 b' ' | b'%' | b's' | b'S' => {}
203 b'G' | b'V' if end == 1 => special = Some(bytes[end]),
204 other => {
205 return Err(GitError::InvalidFormat(format!(
206 "Bad remote-ext placeholder '%{}'",
207 other as char
208 )));
209 }
210 }
211 escaped = false;
212 } else {
213 escaped = bytes[end] == b'%';
214 }
215 end += 1;
216 }
217 if escaped && end == bytes.len() {
218 return Err(GitError::InvalidFormat(
219 "remote-ext command has incomplete placeholder".into(),
220 ));
221 }
222 let mut next = &input[end..];
223 if next.starts_with(' ') {
224 next = &next[1..];
225 }
226
227 let body = if special.is_some() {
228 &input[2..end]
229 } else {
230 &input[..end]
231 };
232 let expanded = expand_remote_ext_arg(body, service, service_noprefix)?;
233 let arg = match special {
234 Some(b'G') => RemoteExtArg::GitRequest(expanded),
235 Some(b'V') => RemoteExtArg::GitRequestVhost(expanded),
236 Some(_) => unreachable!("validated remote-ext special"),
237 None => RemoteExtArg::Arg(expanded),
238 };
239 Ok((arg, next))
240}
241
242fn expand_remote_ext_arg(input: &str, service: &str, service_noprefix: &str) -> Result<String> {
243 let mut out = String::new();
244 let bytes = input.as_bytes();
245 let mut pos = 0;
246 while pos < bytes.len() {
247 if bytes[pos] != b'%' {
248 out.push(bytes[pos] as char);
249 pos += 1;
250 continue;
251 }
252 pos += 1;
253 if pos == bytes.len() {
254 return Err(GitError::InvalidFormat(
255 "remote-ext command has incomplete placeholder".into(),
256 ));
257 }
258 match bytes[pos] {
259 b' ' => out.push(' '),
260 b'%' => out.push('%'),
261 b's' => out.push_str(service_noprefix),
262 b'S' => out.push_str(service),
263 other => {
264 return Err(GitError::InvalidFormat(format!(
265 "Bad remote-ext placeholder '%{}'",
266 other as char
267 )));
268 }
269 }
270 pos += 1;
271 }
272 Ok(out)
273}
274
275fn ssh_command_variant(
276 program: &str,
277 config_variant: Option<SshCommandVariant>,
278) -> SshCommandVariant {
279 if let Ok(variant) = env::var("GIT_SSH_VARIANT") {
280 return match variant.as_str() {
281 "auto" => detect_ssh_command_variant(program),
282 "ssh" => SshCommandVariant::OpenSsh,
283 "simple" => SshCommandVariant::Simple,
284 "plink" | "putty" => SshCommandVariant::Plink,
285 "tortoiseplink" => SshCommandVariant::TortoisePlink,
286 _ => detect_ssh_command_variant(program),
287 };
288 }
289 config_variant.unwrap_or_else(|| detect_ssh_command_variant(program))
290}
291
292fn detect_ssh_command_variant(program: &str) -> SshCommandVariant {
293 let basename = Path::new(&program)
294 .file_name()
295 .and_then(|value| value.to_str())
296 .unwrap_or(program)
297 .to_ascii_lowercase();
298 match basename.as_str() {
299 "plink" | "plink.exe" => SshCommandVariant::Plink,
300 "tortoiseplink" | "tortoiseplink.exe" => SshCommandVariant::TortoisePlink,
301 "simple" => SshCommandVariant::Simple,
302 "uplink" => {
303 if ssh_supports_openssh_config_probe(program) {
304 SshCommandVariant::OpenSsh
305 } else {
306 SshCommandVariant::Simple
307 }
308 }
309 _ => SshCommandVariant::OpenSsh,
310 }
311}
312
313fn ssh_supports_openssh_config_probe(program: &str) -> bool {
314 ProcessCommand::new(program)
315 .arg("-G")
316 .arg("example.com")
317 .stdin(Stdio::null())
318 .stdout(Stdio::null())
319 .stderr(Stdio::null())
320 .status()
321 .map(|status| status.success())
322 .unwrap_or(false)
323}
324
325fn ssh_program_and_prefix_args() -> Result<(String, Vec<String>)> {
326 if let Ok(command) = env::var("GIT_SSH_COMMAND") {
327 let words = split_shell_words(&command)?;
328 let Some((program, args)) = words.split_first() else {
329 return Err(GitError::Command("GIT_SSH_COMMAND is empty".into()));
330 };
331 return Ok((program.clone(), args.to_vec()));
332 }
333 Ok((
334 env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into()),
335 Vec::new(),
336 ))
337}
338
339fn split_shell_words(command: &str) -> Result<Vec<String>> {
340 let mut words = Vec::new();
341 let mut current = String::new();
342 let mut chars = command.chars().peekable();
343 let mut quote = None::<char>;
344 while let Some(ch) = chars.next() {
345 if let Some(quote_ch) = quote {
346 if ch == quote_ch {
347 quote = None;
348 } else if ch == '\\' && quote_ch == '"' {
349 if let Some(next) = chars.next() {
350 current.push(next);
351 }
352 } else {
353 current.push(ch);
354 }
355 continue;
356 }
357 match ch {
358 '\'' | '"' => quote = Some(ch),
359 '\\' => {
360 if let Some(next) = chars.next() {
361 current.push(next);
362 }
363 }
364 ch if ch.is_whitespace() => {
365 if !current.is_empty() {
366 words.push(std::mem::take(&mut current));
367 }
368 }
369 _ => current.push(ch),
370 }
371 }
372 if quote.is_some() {
373 return Err(GitError::Command(
374 "unclosed quote in GIT_SSH_COMMAND".into(),
375 ));
376 }
377 if !current.is_empty() {
378 words.push(current);
379 }
380 Ok(words)
381}
382
383fn spawn_service_process(
384 remote: &RemoteUrl,
385 service: GitService,
386 keep_stdin: bool,
387 options: SshTransportOptions,
388) -> Result<(Child, Option<ChildStdin>, ChildStdout)> {
389 let command = ssh_process_command_for_remote(remote, service, options)?;
390 let mut process = ProcessCommand::new(&command.program);
391 process
392 .args(&command.args)
393 .envs(command.env)
394 .env_remove("GIT_EXEC_PATH")
395 .stdin(if keep_stdin || command.git_request.is_some() {
396 Stdio::piped()
397 } else {
398 Stdio::null()
399 })
400 .stdout(Stdio::piped())
401 .stderr(Stdio::piped());
402 let mut child = process.spawn()?;
403 let mut stdin = child.stdin.take();
404 if let Some(request) = &command.git_request {
405 let Some(input) = stdin.as_mut() else {
406 return Err(GitError::Command("remote-ext stdin was not piped".into()));
407 };
408 write_remote_ext_git_request(input, service, request)?;
409 }
410 if !keep_stdin {
411 drop(stdin.take());
412 }
413 let stdout = child
414 .stdout
415 .take()
416 .ok_or_else(|| GitError::Command("service stdout was not piped".into()))?;
417 Ok((child, stdin, stdout))
418}
419
420fn write_remote_ext_git_request(
421 writer: &mut impl Write,
422 service: GitService,
423 request: &ExtGitRequest,
424) -> Result<()> {
425 let mut payload = Vec::new();
426 payload.extend_from_slice(service.as_str().as_bytes());
427 payload.push(b' ');
428 payload.extend_from_slice(request.repo.as_bytes());
429 payload.push(0);
430 if let Some(vhost) = &request.vhost {
431 payload.extend_from_slice(b"host=");
432 payload.extend_from_slice(vhost.as_bytes());
433 payload.push(0);
434 }
435 write_pkt_line_payload(writer, &payload)
436}
437
438pub(crate) struct SshPushRequest<'a> {
449 pub git_dir: &'a Path,
450 pub common_git_dir: &'a Path,
451 pub format: ObjectFormat,
452 pub remote: &'a RemoteUrl,
453 pub refspecs: &'a [String],
454 pub force: bool,
455}
456
457pub(crate) struct SshPushCommandsRequest<'a> {
458 pub common_git_dir: &'a Path,
459 pub format: ObjectFormat,
460 pub remote: &'a RemoteUrl,
461 pub command_forces: Vec<(ReceivePackCommand, bool)>,
462 pub pack_objects: Vec<ObjectId>,
463}
464
465pub(crate) struct SshPushPlan {
466 pub(crate) commands: Vec<ReceivePackCommand>,
467 pub(crate) pack_objects: Vec<ObjectId>,
468 child: Child,
469 stdin: Option<ChildStdin>,
470 stdout: ChildStdout,
471 features: ReceivePackFeatures,
472 advertisements: Vec<RefAdvertisement>,
473 remote: RemoteUrl,
474}
475
476pub(crate) fn plan_push_ssh(request: SshPushRequest<'_>) -> Result<SshPushPlan> {
477 let SshPushRequest {
478 git_dir,
479 common_git_dir,
480 format,
481 remote,
482 refspecs,
483 force,
484 } = request;
485 if !matches!(
486 remote.transport,
487 RemoteTransport::Ssh | RemoteTransport::Ext
488 ) {
489 return Err(GitError::InvalidFormat(
490 "SSH receive-pack requires an SSH remote".into(),
491 ));
492 }
493 let (child, stdin, mut stdout) = spawn_service_process(
494 remote,
495 GitService::ReceivePack,
496 true,
497 SshTransportOptions::default(),
498 )?;
499 let stdin =
500 stdin.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
501
502 let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
503 let features = advertisement_set
504 .refs
505 .first()
506 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
507 .transpose()?
508 .unwrap_or_default();
509 if let Some(remote_format) = features.object_format {
510 if remote_format != format {
511 return Err(GitError::InvalidObjectId(format!(
512 "remote repository uses {}, local repository uses {}",
513 remote_format.name(),
514 format.name()
515 )));
516 }
517 } else if format != ObjectFormat::Sha1 {
518 return Err(GitError::InvalidObjectId(format!(
519 "remote repository did not advertise object-format for {} push",
520 format.name()
521 )));
522 }
523
524 let local_store = FileRefStore::new(git_dir, format);
525 let mut local_refs = crate::push::local_push_source_refs(&local_store, format)?;
526 crate::push::add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
527 let command_forces = crate::push::plan_push_command_forces(
528 format,
529 &local_refs,
530 &advertisement_set.refs,
531 refspecs,
532 force,
533 )?;
534 let commands = command_forces
535 .iter()
536 .map(|(command, _)| command.clone())
537 .collect::<Vec<_>>();
538 if commands.is_empty() {
539 drop(stdin);
540 return Ok(SshPushPlan {
541 commands,
542 pack_objects: Vec::new(),
543 child,
544 stdin: None,
545 stdout,
546 features,
547 advertisements: advertisement_set.refs,
548 remote: remote.clone(),
549 });
550 }
551
552 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
553 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
554 Ok(SshPushPlan {
555 commands,
556 pack_objects: Vec::new(),
557 child,
558 stdin: Some(stdin),
559 stdout,
560 features,
561 advertisements: advertisement_set.refs,
562 remote: remote.clone(),
563 })
564}
565
566pub(crate) fn plan_push_ssh_commands(request: SshPushCommandsRequest<'_>) -> Result<SshPushPlan> {
567 let SshPushCommandsRequest {
568 common_git_dir,
569 format,
570 remote,
571 command_forces,
572 pack_objects,
573 } = request;
574 if !matches!(
575 remote.transport,
576 RemoteTransport::Ssh | RemoteTransport::Ext
577 ) {
578 return Err(GitError::InvalidFormat(
579 "SSH receive-pack requires an SSH remote".into(),
580 ));
581 }
582 let (child, stdin, mut stdout) = spawn_service_process(
583 remote,
584 GitService::ReceivePack,
585 true,
586 SshTransportOptions::default(),
587 )?;
588 let stdin =
589 stdin.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
590
591 let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
592 let features = advertisement_set
593 .refs
594 .first()
595 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
596 .transpose()?
597 .unwrap_or_default();
598 if let Some(remote_format) = features.object_format {
599 if remote_format != format {
600 return Err(GitError::InvalidObjectId(format!(
601 "remote repository uses {}, local repository uses {}",
602 remote_format.name(),
603 format.name()
604 )));
605 }
606 } else if format != ObjectFormat::Sha1 {
607 return Err(GitError::InvalidObjectId(format!(
608 "remote repository did not advertise object-format for {} push",
609 format.name()
610 )));
611 }
612
613 let commands = command_forces
614 .iter()
615 .map(|(command, _)| command.clone())
616 .collect::<Vec<_>>();
617
618 if commands.is_empty() {
619 drop(stdin);
620 return Ok(SshPushPlan {
621 commands,
622 pack_objects,
623 child,
624 stdin: None,
625 stdout,
626 features,
627 advertisements: advertisement_set.refs,
628 remote: remote.clone(),
629 });
630 }
631
632 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
633 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
634 Ok(SshPushPlan {
635 commands,
636 pack_objects,
637 child,
638 stdin: Some(stdin),
639 stdout,
640 features,
641 advertisements: advertisement_set.refs,
642 remote: remote.clone(),
643 })
644}
645
646pub(crate) fn execute_push_ssh_plan(
647 request: PushRequest<'_>,
648 mut plan: SshPushPlan,
649) -> Result<PushOutcome> {
650 if plan.commands.is_empty() {
651 return Ok(PushOutcome::default());
652 }
653 let mut stdin = plan
654 .stdin
655 .take()
656 .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not available".into()))?;
657 let commands = plan.commands.clone();
658 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
659 let packfile = crate::pack::build_push_packfile(&crate::pack::PushPackRequest {
660 local_db: &local_db,
661 format: request.format,
662 commands: &commands,
663 pack_objects: &plan.pack_objects,
664 remote_advertisements: &plan.advertisements,
665 features: &plan.features,
666 options: ReceivePackPushRequestOptions {
667 ofs_delta: plan.features.ofs_delta,
668 ..ReceivePackPushRequestOptions::default()
669 },
670 thin: false,
671 })?;
672 let request = build_receive_pack_push_request(
673 &plan.features,
674 commands.clone(),
675 packfile,
676 ReceivePackPushRequestOptions {
677 report_status: plan.features.report_status,
678 ofs_delta: plan.features.ofs_delta,
679 quiet: request.options.quiet && plan.features.quiet,
680 object_format: plan
681 .features
682 .object_format
683 .filter(|_| request.format != ObjectFormat::Sha1),
684 ..ReceivePackPushRequestOptions::default()
685 },
686 )?;
687 write_receive_pack_push_request(&mut stdin, &request)?;
688 drop(stdin);
689
690 let report = if plan.features.report_status {
691 let report = read_receive_pack_report_status(&mut plan.stdout)?;
692 crate::push::validate_receive_pack_report(&report)?;
693 Some(report)
694 } else {
695 let mut sink = Vec::new();
696 plan.stdout.read_to_end(&mut sink)?;
697 None
698 };
699 let output = plan.child.wait_with_output()?;
700 if !output.status.success() {
701 return Err(GitError::Command(format!(
702 "ssh receive-pack failed for {}: {}",
703 ssh_remote_display(&plan.remote),
704 String::from_utf8_lossy(&output.stderr).trim()
705 )));
706 }
707
708 Ok(PushOutcome { commands, report })
709}
710
711pub(crate) fn ls_remote_ssh(
717 remote: &RemoteUrl,
718 filter: &crate::ls_remote::LsRemoteFilter,
719 matches: &dyn Fn(&str) -> bool,
720) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
721 if !matches!(
722 remote.transport,
723 RemoteTransport::Ssh | RemoteTransport::Ext
724 ) {
725 return Err(GitError::InvalidFormat(
726 "SSH upload-pack requires an SSH remote".into(),
727 ));
728 }
729 let (child, _stdin, mut stdout) = spawn_service_process(
730 remote,
731 GitService::UploadPack,
732 false,
733 SshTransportOptions::default(),
734 )?;
735 let set_result = read_ref_advertisement_set(ObjectFormat::Sha1, &mut stdout);
736 let output = child.wait_with_output()?;
737 let set = match set_result {
738 Ok(set) => set,
739 Err(_) if !output.status.success() => {
740 return Err(GitError::Command(format!(
741 "ssh upload-pack failed for {}: {}",
742 ssh_remote_display(remote),
743 String::from_utf8_lossy(&output.stderr).trim()
744 )));
745 }
746 Err(err) => return Err(err),
747 };
748 let features = set
749 .refs
750 .first()
751 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
752 .transpose()?
753 .unwrap_or_default();
754 let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
755 if format != ObjectFormat::Sha1 {
756 return Err(GitError::Unsupported(format!(
757 "ssh ls-remote currently supports SHA-1 advertisements, got {}",
758 format.name()
759 )));
760 }
761 let symrefs = features
762 .symrefs
763 .iter()
764 .filter_map(|symref| symref.split_once(':'))
765 .map(|(name, target)| (name.to_string(), target.to_string()))
766 .collect::<HashMap<_, _>>();
767 let mut records = Vec::new();
768 for advertisement in set.refs {
769 if advertisement.oid.is_null() {
770 continue;
771 }
772 if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
773 {
774 continue;
775 }
776 let is_head = advertisement.name.starts_with("refs/heads/");
777 let is_tag = advertisement.name.starts_with("refs/tags/");
778 if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
779 {
780 continue;
781 }
782 if !matches(&advertisement.name) {
783 continue;
784 }
785 records.push(crate::ls_remote::LsRemoteRecord {
786 oid: advertisement.oid,
787 symref: symrefs.get(&advertisement.name).cloned(),
788 name: advertisement.name,
789 });
790 }
791 Ok((records, format))
792}
793
794pub struct SshFetchPackRequest<'a> {
805 pub git_dir: &'a Path,
807 pub format: ObjectFormat,
809 pub remote: &'a RemoteUrl,
811 pub features: &'a UploadPackFeatures,
813 pub wants: Vec<ObjectId>,
815 pub shallow: Vec<ObjectId>,
817 pub deepen: Option<u32>,
819 pub promisor: bool,
821 pub command_options: SshTransportOptions,
824}
825
826pub fn install_fetch_pack_via_ssh_upload_pack(
827 request: SshFetchPackRequest<'_>,
828) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
829 if request.wants.is_empty() {
830 return Ok(Vec::new());
831 }
832 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
833 if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
837 return Ok(Vec::new());
838 }
839 let upload_request = UploadPackRequest {
840 wants: request.wants,
841 capabilities: ssh_shallow_request_capabilities(request.deepen),
842 shallow: request.shallow,
843 deepen: request.deepen,
844 ..UploadPackRequest::default()
845 };
846 let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
847 let (shallow_info, response) = if request.deepen.is_some() {
851 ssh_upload_pack_shallow_fetch_response(
852 request.remote,
853 request.format,
854 request.features,
855 upload_request,
856 haves,
857 request.command_options,
858 )?
859 } else {
860 let response = ssh_upload_pack_fetch_response(
861 request.remote,
862 request.format,
863 request.features,
864 upload_request,
865 haves,
866 request.command_options,
867 )?;
868 (Vec::new(), response)
869 };
870 if request.promisor {
871 install_upload_pack_raw_promisor_response(&response, &local_db)?;
872 } else {
873 install_upload_pack_raw_response(&response, &local_db)?;
874 }
875 Ok(shallow_info)
876}
877
878fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
879 for want in wants {
880 if !db.contains(want)? {
881 return Ok(false);
882 }
883 }
884 Ok(true)
885}
886
887fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
891 if deepen.is_some() {
892 vec![Capability {
893 name: "shallow".into(),
894 value: None,
895 }]
896 } else {
897 Vec::new()
898 }
899}
900
901pub fn ssh_upload_pack_advertisements(
903 remote: &RemoteUrl,
904 format: ObjectFormat,
905) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
906 ssh_upload_pack_advertisements_with_options(remote, format, SshTransportOptions::default())
907}
908
909pub fn ssh_upload_pack_advertisements_with_options(
910 remote: &RemoteUrl,
911 format: ObjectFormat,
912 options: SshTransportOptions,
913) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
914 if !matches!(
915 remote.transport,
916 RemoteTransport::Ssh | RemoteTransport::Ext
917 ) {
918 return Err(GitError::InvalidFormat(
919 "SSH upload-pack requires an SSH remote".into(),
920 ));
921 }
922 let (child, _stdin, mut stdout) =
923 spawn_service_process(remote, GitService::UploadPack, false, options)?;
924 let set_result = read_ref_advertisement_set(format, &mut stdout);
925 let output = child.wait_with_output()?;
926 let set = match set_result {
927 Ok(set) => set,
928 Err(_) if !output.status.success() => {
929 return Err(GitError::Command(format!(
930 "ssh upload-pack failed for {}: {}",
931 ssh_remote_display(remote),
932 String::from_utf8_lossy(&output.stderr).trim()
933 )));
934 }
935 Err(err) => return Err(err),
936 };
937 let features = set
938 .refs
939 .first()
940 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
941 .transpose()?
942 .unwrap_or_default();
943 Ok((set.refs, features))
944}
945
946pub fn ssh_upload_pack_fetch_response(
951 remote: &RemoteUrl,
952 format: ObjectFormat,
953 _features: &UploadPackFeatures,
954 request: UploadPackRequest,
955 haves: Vec<ObjectId>,
956 options: SshTransportOptions,
957) -> Result<UploadPackRawPackfileResponse> {
958 let (_shallow, response) =
959 ssh_upload_pack_fetch_response_inner(remote, format, request, haves, false, options)?;
960 Ok(response)
961}
962
963pub fn ssh_upload_pack_shallow_fetch_response(
969 remote: &RemoteUrl,
970 format: ObjectFormat,
971 _features: &UploadPackFeatures,
972 request: UploadPackRequest,
973 haves: Vec<ObjectId>,
974 options: SshTransportOptions,
975) -> Result<(
976 Vec<ProtocolV2FetchShallowInfo>,
977 UploadPackRawPackfileResponse,
978)> {
979 ssh_upload_pack_fetch_response_inner(remote, format, request, haves, true, options)
980}
981
982fn ssh_upload_pack_fetch_response_inner(
987 remote: &RemoteUrl,
988 format: ObjectFormat,
989 request: UploadPackRequest,
990 haves: Vec<ObjectId>,
991 expect_shallow_info: bool,
992 options: SshTransportOptions,
993) -> Result<(
994 Vec<ProtocolV2FetchShallowInfo>,
995 UploadPackRawPackfileResponse,
996)> {
997 if !matches!(
998 remote.transport,
999 RemoteTransport::Ssh | RemoteTransport::Ext
1000 ) {
1001 return Err(GitError::InvalidFormat(
1002 "SSH upload-pack requires an SSH remote".into(),
1003 ));
1004 }
1005 let (child, stdin, mut stdout) =
1006 spawn_service_process(remote, GitService::UploadPack, true, options)?;
1007 let mut stdin =
1008 stdin.ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
1009
1010 read_ref_advertisement_set(format, &mut stdout)?;
1011 write_upload_pack_request(&mut stdin, Some(&request))?;
1012 write_upload_pack_negotiation_request(
1013 &mut stdin,
1014 &UploadPackNegotiationRequest { haves, done: true },
1015 )?;
1016 drop(stdin);
1017
1018 let result = if expect_shallow_info {
1019 read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stdout)?
1020 } else {
1021 (
1022 Vec::new(),
1023 read_upload_pack_raw_packfile_response(format, &mut stdout)?,
1024 )
1025 };
1026 let output = child.wait_with_output()?;
1027 if !output.status.success() {
1028 return Err(GitError::Command(format!(
1029 "ssh upload-pack failed for {}: {}",
1030 ssh_remote_display(remote),
1031 String::from_utf8_lossy(&output.stderr).trim()
1032 )));
1033 }
1034 Ok(result)
1035}
1036
1037fn ssh_remote_display(remote: &RemoteUrl) -> String {
1042 if remote.transport == RemoteTransport::Ext {
1043 return format!("ext::{}", remote.path);
1044 }
1045 let host = remote.host.as_deref().unwrap_or("");
1046 let mut out = String::new();
1047 if let Some(user) = &remote.user {
1048 out.push_str(user);
1049 out.push('@');
1050 }
1051 out.push_str(host);
1052 if let Some(port) = remote.port {
1053 out.push(':');
1054 out.push_str(&port.to_string());
1055 }
1056 if !remote.path.is_empty() {
1057 if !out.is_empty() {
1058 out.push(':');
1059 }
1060 out.push_str(&remote.path);
1061 }
1062 out
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068
1069 #[test]
1070 fn remote_ext_parser_keeps_percent_escaped_spaces_inside_arguments() {
1071 let parsed = parse_remote_ext_command("sh -c %S% ..", GitService::UploadPack)
1072 .expect("remote-ext command parses");
1073
1074 assert_eq!(parsed.argv, vec!["sh", "-c", "git-upload-pack .."],);
1075 assert_eq!(parsed.git_request, None);
1076 assert_eq!(parsed.git_request_vhost, None);
1077 }
1078
1079 #[test]
1080 fn remote_ext_parser_expands_service_without_git_prefix() {
1081 let parsed = parse_remote_ext_command("helper %s %%", GitService::ReceivePack)
1082 .expect("remote-ext command parses");
1083
1084 assert_eq!(parsed.argv, vec!["helper", "receive-pack", "%"]);
1085 }
1086
1087 #[test]
1088 fn remote_ext_parser_extracts_git_daemon_request_arguments() {
1089 let parsed =
1090 parse_remote_ext_command("fake-daemon %G/two.git %Vhost", GitService::UploadPack)
1091 .expect("remote-ext command parses");
1092
1093 assert_eq!(parsed.argv, vec!["fake-daemon"]);
1094 assert_eq!(parsed.git_request.as_deref(), Some("/two.git"));
1095 assert_eq!(parsed.git_request_vhost.as_deref(), Some("host"));
1096 }
1097}