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