1use std::collections::HashMap;
24use std::env;
25use std::io::Read;
26use std::path::Path;
27use std::process::{Child, ChildStdin, ChildStdout, Command as ProcessCommand, Stdio};
28
29use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
30use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
31use sley_odb::{FileObjectDatabase, build_reachable_pack, collect_reachable_object_ids};
32use sley_protocol::{
33 GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
34 ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
35 UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
36 build_receive_pack_push_request, parse_receive_pack_features, parse_refspec,
37 parse_upload_pack_features, plan_push_commands, read_receive_pack_report_status,
38 read_ref_advertisement_set, read_upload_pack_raw_packfile_response,
39 read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
40 write_upload_pack_negotiation_request, write_upload_pack_request,
41};
42use sley_refs::FileRefStore;
43use sley_transport::{RemoteTransport, RemoteUrl, SshCommandVariant, ssh_process_command};
44
45use crate::{PushOutcome, PushRequest};
46
47pub fn ssh_program() -> String {
52 env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into())
53}
54
55pub(crate) struct SshPushRequest<'a> {
66 pub git_dir: &'a Path,
67 pub common_git_dir: &'a Path,
68 pub format: ObjectFormat,
69 pub remote: &'a RemoteUrl,
70 pub refspecs: &'a [String],
71 pub force: bool,
72}
73
74pub(crate) struct SshPushCommandsRequest<'a> {
75 pub common_git_dir: &'a Path,
76 pub format: ObjectFormat,
77 pub remote: &'a RemoteUrl,
78 pub command_forces: Vec<(ReceivePackCommand, bool)>,
79 pub pack_objects: Vec<ObjectId>,
80}
81
82pub(crate) struct SshPushPlan {
83 pub(crate) commands: Vec<ReceivePackCommand>,
84 pub(crate) pack_objects: Vec<ObjectId>,
85 child: Child,
86 stdin: Option<ChildStdin>,
87 stdout: ChildStdout,
88 features: ReceivePackFeatures,
89 advertisements: Vec<RefAdvertisement>,
90 remote: RemoteUrl,
91}
92
93pub(crate) fn plan_push_ssh(request: SshPushRequest<'_>) -> Result<SshPushPlan> {
94 let SshPushRequest {
95 git_dir,
96 common_git_dir,
97 format,
98 remote,
99 refspecs,
100 force,
101 } = request;
102 if remote.transport != RemoteTransport::Ssh {
103 return Err(GitError::InvalidFormat(
104 "SSH receive-pack requires an SSH remote".into(),
105 ));
106 }
107 let ssh = ssh_process_command(
108 remote,
109 GitService::ReceivePack,
110 ssh_program(),
111 SshCommandVariant::OpenSsh,
112 )?;
113 let mut child = ProcessCommand::new(&ssh.program)
114 .args(&ssh.args)
115 .stdin(Stdio::piped())
116 .stdout(Stdio::piped())
117 .stderr(Stdio::piped())
118 .spawn()?;
119 let mut stdout = child
120 .stdout
121 .take()
122 .ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
123 let stdin = child
124 .stdin
125 .take()
126 .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
127
128 let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
129 let features = advertisement_set
130 .refs
131 .first()
132 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
133 .transpose()?
134 .unwrap_or_default();
135 if let Some(remote_format) = features.object_format {
136 if remote_format != format {
137 return Err(GitError::InvalidObjectId(format!(
138 "remote repository uses {}, local repository uses {}",
139 remote_format.name(),
140 format.name()
141 )));
142 }
143 } else if format != ObjectFormat::Sha1 {
144 return Err(GitError::InvalidObjectId(format!(
145 "remote repository did not advertise object-format for {} push",
146 format.name()
147 )));
148 }
149
150 let local_store = FileRefStore::new(git_dir, format);
151 let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
152 let parsed_refspecs = refspecs
153 .iter()
154 .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
155 .collect::<Result<Vec<_>>>()?;
156 let mut command_forces = Vec::new();
157 for refspec in &parsed_refspecs {
158 for command in plan_push_commands(
159 format,
160 &local_refs,
161 &advertisement_set.refs,
162 std::slice::from_ref(refspec),
163 )? {
164 command_forces.push((command, force || refspec.force));
165 }
166 }
167 let commands = command_forces
168 .iter()
169 .map(|(command, _)| command.clone())
170 .collect::<Vec<_>>();
171 if commands.is_empty() {
172 drop(stdin);
173 return Ok(SshPushPlan {
174 commands,
175 pack_objects: Vec::new(),
176 child,
177 stdin: None,
178 stdout,
179 features,
180 advertisements: advertisement_set.refs,
181 remote: remote.clone(),
182 });
183 }
184
185 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
186 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
187 Ok(SshPushPlan {
188 commands,
189 pack_objects: Vec::new(),
190 child,
191 stdin: Some(stdin),
192 stdout,
193 features,
194 advertisements: advertisement_set.refs,
195 remote: remote.clone(),
196 })
197}
198
199pub(crate) fn plan_push_ssh_commands(request: SshPushCommandsRequest<'_>) -> Result<SshPushPlan> {
200 let SshPushCommandsRequest {
201 common_git_dir,
202 format,
203 remote,
204 command_forces,
205 pack_objects,
206 } = request;
207 if remote.transport != RemoteTransport::Ssh {
208 return Err(GitError::InvalidFormat(
209 "SSH receive-pack requires an SSH remote".into(),
210 ));
211 }
212 let ssh = ssh_process_command(
213 remote,
214 GitService::ReceivePack,
215 ssh_program(),
216 SshCommandVariant::OpenSsh,
217 )?;
218 let mut child = ProcessCommand::new(&ssh.program)
219 .args(&ssh.args)
220 .stdin(Stdio::piped())
221 .stdout(Stdio::piped())
222 .stderr(Stdio::piped())
223 .spawn()?;
224 let mut stdout = child
225 .stdout
226 .take()
227 .ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
228 let stdin = child
229 .stdin
230 .take()
231 .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
232
233 let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
234 let features = advertisement_set
235 .refs
236 .first()
237 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
238 .transpose()?
239 .unwrap_or_default();
240 if let Some(remote_format) = features.object_format {
241 if remote_format != format {
242 return Err(GitError::InvalidObjectId(format!(
243 "remote repository uses {}, local repository uses {}",
244 remote_format.name(),
245 format.name()
246 )));
247 }
248 } else if format != ObjectFormat::Sha1 {
249 return Err(GitError::InvalidObjectId(format!(
250 "remote repository did not advertise object-format for {} push",
251 format.name()
252 )));
253 }
254
255 let commands = command_forces
256 .iter()
257 .map(|(command, _)| command.clone())
258 .collect::<Vec<_>>();
259
260 if commands.is_empty() {
261 drop(stdin);
262 return Ok(SshPushPlan {
263 commands,
264 pack_objects,
265 child,
266 stdin: None,
267 stdout,
268 features,
269 advertisements: advertisement_set.refs,
270 remote: remote.clone(),
271 });
272 }
273
274 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
275 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
276 Ok(SshPushPlan {
277 commands,
278 pack_objects,
279 child,
280 stdin: Some(stdin),
281 stdout,
282 features,
283 advertisements: advertisement_set.refs,
284 remote: remote.clone(),
285 })
286}
287
288pub(crate) fn execute_push_ssh_plan(
289 request: PushRequest<'_>,
290 mut plan: SshPushPlan,
291) -> Result<PushOutcome> {
292 if plan.commands.is_empty() {
293 return Ok(PushOutcome::default());
294 }
295 let mut stdin = plan
296 .stdin
297 .take()
298 .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not available".into()))?;
299 let commands = plan.commands.clone();
300 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
301 let remote_excluded_tips =
302 crate::remote_advertisement_tips_known_to_local(&local_db, &plan.advertisements)?;
303 let remote_excluded =
304 collect_reachable_object_ids(&local_db, request.format, remote_excluded_tips)?;
305 let starts = crate::pack::push_pack_roots(&commands, &plan.pack_objects);
306 let packfile = build_reachable_pack(&local_db, request.format, starts, &remote_excluded)?
307 .map(|pack| pack.pack)
308 .unwrap_or_default();
309 let request = build_receive_pack_push_request(
310 &plan.features,
311 commands.clone(),
312 packfile,
313 ReceivePackPushRequestOptions {
314 report_status: plan.features.report_status,
315 ofs_delta: plan.features.ofs_delta,
316 quiet: request.options.quiet && plan.features.quiet,
317 object_format: plan
318 .features
319 .object_format
320 .filter(|_| request.format != ObjectFormat::Sha1),
321 ..ReceivePackPushRequestOptions::default()
322 },
323 )?;
324 write_receive_pack_push_request(&mut stdin, &request)?;
325 drop(stdin);
326
327 let report = if plan.features.report_status {
328 let report = read_receive_pack_report_status(&mut plan.stdout)?;
329 crate::push::validate_receive_pack_report(&report)?;
330 Some(report)
331 } else {
332 let mut sink = Vec::new();
333 plan.stdout.read_to_end(&mut sink)?;
334 None
335 };
336 let output = plan.child.wait_with_output()?;
337 if !output.status.success() {
338 return Err(GitError::Command(format!(
339 "ssh receive-pack failed for {}: {}",
340 ssh_remote_display(&plan.remote),
341 String::from_utf8_lossy(&output.stderr).trim()
342 )));
343 }
344
345 Ok(PushOutcome { commands, report })
346}
347
348pub(crate) fn ls_remote_ssh(
354 remote: &RemoteUrl,
355 filter: &crate::ls_remote::LsRemoteFilter,
356 matches: &dyn Fn(&str) -> bool,
357) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
358 if remote.transport != RemoteTransport::Ssh {
359 return Err(GitError::InvalidFormat(
360 "SSH upload-pack requires an SSH remote".into(),
361 ));
362 }
363 let ssh = ssh_process_command(
364 remote,
365 GitService::UploadPack,
366 ssh_program(),
367 SshCommandVariant::OpenSsh,
368 )?;
369 let output = ProcessCommand::new(&ssh.program)
370 .args(&ssh.args)
371 .stdin(Stdio::null())
372 .output()?;
373 let mut stdout = output.stdout.as_slice();
374 let set = match read_ref_advertisement_set(ObjectFormat::Sha1, &mut stdout) {
375 Ok(set) => set,
376 Err(_) if !output.status.success() => {
377 return Err(GitError::Command(format!(
378 "ssh upload-pack failed for {}: {}",
379 ssh_remote_display(remote),
380 String::from_utf8_lossy(&output.stderr).trim()
381 )));
382 }
383 Err(err) => return Err(err),
384 };
385 let features = set
386 .refs
387 .first()
388 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
389 .transpose()?
390 .unwrap_or_default();
391 let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
392 if format != ObjectFormat::Sha1 {
393 return Err(GitError::Unsupported(format!(
394 "ssh ls-remote currently supports SHA-1 advertisements, got {}",
395 format.name()
396 )));
397 }
398 let symrefs = features
399 .symrefs
400 .iter()
401 .filter_map(|symref| symref.split_once(':'))
402 .map(|(name, target)| (name.to_string(), target.to_string()))
403 .collect::<HashMap<_, _>>();
404 let mut records = Vec::new();
405 for advertisement in set.refs {
406 if advertisement.oid.is_null() {
407 continue;
408 }
409 if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
410 {
411 continue;
412 }
413 let is_head = advertisement.name.starts_with("refs/heads/");
414 let is_tag = advertisement.name.starts_with("refs/tags/");
415 if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
416 {
417 continue;
418 }
419 if !matches(&advertisement.name) {
420 continue;
421 }
422 records.push(crate::ls_remote::LsRemoteRecord {
423 oid: advertisement.oid,
424 symref: symrefs.get(&advertisement.name).cloned(),
425 name: advertisement.name,
426 });
427 }
428 Ok((records, format))
429}
430
431pub struct SshFetchPackRequest<'a> {
442 pub git_dir: &'a Path,
444 pub format: ObjectFormat,
446 pub remote: &'a RemoteUrl,
448 pub features: &'a UploadPackFeatures,
450 pub wants: Vec<ObjectId>,
452 pub shallow: Vec<ObjectId>,
454 pub deepen: Option<u32>,
456 pub promisor: bool,
458}
459
460pub fn install_fetch_pack_via_ssh_upload_pack(
461 request: SshFetchPackRequest<'_>,
462) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
463 if request.wants.is_empty() {
464 return Ok(Vec::new());
465 }
466 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
467 if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
471 return Ok(Vec::new());
472 }
473 let upload_request = UploadPackRequest {
474 wants: request.wants,
475 capabilities: ssh_shallow_request_capabilities(request.deepen),
476 shallow: request.shallow,
477 deepen: request.deepen,
478 ..UploadPackRequest::default()
479 };
480 let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
481 let (shallow_info, response) = if request.deepen.is_some() {
485 ssh_upload_pack_shallow_fetch_response(
486 request.remote,
487 request.format,
488 request.features,
489 upload_request,
490 haves,
491 )?
492 } else {
493 let response = ssh_upload_pack_fetch_response(
494 request.remote,
495 request.format,
496 request.features,
497 upload_request,
498 haves,
499 )?;
500 (Vec::new(), response)
501 };
502 if request.promisor {
503 install_upload_pack_raw_promisor_response(&response, &local_db)?;
504 } else {
505 install_upload_pack_raw_response(&response, &local_db)?;
506 }
507 Ok(shallow_info)
508}
509
510fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
511 for want in wants {
512 if !db.contains(want)? {
513 return Ok(false);
514 }
515 }
516 Ok(true)
517}
518
519fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
523 if deepen.is_some() {
524 vec![Capability {
525 name: "shallow".into(),
526 value: None,
527 }]
528 } else {
529 Vec::new()
530 }
531}
532
533pub fn ssh_upload_pack_advertisements(
535 remote: &RemoteUrl,
536 format: ObjectFormat,
537) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
538 if remote.transport != RemoteTransport::Ssh {
539 return Err(GitError::InvalidFormat(
540 "SSH upload-pack requires an SSH remote".into(),
541 ));
542 }
543 let ssh = ssh_process_command(
544 remote,
545 GitService::UploadPack,
546 ssh_program(),
547 SshCommandVariant::OpenSsh,
548 )?;
549 let output = ProcessCommand::new(&ssh.program)
550 .args(&ssh.args)
551 .stdin(Stdio::null())
552 .output()?;
553 let mut stdout = output.stdout.as_slice();
554 let set = match read_ref_advertisement_set(format, &mut stdout) {
555 Ok(set) => set,
556 Err(_) if !output.status.success() => {
557 return Err(GitError::Command(format!(
558 "ssh upload-pack failed for {}: {}",
559 ssh_remote_display(remote),
560 String::from_utf8_lossy(&output.stderr).trim()
561 )));
562 }
563 Err(err) => return Err(err),
564 };
565 let features = set
566 .refs
567 .first()
568 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
569 .transpose()?
570 .unwrap_or_default();
571 Ok((set.refs, features))
572}
573
574pub fn ssh_upload_pack_fetch_response(
579 remote: &RemoteUrl,
580 format: ObjectFormat,
581 _features: &UploadPackFeatures,
582 request: UploadPackRequest,
583 haves: Vec<ObjectId>,
584) -> Result<UploadPackRawPackfileResponse> {
585 let (_shallow, response) =
586 ssh_upload_pack_fetch_response_inner(remote, format, request, haves, false)?;
587 Ok(response)
588}
589
590pub fn ssh_upload_pack_shallow_fetch_response(
596 remote: &RemoteUrl,
597 format: ObjectFormat,
598 _features: &UploadPackFeatures,
599 request: UploadPackRequest,
600 haves: Vec<ObjectId>,
601) -> Result<(
602 Vec<ProtocolV2FetchShallowInfo>,
603 UploadPackRawPackfileResponse,
604)> {
605 ssh_upload_pack_fetch_response_inner(remote, format, request, haves, true)
606}
607
608fn ssh_upload_pack_fetch_response_inner(
613 remote: &RemoteUrl,
614 format: ObjectFormat,
615 request: UploadPackRequest,
616 haves: Vec<ObjectId>,
617 expect_shallow_info: bool,
618) -> Result<(
619 Vec<ProtocolV2FetchShallowInfo>,
620 UploadPackRawPackfileResponse,
621)> {
622 if remote.transport != RemoteTransport::Ssh {
623 return Err(GitError::InvalidFormat(
624 "SSH upload-pack requires an SSH remote".into(),
625 ));
626 }
627 let ssh = ssh_process_command(
628 remote,
629 GitService::UploadPack,
630 ssh_program(),
631 SshCommandVariant::OpenSsh,
632 )?;
633 let mut child = ProcessCommand::new(&ssh.program)
634 .args(&ssh.args)
635 .stdin(Stdio::piped())
636 .stdout(Stdio::piped())
637 .stderr(Stdio::piped())
638 .spawn()?;
639 let mut stdout = child
640 .stdout
641 .take()
642 .ok_or_else(|| GitError::Command("ssh upload-pack stdout was not piped".into()))?;
643 let mut stdin = child
644 .stdin
645 .take()
646 .ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
647
648 read_ref_advertisement_set(format, &mut stdout)?;
649 write_upload_pack_request(&mut stdin, Some(&request))?;
650 write_upload_pack_negotiation_request(
651 &mut stdin,
652 &UploadPackNegotiationRequest { haves, done: true },
653 )?;
654 drop(stdin);
655
656 let result = if expect_shallow_info {
657 read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stdout)?
658 } else {
659 (
660 Vec::new(),
661 read_upload_pack_raw_packfile_response(format, &mut stdout)?,
662 )
663 };
664 let output = child.wait_with_output()?;
665 if !output.status.success() {
666 return Err(GitError::Command(format!(
667 "ssh upload-pack failed for {}: {}",
668 ssh_remote_display(remote),
669 String::from_utf8_lossy(&output.stderr).trim()
670 )));
671 }
672 Ok(result)
673}
674
675fn ssh_remote_display(remote: &RemoteUrl) -> String {
680 let host = remote.host.as_deref().unwrap_or("");
681 let mut out = String::new();
682 if let Some(user) = &remote.user {
683 out.push_str(user);
684 out.push('@');
685 }
686 out.push_str(host);
687 if let Some(port) = remote.port {
688 out.push(':');
689 out.push_str(&port.to_string());
690 }
691 if !remote.path.is_empty() {
692 if !out.is_empty() {
693 out.push(':');
694 }
695 out.push_str(&remote.path);
696 }
697 out
698}