#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::{OsStr, OsString};
use std::num::NonZeroU32;
use std::ops::ControlFlow;
#[cfg(feature = "http")]
use std::path::Path;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use crate::fetch::{
FetchLimits, FetchRequest, FetchUpdateKind, FetchUpdateLimits, FetchWorkflowError,
KnownHistory, RemoteHead,
};
use crate::push::{PreparedPush, PushCommand, PushFailure, PushLimits, PushReport};
use crate::refs::{RefName, Reflog};
use crate::remote::{Destination, Direction, Protocol, ProtocolEnvironment, Refspecs, Remote};
use crate::transport::TransportControl;
use crate::{ObjectId, Repository};
#[derive(Clone, Default)]
pub struct Environment {
vars: BTreeMap<OsString, OsString>,
current_dir: Option<PathBuf>,
}
impl std::fmt::Debug for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Environment")
.field("names", &self.vars.keys().collect::<Vec<_>>())
.finish()
}
}
impl Environment {
pub fn from_process() -> Self {
let mut environment: Self = std::env::vars_os().collect();
environment.current_dir = std::env::current_dir().ok();
environment
}
pub fn with_current_dir(mut self, directory: impl Into<PathBuf>) -> Self {
self.current_dir = Some(directory.into());
self
}
pub fn get(&self, name: &str) -> Option<&OsStr> {
self.vars.get(OsStr::new(name)).map(OsString::as_os_str)
}
pub fn with(mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.vars.insert(name.into(), value.into());
self
}
pub fn without(mut self, name: impl AsRef<OsStr>) -> Self {
self.vars.remove(name.as_ref());
self
}
fn bytes(&self, name: &str) -> Option<Vec<u8>> {
self.get(name)
.map(|value| value.as_encoded_bytes().to_vec())
}
#[cfg(feature = "http")]
fn string(&self, name: &str) -> Option<String> {
self.get(name).and_then(OsStr::to_str).map(str::to_owned)
}
}
impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for Environment {
fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
Self {
vars: iter
.into_iter()
.map(|(name, value)| (name.into(), value.into()))
.collect(),
current_dir: None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum CredentialPrompt {
Username,
Password,
}
pub trait TransferCallbacks {
fn remote_message(&mut self, _message: &[u8]) {}
fn transport_message(&mut self, _message: &[u8]) {}
fn credential(&mut self, _url: &str, _prompt: CredentialPrompt) -> Option<Vec<u8>> {
None
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NoCallbacks;
impl TransferCallbacks for NoCallbacks {}
#[derive(Clone, Debug)]
pub struct FetchOptions {
refspecs: Vec<Vec<u8>>,
depth: Option<NonZeroU32>,
prune: bool,
tag_namespaces: Vec<RefName>,
reflog: Option<(crate::Signature, Vec<u8>)>,
}
impl FetchOptions {
pub fn new<I, S>(refspecs: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<[u8]>,
{
Self {
refspecs: refspecs
.into_iter()
.map(|refspec| refspec.as_ref().to_vec())
.collect(),
depth: None,
prune: false,
tag_namespaces: Vec::new(),
reflog: None,
}
}
pub fn depth(mut self, depth: Option<NonZeroU32>) -> Self {
self.depth = depth;
self
}
pub fn prune(mut self, prune: bool) -> Self {
self.prune = prune;
self
}
pub fn tag_namespace(mut self, namespace: RefName) -> Self {
self.tag_namespaces.push(namespace);
self
}
pub fn reflog(mut self, committer: crate::Signature, message: impl Into<Vec<u8>>) -> Self {
self.reflog = Some((committer, message.into()));
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FetchedRef {
pub name: RefName,
pub old: Option<ObjectId>,
pub new: Option<ObjectId>,
}
#[derive(Clone, Debug, Default)]
pub struct FetchOutcome {
pub updated: Vec<FetchedRef>,
pub missing_sources: Vec<Vec<u8>>,
}
#[derive(Clone, Debug, Default)]
pub struct PushOptions {
push_options: Vec<Vec<u8>>,
identity: Option<crate::Signature>,
compression: Option<crate::PackCompression>,
}
impl PushOptions {
pub fn compression(mut self, compression: crate::PackCompression) -> Self {
self.compression = Some(compression);
self
}
pub fn identity(mut self, identity: crate::Signature) -> Self {
self.identity = Some(identity);
self
}
pub fn push_option(mut self, option: impl Into<Vec<u8>>) -> Self {
self.push_options.push(option.into());
self
}
}
pub type RemoteHeadState = RemoteHead;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TransferError {
#[error("no such remote: {0}")]
NoSuchRemote(String),
#[error("invalid remote configuration: {0}")]
Configuration(String),
#[error("unsupported transport for remote {0}")]
UnsupportedTransport(String),
#[error("authentication failed for {0}")]
Authentication(String),
#[error(transparent)]
Fetch(Box<FetchWorkflowError>),
#[error(transparent)]
FetchFinish(Box<crate::fetch::FetchFinishError>),
#[error(transparent)]
Push(Box<crate::push::PushError>),
#[error(transparent)]
Local(Box<dyn std::error::Error + Send + Sync>),
}
impl TransferError {
fn local(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self::Local(error.into())
}
}
enum Endpoint {
Local(PathBuf),
#[cfg(feature = "http")]
Http(Box<Http>),
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
Ssh(Box<crate::transport::ssh::SshRemote>),
}
#[cfg(feature = "http")]
struct Http {
settings_config: crate::Config,
remote_name: Vec<u8>,
destination: Destination,
environment: crate::transport::http::HttpEnvironment,
helpers: Vec<crate::remote::CredentialHelper>,
display_url: String,
}
#[cfg(feature = "http")]
impl Http {
fn remote(
&self,
credentials: Option<crate::remote::CredentialSession>,
) -> Result<crate::transport::http::HttpRemote, TransferError> {
let settings = crate::transport::http::HttpSettings::resolve_for_remote(
&self.settings_config,
&self.remote_name,
&self.destination,
&self.environment,
)
.map_err(|error| TransferError::Configuration(error.to_string()))?;
let remote = crate::transport::http::HttpRemote::configured(settings)
.map_err(|error| TransferError::Configuration(error.to_string()))?;
match credentials {
Some(session) => remote
.with_credentials(session)
.map_err(|error| TransferError::Configuration(error.to_string())),
None => Ok(remote),
}
}
fn credentials(
&self,
callbacks: &mut dyn TransferCallbacks,
) -> Result<crate::remote::CredentialSession, TransferError> {
let url = url_parts(self.destination.bytes())
.ok_or_else(|| TransferError::Configuration("invalid HTTP URL".into()))?;
let context = crate::remote::CredentialContext {
protocol: url.scheme.into_bytes(),
host: url.authority.into_bytes(),
path: Some(url.path.trim_matches('/').as_bytes().to_vec()),
use_http_path: false,
};
let display_url = self.display_url.clone();
let mut prompt = |prompt: crate::remote::Prompt| {
let prompt = match prompt {
crate::remote::Prompt::Username => CredentialPrompt::Username,
crate::remote::Prompt::Password => CredentialPrompt::Password,
};
callbacks.credential(&display_url, prompt)
};
crate::remote::CredentialSession::fill(
&self.settings_config,
context,
&self.helpers,
Some(&mut prompt),
&AtomicBool::new(false),
None,
)
.map_err(|_| TransferError::Authentication(self.display_url.clone()))
}
}
#[cfg(feature = "http")]
struct UrlParts {
scheme: String,
authority: String,
path: String,
}
#[cfg(feature = "http")]
fn url_parts(bytes: &[u8]) -> Option<UrlParts> {
let text = std::str::from_utf8(bytes).ok()?;
let (scheme, rest) = text.split_once("://")?;
let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
let authority = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
Some(UrlParts {
scheme: scheme.to_ascii_lowercase(),
authority: authority.to_owned(),
path: path.to_owned(),
})
}
#[cfg(feature = "http")]
fn redacted_url(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
match url_parts(bytes) {
Some(parts) => format!("{}://{}{}", parts.scheme, parts.authority, parts.path),
None => text.into_owned(),
}
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
fn find_program(environment: &Environment, program: &str) -> Option<PathBuf> {
let path = environment.get("PATH")?;
std::env::split_paths(path)
.map(|dir| dir.join(program))
.find(|candidate| candidate.is_file())
}
#[cfg(feature = "http")]
fn credential_helpers(
config: &crate::Config,
environment: &Environment,
) -> Vec<crate::remote::CredentialHelper> {
let exec_dirs: Vec<PathBuf> = environment
.get("GIT_EXEC_PATH")
.map(PathBuf::from)
.into_iter()
.chain(
[
"/opt/homebrew/opt/git/libexec/git-core",
"/usr/local/opt/git/libexec/git-core",
"/usr/lib/git-core",
"/usr/libexec/git-core",
"/Library/Developer/CommandLineTools/usr/libexec/git-core",
"/Applications/Xcode.app/Contents/Developer/usr/libexec/git-core",
]
.map(PathBuf::from),
)
.collect();
config
.entries()
.iter()
.filter(|entry| {
entry.section.eq_ignore_ascii_case(b"credential")
&& entry.name.eq_ignore_ascii_case(b"helper")
})
.filter_map(|entry| entry.value.as_deref())
.filter(|value| !value.is_empty())
.filter_map(|value| {
let text = std::str::from_utf8(value).ok()?;
if let Some(store) = native_store(text, environment) {
return Some(crate::remote::CredentialHelper {
configured_name: value.to_vec(),
backend: crate::remote::CredentialBackend::Store(store),
});
}
let command = if let Some(snippet) = text.strip_prefix('!') {
snippet.to_owned()
} else if Path::new(text.split_whitespace().next()?).is_absolute() {
text.to_owned()
} else {
let (name, arguments) = text.split_once(' ').unwrap_or((text, ""));
let program = format!("git-credential-{name}");
let path = find_program(environment, &program).or_else(|| {
exec_dirs
.iter()
.map(|dir| dir.join(&program))
.find(|candidate| candidate.is_file())
})?;
format!("{} {arguments}", shell_quote(&path.to_string_lossy()))
};
Some(crate::remote::CredentialHelper {
configured_name: value.to_vec(),
backend: crate::remote::CredentialBackend::Program(
crate::remote::CredentialProgram {
executable: PathBuf::from("/bin/sh"),
arguments: vec![
"-c".into(),
format!("{command} \"$@\"").into(),
command.into(),
],
inherit_environment: false,
environment: environment
.vars
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
},
),
})
})
.collect()
}
#[cfg(feature = "http")]
fn native_store(text: &str, environment: &Environment) -> Option<crate::remote::CredentialStore> {
let mut words = text.split_whitespace();
if words.next()? != "store" {
return None;
}
let home = environment.get("HOME").map(PathBuf::from);
let file = match (words.next(), words.next()) {
(None, _) => None,
(Some("--file"), Some(path)) => Some(path),
(Some(option), None) => Some(option.strip_prefix("--file=")?),
_ => return None,
};
if words.next().is_some() {
return None;
}
let Some(file) = file else {
let xdg = environment.get("XDG_CONFIG_HOME").map(PathBuf::from);
return Some(crate::remote::CredentialStore::user_files(
home.as_deref(),
xdg.as_deref(),
));
};
let plain = |byte: u8| byte.is_ascii_alphanumeric() || b"/._-+,:@%=~".contains(&byte);
if !file.bytes().all(plain) {
return None;
}
let path = match file.strip_prefix("~/") {
Some(rest) => home?.join(rest),
None if file.starts_with('~') => return None,
None => PathBuf::from(file),
};
let path = match &environment.current_dir {
Some(directory) if path.is_relative() => directory.join(path),
_ => path,
};
Some(crate::remote::CredentialStore::file(path))
}
#[cfg(feature = "http")]
fn http_environment(
config: &crate::Config,
destination: &Destination,
environment: &Environment,
) -> Result<crate::transport::http::HttpEnvironment, TransferError> {
let ca_path = match environment.get("GIT_SSL_CAINFO") {
Some(path) => Some(PathBuf::from(path)),
None => crate::transport::http::HttpSettings::configured_ca_info(config, destination)
.map_err(|error| TransferError::Configuration(error.to_string()))?
.map(|path| {
let path = String::from_utf8_lossy(path).into_owned();
match (path.strip_prefix("~/"), environment.get("HOME")) {
(Some(rest), Some(home)) => Path::new(home).join(rest),
_ => PathBuf::from(path),
}
}),
};
let ssl_ca_info = ca_path
.map(|path| {
std::fs::read(&path).map_err(|error| {
TransferError::Configuration(format!("reading {}: {error}", path.display()))
})
})
.transpose()?;
let proxy_var = |names: &[&str]| names.iter().find_map(|name| environment.string(name));
Ok(crate::transport::http::HttpEnvironment {
ssl_ca_info,
ssl_no_verify: environment
.bytes("GIT_SSL_NO_VERIFY")
.map(|value| crate::config::boolean(Some(&value)).unwrap_or(true)),
proxy: proxy_var(&[
"https_proxy",
"HTTPS_PROXY",
"http_proxy",
"all_proxy",
"ALL_PROXY",
]),
no_proxy: proxy_var(&["no_proxy", "NO_PROXY"]),
proxy_credentials: None,
allow_insecure_tls: true,
})
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
fn ssh_endpoint(
config: &crate::Config,
destination: &Destination,
environment: &Environment,
) -> Result<crate::transport::ssh::SshRemote, TransferError> {
use crate::transport::ssh::{ApprovedSshCommand, OpenSshOptions, SshRemote};
let configured = environment
.bytes("GIT_SSH_COMMAND")
.or_else(|| {
config
.string("core", None, "sshcommand")
.map(<[u8]>::to_vec)
})
.map(|command| (command.clone(), command))
.or_else(|| {
environment.bytes("GIT_SSH").map(|program| {
let quoted = shell_quote(&String::from_utf8_lossy(&program));
(program, quoted.into_bytes())
})
});
let approved_command = configured.map(|(configured, command)| {
let command = String::from_utf8_lossy(&command).into_owned();
ApprovedSshCommand {
configured,
executable: PathBuf::from("/bin/sh"),
arguments: vec!["-c".into(), format!("{command} \"$@\""), command],
}
});
let default_executable =
find_program(environment, "ssh").unwrap_or_else(|| PathBuf::from("/usr/bin/ssh"));
let environment = environment
.clone()
.without("GIT_PROTOCOL")
.without("GIT_SSH_VARIANT");
SshRemote::openssh(
config,
destination,
OpenSshOptions {
default_executable,
approved_command,
environment: environment.vars,
},
)
.map_err(|error| TransferError::Configuration(error.to_string()))
}
impl Repository {
fn endpoint(
&self,
remote_name: &str,
direction: Direction,
environment: &Environment,
) -> Result<(Endpoint, Destination), TransferError> {
let config = self.config();
let remote = Remote::find(config, remote_name.as_bytes())
.map_err(|error| TransferError::Configuration(error.to_string()))?
.ok_or_else(|| TransferError::NoSuchRemote(remote_name.to_owned()))?;
let protocol_environment =
ProtocolEnvironment::from_environment(|name| environment.bytes(name))
.map_err(|error| TransferError::Configuration(error.to_string()))?;
let destination = match direction {
Direction::Fetch => remote.fetch_destination(config, &protocol_environment),
Direction::Push => remote
.push_destinations(config, &protocol_environment)
.and_then(|destinations| {
destinations
.into_iter()
.next()
.ok_or(crate::remote::EndpointError::Missing)
}),
}
.map_err(|error| match error {
crate::remote::EndpointError::Missing => {
TransferError::NoSuchRemote(remote_name.to_owned())
}
error => TransferError::Configuration(error.to_string()),
})?;
let endpoint = match destination.protocol() {
Protocol::Local | Protocol::File => {
let path = destination
.local_path()
.map_err(|error| TransferError::Configuration(error.to_string()))?;
let base = environment
.current_dir
.as_deref()
.or(self.worktree())
.unwrap_or(self.git_dir());
Endpoint::Local(base.join(path))
}
#[cfg(feature = "http")]
Protocol::Http | Protocol::Https => Endpoint::Http(Box::new(Http {
environment: http_environment(config, &destination, environment)?,
helpers: credential_helpers(config, environment),
display_url: redacted_url(destination.bytes()),
settings_config: config.clone(),
remote_name: remote_name.as_bytes().to_vec(),
destination: destination.clone(),
})),
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
Protocol::Ssh => {
Endpoint::Ssh(Box::new(ssh_endpoint(config, &destination, environment)?))
}
_ => return Err(TransferError::UnsupportedTransport(remote_name.to_owned())),
};
Ok((endpoint, destination))
}
pub fn remote_head(
&self,
remote_name: &str,
environment: &Environment,
callbacks: &mut dyn TransferCallbacks,
) -> Result<RemoteHead, TransferError> {
let (endpoint, _) = self.endpoint(remote_name, Direction::Fetch, environment)?;
let cancel = AtomicBool::new(false);
let control = TransportControl::new(&cancel);
let limits = FetchLimits::trusted();
let discovery = match endpoint {
Endpoint::Local(path) => crate::fetch::discover_local(path, limits, control),
#[cfg(feature = "http")]
Endpoint::Http(http) => {
let runtime = runtime()?;
let result = runtime.block_on(crate::fetch::discover_http(
&http.remote(None)?,
limits,
control,
));
if is_unauthorized_fetch(&result) {
let session = http.credentials(callbacks)?;
runtime.block_on(crate::fetch::discover_http(
&http.remote(Some(session))?,
limits,
control,
))
} else {
result
}
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
Endpoint::Ssh(ssh) => {
runtime()?.block_on(crate::fetch::discover_ssh(&ssh, limits, control))
}
};
let _ = &callbacks;
discovery
.map(|discovery| discovery.head)
.map_err(|error| TransferError::Fetch(Box::new(FetchWorkflowError::Transfer(error))))
}
pub fn fetch(
&self,
remote_name: &str,
options: &FetchOptions,
environment: &Environment,
callbacks: &mut dyn TransferCallbacks,
) -> Result<FetchOutcome, TransferError> {
let (endpoint, _) = self.endpoint(remote_name, Direction::Fetch, environment)?;
let objects = self
.objects(crate::PackLimits::trusted())
.map_err(TransferError::local)?;
let refs = self.references().map_err(TransferError::local)?;
let tips: Vec<ObjectId> = refs
.list()
.map_err(TransferError::local)?
.into_iter()
.filter_map(|reference| match reference.target {
crate::refs::Target::Direct(id) => Some(id),
crate::refs::Target::Symbolic(_) => None,
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let cancel = AtomicBool::new(false);
let limits = FetchLimits::trusted();
let known = if matches!(endpoint, Endpoint::Local(_)) {
KnownHistory::from_store_roots(objects, &tips, &cancel).map_err(|error| {
TransferError::Fetch(Box::new(FetchWorkflowError::Transfer(error)))
})?
} else {
KnownHistory::from_store(objects, &tips, limits, &cancel).map_err(|error| {
TransferError::Fetch(Box::new(FetchWorkflowError::Transfer(error)))
})?
};
let request = || self.fetch_request(options);
let control = TransportControl::new(&cancel);
let mut on_message = |message: &[u8]| {
callbacks.remote_message(message);
ControlFlow::Continue(())
};
let ready = match endpoint {
Endpoint::Local(path) => {
request()?.receive_local(path, &known, limits, control, &mut on_message)
}
#[cfg(feature = "http")]
Endpoint::Http(http) => {
let runtime = runtime()?;
let known = std::sync::Arc::new(known);
let receive = |remote: crate::transport::http::HttpRemote,
messages: &mut dyn FnMut(&[u8]) -> ControlFlow<()>|
-> Result<_, TransferError> {
let download = runtime.block_on(request()?.receive_http_with_progress(
&remote,
Some(known.clone()),
limits,
control,
|message| {
let _ = messages(message);
},
));
Ok(download)
};
let mut download = receive(http.remote(None)?, &mut on_message)?;
if matches!(
&download,
Err(FetchWorkflowError::Transfer(
crate::fetch::FetchError::Http(crate::transport::http::HttpError::Status(
401
))
))
) {
let session = http.credentials(callbacks)?;
download = receive(http.remote(Some(session))?, &mut |message| {
callbacks.remote_message(message);
ControlFlow::Continue(())
})?;
}
download
.and_then(|download| download.validate(&cancel, |_| ControlFlow::Continue(())))
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
Endpoint::Ssh(ssh) => {
let callbacks = std::cell::RefCell::new(&mut *callbacks);
let download = runtime()?.block_on(request()?.receive_ssh_with_progress(
&ssh,
Some(std::sync::Arc::new(known)),
limits,
control,
|message| callbacks.borrow_mut().transport_message(message),
|message| callbacks.borrow_mut().remote_message(message),
));
download
.and_then(|download| download.validate(&cancel, |_| ControlFlow::Continue(())))
}
}
.map_err(|error| TransferError::Fetch(Box::new(error)))?;
let report = ready
.finish(FetchUpdateLimits::trusted(), &cancel)
.map_err(|error| TransferError::FetchFinish(Box::new(error)))?;
Ok(FetchOutcome {
updated: report
.updates
.iter()
.filter(|update| {
!matches!(
update.kind,
FetchUpdateKind::SourceOnly | FetchUpdateKind::Unchanged
)
})
.filter_map(|update| {
Some(FetchedRef {
name: update.mapping.destination.clone()?,
old: update.previous,
new: update.mapping.source.as_ref().map(|source| source.id),
})
})
.collect(),
missing_sources: report.missing,
})
}
fn fetch_request(&self, options: &FetchOptions) -> Result<FetchRequest, TransferError> {
let specs = Refspecs::parse(Direction::Fetch, options.refspecs.iter())
.map_err(|error| TransferError::Configuration(error.to_string()))?;
let authorized: BTreeSet<RefName> = self
.references()
.map_err(TransferError::local)?
.list()
.map_err(TransferError::local)?
.into_iter()
.map(|reference| reference.name)
.collect();
let reflog = match &options.reflog {
None => Reflog::Preserve,
Some((committer, message)) => Reflog::AppendIfChanged {
committer: committer.clone(),
message: message.clone(),
},
};
let mut request = FetchRequest::prepare(self.clone(), specs, authorized, reflog)
.map_err(|error| TransferError::Fetch(Box::new(error.into())))?;
if options.prune {
request = request.with_prune();
#[expect(clippy::expect_used, reason = "constant reference name is valid")]
let namespaces = std::iter::once(RefName::new("refs/remotes").expect("valid name"))
.chain(options.tag_namespaces.iter().cloned());
for namespace in namespaces {
request = request.with_reflog_for_update_kind(
namespace,
FetchUpdateKind::Prune,
Reflog::Delete,
);
}
}
for namespace in &options.tag_namespaces {
request = request.with_tag_destination_namespace(namespace.clone());
}
if let Some(depth) = options.depth {
request = request.with_depth(depth);
}
Ok(request)
}
pub fn push(
&self,
remote_name: &str,
commands: Vec<PushCommand>,
options: &PushOptions,
environment: &Environment,
callbacks: &mut dyn TransferCallbacks,
) -> Result<PushReport, TransferError> {
self.push_observed(
remote_name,
commands,
options,
environment,
callbacks,
&|| {},
)
}
fn push_observed(
&self,
remote_name: &str,
commands: Vec<PushCommand>,
options: &PushOptions,
environment: &Environment,
callbacks: &mut dyn TransferCallbacks,
before_prepare: &dyn Fn(),
) -> Result<PushReport, TransferError> {
let (endpoint, _) = self.endpoint(remote_name, Direction::Push, environment)?;
let objects = self
.objects(crate::PackLimits::trusted())
.map_err(TransferError::local)?;
let format = objects.object_format();
let mut limits = PushLimits::trusted();
if let Some(compression) = options.compression {
limits.compression = compression;
}
let cancel = AtomicBool::new(false);
let control = TransportControl::new(&cancel);
let prepare = |roots: &[ObjectId]| -> Result<PreparedPush, PushFailure> {
before_prepare();
let prepared =
PreparedPush::new_local(&objects, commands.clone(), roots, limits, &cancel)?;
let prepared = if options.push_options.is_empty() {
prepared
} else {
prepared.with_push_options(options.push_options.clone())?
};
Ok(prepared.with_progress())
};
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
let names: Vec<RefName> = commands.iter().map(|c| c.name.clone()).collect();
let result = match endpoint {
Endpoint::Local(path) => {
let send = || {
crate::push::send_local_preparing(
&path,
format,
control,
crate::push::LocalPushContext {
config_inputs: &crate::config::ConfigInputs::default(),
identity: options.identity.as_ref(),
},
|advertisement| prepare(&advertisement.receiver_roots()?),
)
};
let mut result = send();
if is_knowledge_changed(&result) {
result = send();
}
if let Ok(report) = &result {
for message in &report.progress {
callbacks.remote_message(message);
}
}
result
}
#[cfg(feature = "http")]
Endpoint::Http(http) => {
let runtime = runtime()?;
let send = |remote: &crate::transport::http::HttpRemote,
callbacks: &mut dyn TransferCallbacks| {
let advertised = std::cell::RefCell::new(BTreeMap::new());
let result = runtime.block_on(crate::push::send_http_preparing(
remote,
format,
limits,
control,
|advertisement| {
record_targets(advertisement, &names, &advertised);
prepare(&advertisement.receiver_roots())
},
|message| callbacks.remote_message(message),
));
result.map(|mut report| {
mark_up_to_date(&mut report, &advertised.into_inner());
report
})
};
let remote = http.remote(None)?;
let mut result = send(&remote, callbacks);
if is_unauthorized_push(&result) {
let session = http.credentials(callbacks)?;
let remote = http.remote(Some(session))?;
result = send(&remote, callbacks);
}
result
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
Endpoint::Ssh(ssh) => {
let runtime = runtime()?;
let advertised = std::cell::RefCell::new(BTreeMap::new());
let callbacks = std::cell::RefCell::new(callbacks);
let result = runtime.block_on(crate::push::send_ssh_preparing(
&ssh,
format,
limits,
control,
|advertisement| {
record_targets(advertisement, &names, &advertised);
prepare(&advertisement.receiver_roots())
},
|message| callbacks.borrow_mut().transport_message(message),
|message| callbacks.borrow_mut().remote_message(message),
));
result.map(|mut report| {
mark_up_to_date(&mut report, &advertised.into_inner());
report
})
}
};
let report = result.map_err(|error| TransferError::Push(Box::new(error)))?;
self.update_tracking_refs(remote_name, &report, options.identity.as_ref());
Ok(report)
}
fn update_tracking_refs(
&self,
remote_name: &str,
report: &PushReport,
identity: Option<&crate::Signature>,
) {
let Ok(Some(remote)) =
crate::remote::ConfiguredRemote::find(self.config(), remote_name.as_bytes())
else {
return;
};
let Ok(refs) = self.references() else {
return;
};
let succeeded = report.unpack == Some(crate::push::Status::Ok);
for status in &report.refs {
if !succeeded || status.status != Some(crate::push::Status::Ok) {
continue;
}
let Some(name) = tracking_ref(remote.fetch_refspecs(), status.command.name.as_bytes())
else {
continue;
};
let deleted = status.command.deletes();
let edit = crate::refs::RefEdit {
name,
dereference: false,
target: (!deleted).then_some(crate::refs::Target::Direct(status.command.new)),
expected: crate::refs::Expected::Any,
reflog: match (deleted, identity) {
(true, _) => Reflog::Delete,
(false, Some(identity)) => Reflog::AppendIfChanged {
committer: identity.clone(),
message: b"update by push".to_vec(),
},
(false, None) => Reflog::Preserve,
},
};
let _ = refs.transaction(std::slice::from_ref(&edit));
}
}
}
fn tracking_ref(refspecs: &[crate::remote::ConfiguredRefspec], name: &[u8]) -> Option<RefName> {
use crate::remote::ConfiguredRefspecKind;
let matches = |pattern: &[u8]| -> Option<Vec<u8>> {
match crate::bytes::split_once(pattern, b'*') {
None => (pattern == name).then(Vec::new),
Some((prefix, suffix)) => name
.strip_prefix(prefix)?
.strip_suffix(suffix)
.map(<[u8]>::to_vec),
}
};
let excluded = refspecs.iter().any(|spec| {
spec.kind() == ConfiguredRefspecKind::Exclusion && spec.source().and_then(matches).is_some()
});
if excluded {
return None;
}
refspecs
.iter()
.filter(|spec| spec.kind() == ConfiguredRefspecKind::Mapping)
.find_map(|spec| {
let captured = matches(spec.source()?)?;
let destination = spec.destination()?;
let destination = match crate::bytes::split_once(destination, b'*') {
None => destination.to_vec(),
Some((prefix, suffix)) => [prefix, &captured, suffix].concat(),
};
RefName::new(destination).ok()
})
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
fn record_targets(
advertisement: &crate::push::PushAdvertisement,
names: &[RefName],
advertised: &std::cell::RefCell<BTreeMap<RefName, Option<ObjectId>>>,
) {
let mut advertised = advertised.borrow_mut();
for name in names {
advertised.insert(name.clone(), advertisement.target(name));
}
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
fn mark_up_to_date(report: &mut PushReport, advertised: &BTreeMap<RefName, Option<ObjectId>>) {
for status in &mut report.refs {
let Some(current) = advertised.get(&status.command.name) else {
continue;
};
let up_to_date = !status.command.deletes() && *current == Some(status.command.new);
if up_to_date && status.status != Some(crate::push::Status::Ok) {
status.status = Some(crate::push::Status::Ok);
status.rejection_origin = None;
}
}
}
fn is_knowledge_changed<T>(result: &Result<T, crate::push::PushError>) -> bool {
matches!(
result,
Err(crate::push::PushError::NotSent(
crate::push::PushFailure::KnowledgeChanged(_)
))
)
}
#[cfg(feature = "http")]
fn is_unauthorized_push<T>(result: &Result<T, crate::push::PushError>) -> bool {
matches!(
result,
Err(crate::push::PushError::NotSent(
crate::push::PushFailure::Http(crate::transport::http::HttpError::Status(401))
))
)
}
#[cfg(feature = "http")]
fn is_unauthorized_fetch<T>(result: &Result<T, crate::fetch::FetchError>) -> bool {
matches!(
result,
Err(crate::fetch::FetchError::Http(
crate::transport::http::HttpError::Status(401)
))
)
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
fn runtime() -> Result<tokio::runtime::Runtime, TransferError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(TransferError::local)
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::*;
use crate::push::{ForcePolicy, PushError};
fn git(directory: &Path, args: &[&str], date: usize) -> String {
let date = format!("@{} +0000", 1_700_000_000 + date * 60);
let output = Command::new("git")
.current_dir(directory)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Push Author")
.env("GIT_AUTHOR_EMAIL", "push@example.com")
.env("GIT_COMMITTER_NAME", "Push Author")
.env("GIT_COMMITTER_EMAIL", "push@example.com")
.env("GIT_AUTHOR_DATE", &date)
.env("GIT_COMMITTER_DATE", &date)
.args(args)
.output()
.expect("Git required for push fixtures");
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, ObjectId) {
let root = tempfile::tempdir().unwrap();
let work = root.path().join("work");
let dest = root.path().join("dest.git");
std::fs::create_dir(&work).unwrap();
std::fs::create_dir(&dest).unwrap();
git(&work, &["init", "-q", "--initial-branch=main", "."], 0);
git(&dest, &["init", "-q", "--bare", "."], 0);
git(&dest, &["config", "receive.unpackLimit", "1"], 0);
for n in 0..=30 {
if n == 30 {
git(&work, &["push", "-q", &dest.to_string_lossy(), "main"], n);
git(&work, &["switch", "-q", "-c", "topic"], n);
}
std::fs::write(work.join(format!("file-{n}")), format!("{n}\n")).unwrap();
git(&work, &["add", "."], n);
git(&work, &["commit", "-q", "-m", &n.to_string()], n);
}
git(
&work,
&["remote", "add", "origin", &dest.to_string_lossy()],
0,
);
let topic = git(&work, &["rev-parse", "topic"], 0).parse().unwrap();
(root, work, dest, topic)
}
fn push(
work: &Path,
topic: ObjectId,
before_prepare: &dyn Fn(),
) -> Result<PushReport, TransferError> {
Repository::open(work).unwrap().push_observed(
"origin",
vec![PushCommand {
name: RefName::new("refs/heads/topic").unwrap(),
expected: None,
new: topic,
force: ForcePolicy::FastForwardOnly,
}],
&PushOptions::default(),
&Environment::default(),
&mut NoCallbacks,
before_prepare,
)
}
fn pack_ids(dest: &Path) -> std::collections::BTreeMap<PathBuf, Vec<String>> {
std::fs::read_dir(dest.join("objects/pack"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|e| e == "idx"))
.map(|index| {
let output = Command::new("git")
.arg("show-index")
.stdin(std::fs::File::open(&index).unwrap())
.output()
.unwrap();
assert!(output.status.success());
let ids = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(|line| line.split_whitespace().nth(1).unwrap().to_owned())
.collect();
(index, ids)
})
.collect()
}
fn packed(dest: &Path) -> usize {
pack_ids(dest).values().map(Vec::len).sum()
}
#[test]
fn push_retry_after_knowledge_change_reuses_advertisement() {
let (_root, work, dest, topic) = fixture();
let before = pack_ids(&dest);
let base_tree = git(&work, &["rev-parse", "main~1^{tree}"], 0);
let attempts = Cell::new(0);
let report = push(&work, topic, &|| {
attempts.set(attempts.get() + 1);
if attempts.get() == 1 {
git(&dest, &["update-ref", "refs/heads/main", "main~1"], 0);
}
})
.unwrap();
assert!(report.all_succeeded(), "{report:?}");
assert_eq!(attempts.get(), 2);
let after = pack_ids(&dest);
let mut incoming: Vec<_> = after
.iter()
.filter(|(path, _)| !before.contains_key(*path))
.flat_map(|(_, ids)| ids.iter().cloned())
.collect();
let oracle = git(
&work,
&["rev-list", "--objects", "topic", "--not", "main~1"],
0,
);
let mut expected: Vec<_> = oracle
.lines()
.map(|line| line.split_whitespace().next().unwrap().to_owned())
.collect();
assert_eq!(expected.len(), 6);
expected.push(base_tree);
incoming.sort();
expected.sort();
assert_eq!(incoming, expected);
assert_eq!(git(&dest, &["rev-parse", "topic"], 0), topic.to_string());
git(&dest, &["fsck", "--strict", "--no-dangling"], 0);
}
#[test]
fn push_reports_a_second_knowledge_change_without_sending() {
let (_root, work, dest, topic) = fixture();
let before = packed(&dest);
let attempts = Cell::new(0);
let result = push(&work, topic, &|| {
attempts.set(attempts.get() + 1);
git(&dest, &["update-ref", "refs/heads/main", "main~1"], 0);
});
assert!(
matches!(
result,
Err(TransferError::Push(ref error))
if matches!(**error, PushError::NotSent(PushFailure::KnowledgeChanged(_)))
),
"{result:?}"
);
assert_eq!(attempts.get(), 2);
assert_eq!(packed(&dest), before);
assert!(git(&dest, &["for-each-ref", "refs/heads/topic"], 0).is_empty());
}
#[cfg(feature = "http")]
#[test]
fn native_store_accepts_plain_paths_and_leaves_shell_syntax_to_git() {
let environment = Environment::default()
.with("HOME", "/home/user")
.with_current_dir("/work");
let files = |text| native_store(text, &environment).map(|store| store.files().to_vec());
assert_eq!(
files("store --file=~/creds"),
Some(vec![PathBuf::from("/home/user/creds")])
);
assert_eq!(
files("store --file creds"),
Some(vec![PathBuf::from("/work/creds")])
);
assert_eq!(files("store").map(|files| files.len()), Some(2));
for text in [
"store --file=creds>out",
"store --file creds|tee",
"store --file=a;b",
"store --file=$HOME/creds",
"store --file='creds'",
"store --file=(creds)",
"store --file=a&",
"store --file=<in",
"store --other",
"store --file a b",
"store>out",
] {
assert!(files(text).is_none(), "{text}");
}
}
#[test]
fn tracking_ref_rejects_overlapping_pattern_halves() {
use crate::remote::{ConfiguredRefspec, Direction};
let refspecs =
[
ConfiguredRefspec::parse(Direction::Fetch, b"refs/heads/*/x:refs/remotes/o/*")
.unwrap(),
];
assert_eq!(tracking_ref(&refspecs, b"refs/heads/x"), None);
assert_eq!(
tracking_ref(&refspecs, b"refs/heads/a/x")
.unwrap()
.as_bytes(),
b"refs/remotes/o/a"
);
}
}