use std::collections::HashMap;
use std::env;
use std::io::Read;
use std::path::Path;
use std::process::{Child, ChildStdin, ChildStdout, Command as ProcessCommand, Stdio};
use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
use sley_odb::{FileObjectDatabase, build_reachable_pack, collect_reachable_object_ids};
use sley_protocol::{
GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
build_receive_pack_push_request, parse_receive_pack_features, parse_refspec,
parse_upload_pack_features, plan_push_commands, read_receive_pack_report_status,
read_ref_advertisement_set, read_upload_pack_raw_packfile_response,
read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
write_upload_pack_negotiation_request, write_upload_pack_request,
};
use sley_refs::FileRefStore;
use sley_transport::{RemoteTransport, RemoteUrl, SshCommandVariant, ssh_process_command};
use crate::{PushOutcome, PushRequest};
pub fn ssh_program() -> String {
env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into())
}
pub(crate) struct SshPushRequest<'a> {
pub git_dir: &'a Path,
pub common_git_dir: &'a Path,
pub format: ObjectFormat,
pub remote: &'a RemoteUrl,
pub refspecs: &'a [String],
pub force: bool,
}
pub(crate) struct SshPushCommandsRequest<'a> {
pub common_git_dir: &'a Path,
pub format: ObjectFormat,
pub remote: &'a RemoteUrl,
pub command_forces: Vec<(ReceivePackCommand, bool)>,
pub pack_objects: Vec<ObjectId>,
}
pub(crate) struct SshPushPlan {
pub(crate) commands: Vec<ReceivePackCommand>,
pub(crate) pack_objects: Vec<ObjectId>,
child: Child,
stdin: Option<ChildStdin>,
stdout: ChildStdout,
features: ReceivePackFeatures,
advertisements: Vec<RefAdvertisement>,
remote: RemoteUrl,
}
pub(crate) fn plan_push_ssh(request: SshPushRequest<'_>) -> Result<SshPushPlan> {
let SshPushRequest {
git_dir,
common_git_dir,
format,
remote,
refspecs,
force,
} = request;
if remote.transport != RemoteTransport::Ssh {
return Err(GitError::InvalidFormat(
"SSH receive-pack requires an SSH remote".into(),
));
}
let ssh = ssh_process_command(
remote,
GitService::ReceivePack,
ssh_program(),
SshCommandVariant::OpenSsh,
)?;
let mut child = ProcessCommand::new(&ssh.program)
.args(&ssh.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
let features = advertisement_set
.refs
.first()
.map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
.transpose()?
.unwrap_or_default();
if let Some(remote_format) = features.object_format {
if remote_format != format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
format.name()
)));
}
} else if format != ObjectFormat::Sha1 {
return Err(GitError::InvalidObjectId(format!(
"remote repository did not advertise object-format for {} push",
format.name()
)));
}
let local_store = FileRefStore::new(git_dir, format);
let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
let parsed_refspecs = refspecs
.iter()
.map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
.collect::<Result<Vec<_>>>()?;
let mut command_forces = Vec::new();
for refspec in &parsed_refspecs {
for command in plan_push_commands(
format,
&local_refs,
&advertisement_set.refs,
std::slice::from_ref(refspec),
)? {
command_forces.push((command, force || refspec.force));
}
}
let commands = command_forces
.iter()
.map(|(command, _)| command.clone())
.collect::<Vec<_>>();
if commands.is_empty() {
drop(stdin);
return Ok(SshPushPlan {
commands,
pack_objects: Vec::new(),
child,
stdin: None,
stdout,
features,
advertisements: advertisement_set.refs,
remote: remote.clone(),
});
}
let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
Ok(SshPushPlan {
commands,
pack_objects: Vec::new(),
child,
stdin: Some(stdin),
stdout,
features,
advertisements: advertisement_set.refs,
remote: remote.clone(),
})
}
pub(crate) fn plan_push_ssh_commands(request: SshPushCommandsRequest<'_>) -> Result<SshPushPlan> {
let SshPushCommandsRequest {
common_git_dir,
format,
remote,
command_forces,
pack_objects,
} = request;
if remote.transport != RemoteTransport::Ssh {
return Err(GitError::InvalidFormat(
"SSH receive-pack requires an SSH remote".into(),
));
}
let ssh = ssh_process_command(
remote,
GitService::ReceivePack,
ssh_program(),
SshCommandVariant::OpenSsh,
)?;
let mut child = ProcessCommand::new(&ssh.program)
.args(&ssh.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
let features = advertisement_set
.refs
.first()
.map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
.transpose()?
.unwrap_or_default();
if let Some(remote_format) = features.object_format {
if remote_format != format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
format.name()
)));
}
} else if format != ObjectFormat::Sha1 {
return Err(GitError::InvalidObjectId(format!(
"remote repository did not advertise object-format for {} push",
format.name()
)));
}
let commands = command_forces
.iter()
.map(|(command, _)| command.clone())
.collect::<Vec<_>>();
if commands.is_empty() {
drop(stdin);
return Ok(SshPushPlan {
commands,
pack_objects,
child,
stdin: None,
stdout,
features,
advertisements: advertisement_set.refs,
remote: remote.clone(),
});
}
let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
Ok(SshPushPlan {
commands,
pack_objects,
child,
stdin: Some(stdin),
stdout,
features,
advertisements: advertisement_set.refs,
remote: remote.clone(),
})
}
pub(crate) fn execute_push_ssh_plan(
request: PushRequest<'_>,
mut plan: SshPushPlan,
) -> Result<PushOutcome> {
if plan.commands.is_empty() {
return Ok(PushOutcome::default());
}
let mut stdin = plan
.stdin
.take()
.ok_or_else(|| GitError::Command("ssh receive-pack stdin was not available".into()))?;
let commands = plan.commands.clone();
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
let remote_excluded_tips =
crate::remote_advertisement_tips_known_to_local(&local_db, &plan.advertisements)?;
let remote_excluded =
collect_reachable_object_ids(&local_db, request.format, remote_excluded_tips)?;
let starts = crate::pack::push_pack_roots(&commands, &plan.pack_objects);
let packfile = build_reachable_pack(&local_db, request.format, starts, &remote_excluded)?
.map(|pack| pack.pack)
.unwrap_or_default();
let request = build_receive_pack_push_request(
&plan.features,
commands.clone(),
packfile,
ReceivePackPushRequestOptions {
report_status: plan.features.report_status,
ofs_delta: plan.features.ofs_delta,
quiet: request.options.quiet && plan.features.quiet,
object_format: plan
.features
.object_format
.filter(|_| request.format != ObjectFormat::Sha1),
..ReceivePackPushRequestOptions::default()
},
)?;
write_receive_pack_push_request(&mut stdin, &request)?;
drop(stdin);
let report = if plan.features.report_status {
let report = read_receive_pack_report_status(&mut plan.stdout)?;
crate::push::validate_receive_pack_report(&report)?;
Some(report)
} else {
let mut sink = Vec::new();
plan.stdout.read_to_end(&mut sink)?;
None
};
let output = plan.child.wait_with_output()?;
if !output.status.success() {
return Err(GitError::Command(format!(
"ssh receive-pack failed for {}: {}",
ssh_remote_display(&plan.remote),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(PushOutcome { commands, report })
}
pub(crate) fn ls_remote_ssh(
remote: &RemoteUrl,
filter: &crate::ls_remote::LsRemoteFilter,
matches: &dyn Fn(&str) -> bool,
) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
if remote.transport != RemoteTransport::Ssh {
return Err(GitError::InvalidFormat(
"SSH upload-pack requires an SSH remote".into(),
));
}
let ssh = ssh_process_command(
remote,
GitService::UploadPack,
ssh_program(),
SshCommandVariant::OpenSsh,
)?;
let output = ProcessCommand::new(&ssh.program)
.args(&ssh.args)
.stdin(Stdio::null())
.output()?;
let mut stdout = output.stdout.as_slice();
let set = match read_ref_advertisement_set(ObjectFormat::Sha1, &mut stdout) {
Ok(set) => set,
Err(_) if !output.status.success() => {
return Err(GitError::Command(format!(
"ssh upload-pack failed for {}: {}",
ssh_remote_display(remote),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Err(err) => return Err(err),
};
let features = set
.refs
.first()
.map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
.transpose()?
.unwrap_or_default();
let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
if format != ObjectFormat::Sha1 {
return Err(GitError::Unsupported(format!(
"ssh ls-remote currently supports SHA-1 advertisements, got {}",
format.name()
)));
}
let symrefs = features
.symrefs
.iter()
.filter_map(|symref| symref.split_once(':'))
.map(|(name, target)| (name.to_string(), target.to_string()))
.collect::<HashMap<_, _>>();
let mut records = Vec::new();
for advertisement in set.refs {
if advertisement.oid.is_null() {
continue;
}
if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
{
continue;
}
let is_head = advertisement.name.starts_with("refs/heads/");
let is_tag = advertisement.name.starts_with("refs/tags/");
if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
{
continue;
}
if !matches(&advertisement.name) {
continue;
}
records.push(crate::ls_remote::LsRemoteRecord {
oid: advertisement.oid,
symref: symrefs.get(&advertisement.name).cloned(),
name: advertisement.name,
});
}
Ok((records, format))
}
pub struct SshFetchPackRequest<'a> {
pub git_dir: &'a Path,
pub format: ObjectFormat,
pub remote: &'a RemoteUrl,
pub features: &'a UploadPackFeatures,
pub wants: Vec<ObjectId>,
pub shallow: Vec<ObjectId>,
pub deepen: Option<u32>,
pub promisor: bool,
}
pub fn install_fetch_pack_via_ssh_upload_pack(
request: SshFetchPackRequest<'_>,
) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
if request.wants.is_empty() {
return Ok(Vec::new());
}
let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
return Ok(Vec::new());
}
let upload_request = UploadPackRequest {
wants: request.wants,
capabilities: ssh_shallow_request_capabilities(request.deepen),
shallow: request.shallow,
deepen: request.deepen,
..UploadPackRequest::default()
};
let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
let (shallow_info, response) = if request.deepen.is_some() {
ssh_upload_pack_shallow_fetch_response(
request.remote,
request.format,
request.features,
upload_request,
haves,
)?
} else {
let response = ssh_upload_pack_fetch_response(
request.remote,
request.format,
request.features,
upload_request,
haves,
)?;
(Vec::new(), response)
};
if request.promisor {
install_upload_pack_raw_promisor_response(&response, &local_db)?;
} else {
install_upload_pack_raw_response(&response, &local_db)?;
}
Ok(shallow_info)
}
fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
for want in wants {
if !db.contains(want)? {
return Ok(false);
}
}
Ok(true)
}
fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
if deepen.is_some() {
vec![Capability {
name: "shallow".into(),
value: None,
}]
} else {
Vec::new()
}
}
pub fn ssh_upload_pack_advertisements(
remote: &RemoteUrl,
format: ObjectFormat,
) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
if remote.transport != RemoteTransport::Ssh {
return Err(GitError::InvalidFormat(
"SSH upload-pack requires an SSH remote".into(),
));
}
let ssh = ssh_process_command(
remote,
GitService::UploadPack,
ssh_program(),
SshCommandVariant::OpenSsh,
)?;
let output = ProcessCommand::new(&ssh.program)
.args(&ssh.args)
.stdin(Stdio::null())
.output()?;
let mut stdout = output.stdout.as_slice();
let set = match read_ref_advertisement_set(format, &mut stdout) {
Ok(set) => set,
Err(_) if !output.status.success() => {
return Err(GitError::Command(format!(
"ssh upload-pack failed for {}: {}",
ssh_remote_display(remote),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Err(err) => return Err(err),
};
let features = set
.refs
.first()
.map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
.transpose()?
.unwrap_or_default();
Ok((set.refs, features))
}
pub fn ssh_upload_pack_fetch_response(
remote: &RemoteUrl,
format: ObjectFormat,
_features: &UploadPackFeatures,
request: UploadPackRequest,
haves: Vec<ObjectId>,
) -> Result<UploadPackRawPackfileResponse> {
let (_shallow, response) =
ssh_upload_pack_fetch_response_inner(remote, format, request, haves, false)?;
Ok(response)
}
pub fn ssh_upload_pack_shallow_fetch_response(
remote: &RemoteUrl,
format: ObjectFormat,
_features: &UploadPackFeatures,
request: UploadPackRequest,
haves: Vec<ObjectId>,
) -> Result<(
Vec<ProtocolV2FetchShallowInfo>,
UploadPackRawPackfileResponse,
)> {
ssh_upload_pack_fetch_response_inner(remote, format, request, haves, true)
}
fn ssh_upload_pack_fetch_response_inner(
remote: &RemoteUrl,
format: ObjectFormat,
request: UploadPackRequest,
haves: Vec<ObjectId>,
expect_shallow_info: bool,
) -> Result<(
Vec<ProtocolV2FetchShallowInfo>,
UploadPackRawPackfileResponse,
)> {
if remote.transport != RemoteTransport::Ssh {
return Err(GitError::InvalidFormat(
"SSH upload-pack requires an SSH remote".into(),
));
}
let ssh = ssh_process_command(
remote,
GitService::UploadPack,
ssh_program(),
SshCommandVariant::OpenSsh,
)?;
let mut child = ProcessCommand::new(&ssh.program)
.args(&ssh.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| GitError::Command("ssh upload-pack stdout was not piped".into()))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
read_ref_advertisement_set(format, &mut stdout)?;
write_upload_pack_request(&mut stdin, Some(&request))?;
write_upload_pack_negotiation_request(
&mut stdin,
&UploadPackNegotiationRequest { haves, done: true },
)?;
drop(stdin);
let result = if expect_shallow_info {
read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stdout)?
} else {
(
Vec::new(),
read_upload_pack_raw_packfile_response(format, &mut stdout)?,
)
};
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(GitError::Command(format!(
"ssh upload-pack failed for {}: {}",
ssh_remote_display(remote),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(result)
}
fn ssh_remote_display(remote: &RemoteUrl) -> String {
let host = remote.host.as_deref().unwrap_or("");
let mut out = String::new();
if let Some(user) = &remote.user {
out.push_str(user);
out.push('@');
}
out.push_str(host);
if let Some(port) = remote.port {
out.push(':');
out.push_str(&port.to_string());
}
if !remote.path.is_empty() {
if !out.is_empty() {
out.push(':');
}
out.push_str(&remote.path);
}
out
}