use std::collections::HashSet;
use std::io::{Read, Write};
use super::{
advertised_refs, check_object_format, head_info, parse_oid, read_packet, write_flush,
write_line, Packet, ProtocolVersion, Result, ServeError, ServeOptions,
};
use crate::config::ConfigSet;
use crate::hooks::{run_hook_in_git_dir, HookResult};
use crate::index_pack::{ingest_received_pack, IngestPackOptions};
use crate::objects::ObjectId;
use crate::receive_pack::{max_input_size_from_config, should_use_unpack_objects};
use crate::receive_quarantine::ReceiveQuarantine;
use crate::repo::Repository;
use crate::unpack_objects::{unpack_objects, UnpackOptions};
const CAPABILITIES: &[&str] = &[
"report-status",
"report-status-v2",
"delete-refs",
"side-band-64k",
"quiet",
"atomic",
"ofs-delta",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceivePolicy {
pub deny_non_fast_forwards: bool,
pub deny_deletes: bool,
pub deny_current_branch: bool,
}
impl Default for ReceivePolicy {
fn default() -> Self {
Self {
deny_non_fast_forwards: false,
deny_deletes: false,
deny_current_branch: true,
}
}
}
impl ReceivePolicy {
#[must_use]
pub fn from_config(config: &ConfigSet) -> Self {
let flag = |key: &str| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(false);
let deny_current_branch = match config.get("receive.denyCurrentBranch") {
None => true,
Some(v) => !matches!(
v.to_ascii_lowercase().as_str(),
"ignore" | "warn" | "false" | "no" | "off" | "0" | "updateinstead"
),
};
Self {
deny_non_fast_forwards: flag("receive.denyNonFastForwards"),
deny_deletes: flag("receive.denyDeletes"),
deny_current_branch,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefUpdateResult {
pub refname: String,
pub old: Option<ObjectId>,
pub new: Option<ObjectId>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunRecord {
pub name: String,
pub ran: bool,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReceiveOutcome {
pub unpack_error: Option<String>,
pub updates: Vec<RefUpdateResult>,
pub hooks: Vec<HookRunRecord>,
pub push_options: Vec<String>,
}
pub fn receive_pack(
repo: &Repository,
input: &mut dyn Read,
output: &mut dyn Write,
opts: &ServeOptions,
policy: &ReceivePolicy,
) -> Result<ReceiveOutcome> {
if opts.advertise_refs || !opts.stateless_rpc {
if opts.protocol == ProtocolVersion::V1 {
write_line(output, "version 1")?;
}
write_advertisement(repo, output, opts)?;
output.flush()?;
if opts.advertise_refs {
return Ok(ReceiveOutcome::default());
}
}
let Some(request) = read_commands(repo, input)? else {
return Ok(ReceiveOutcome::default());
};
let cfg = repo
.config()
.unwrap_or_else(|_| std::sync::Arc::new(ConfigSet::new()));
let max_input = max_input_size_from_config(cfg.as_ref());
let sideband = request.caps.contains("side-band-64k");
let needs_pack = request.commands.iter().any(|c| c.new.is_some());
let mut quarantine = if needs_pack {
Some(ReceiveQuarantine::create(repo.odb(), repo.git_dir())?)
} else {
None
};
let unpack_error = if needs_pack {
let Some(q) = quarantine.as_ref() else {
return Err(ServeError::Protocol("pack push requires quarantine".into()));
};
match read_push_pack(input, max_input) {
Ok(pack) => ingest_pushed_pack(&q.odb(), &pack, cfg.as_ref(), max_input)
.err()
.map(|e| e.to_string()),
Err(msg) => Some(msg),
}
} else {
None
};
let mut hook_records = Vec::new();
let updates = if unpack_error.is_some() {
request
.commands
.iter()
.map(|c| c.refused("unpacker error"))
.collect()
} else {
apply_commands_with_hooks(
repo,
&request,
opts,
policy,
quarantine.as_mut(),
sideband.then_some(&mut *output),
&mut hook_records,
)?
};
if request.caps.contains("report-status") || request.caps.contains("report-status-v2") {
let report = build_report(unpack_error.as_deref(), &updates)?;
if sideband {
crate::pkt_line::write_sideband_channel1_64k(&mut &mut *output, &report)?;
write_flush(output)?;
} else {
output.write_all(&report)?;
}
}
output.flush()?;
Ok(ReceiveOutcome {
unpack_error,
updates,
hooks: hook_records,
push_options: request.push_options.clone(),
})
}
fn write_advertisement(
repo: &Repository,
output: &mut dyn Write,
opts: &ServeOptions,
) -> Result<()> {
let mut caps: Vec<String> = CAPABILITIES.iter().map(|c| (*c).to_owned()).collect();
let config = repo
.config()
.unwrap_or_else(|_| std::sync::Arc::new(ConfigSet::new()));
if config
.get_bool("receive.advertisepushoptions")
.and_then(|r| r.ok())
.unwrap_or(false)
{
caps.push("push-options".to_owned());
}
caps.push(format!("object-format={}", repo.odb().hash_algo().name()));
if !opts.agent.is_empty() {
caps.push(format!("agent={}", opts.agent));
}
let caps = caps.join(" ");
let refs = advertised_refs(repo, &opts.hidden_refs)?;
if refs.is_empty() {
let zero = ObjectId::null(repo.odb().hash_algo());
write_line(
output,
&format!("{} capabilities^{{}}\0{caps}", zero.to_hex()),
)?;
}
for (i, r) in refs.iter().enumerate() {
if i == 0 {
write_line(output, &format!("{} {}\0{caps}", r.oid.to_hex(), r.name))?;
} else {
write_line(output, &format!("{} {}", r.oid.to_hex(), r.name))?;
}
}
write_flush(output)
}
struct Command {
old: Option<ObjectId>,
new: Option<ObjectId>,
refname: String,
}
impl Command {
fn refused(&self, reason: &str) -> RefUpdateResult {
RefUpdateResult {
refname: self.refname.clone(),
old: self.old,
new: self.new,
error: Some(reason.to_owned()),
}
}
fn accepted(&self) -> RefUpdateResult {
RefUpdateResult {
refname: self.refname.clone(),
old: self.old,
new: self.new,
error: None,
}
}
}
struct PushRequest {
commands: Vec<Command>,
caps: HashSet<String>,
push_options: Vec<String>,
}
fn read_commands(repo: &Repository, input: &mut dyn Read) -> Result<Option<PushRequest>> {
let mut commands = Vec::new();
let mut caps = HashSet::new();
loop {
match read_packet(input)? {
None | Some(Packet::Flush) => break,
Some(Packet::Data(line)) => {
let (line, line_caps) = line.split_once('\0').unwrap_or((line.as_str(), ""));
if commands.is_empty() {
caps.extend(
line_caps
.split(' ')
.filter(|c| !c.is_empty())
.map(str::to_owned),
);
}
let mut parts = line.splitn(3, ' ');
let (Some(old), Some(new), Some(refname)) =
(parts.next(), parts.next(), parts.next())
else {
return Err(ServeError::Protocol(format!("malformed command '{line}'")));
};
let old = parse_oid(repo, old)?;
let new = parse_oid(repo, new)?;
commands.push(Command {
old: (!old.is_zero()).then_some(old),
new: (!new.is_zero()).then_some(new),
refname: refname.to_owned(),
});
}
Some(other) => {
return Err(ServeError::Protocol(format!(
"unexpected packet in command list: {other:?}"
)))
}
}
}
if commands.is_empty() {
return Ok(None);
}
let mut push_options = Vec::new();
if caps.contains("push-options") {
loop {
match read_packet(input)? {
None | Some(Packet::Flush) => break,
Some(Packet::Data(line)) => {
push_options.push(line);
}
Some(other) => {
return Err(ServeError::Protocol(format!(
"unexpected packet in push options: {other:?}"
)))
}
}
}
}
let format = caps.iter().find_map(|c| c.strip_prefix("object-format="));
check_object_format(repo, format)?;
Ok(Some(PushRequest {
commands,
caps,
push_options,
}))
}
fn apply_commands_with_hooks(
repo: &Repository,
request: &PushRequest,
opts: &ServeOptions,
policy: &ReceivePolicy,
quarantine: Option<&mut ReceiveQuarantine>,
mut sideband_out: Option<&mut dyn Write>,
hook_records: &mut Vec<HookRunRecord>,
) -> Result<Vec<RefUpdateResult>> {
let lookup_odb = quarantine.as_ref().map(|q| q.odb());
let current_branch = if repo.is_bare() {
None
} else {
head_info(repo).target
};
let mut results: Vec<RefUpdateResult> = request
.commands
.iter()
.map(|c| {
match check_command(
repo,
lookup_odb.as_ref(),
c,
opts,
policy,
current_branch.as_deref(),
) {
Some(reason) => c.refused(reason),
None => c.accepted(),
}
})
.collect();
if request.caps.contains("atomic") && results.iter().any(|r| r.error.is_some()) {
for r in results.iter_mut().filter(|r| r.error.is_none()) {
r.error = Some("atomic transaction failed".to_owned());
}
return Ok(results);
}
let algo = repo.odb().hash_algo();
let push_option_env = push_option_env_owned(&request.push_options);
let mut pre_hook_env_owned = push_option_env.clone();
if let Some(q) = quarantine.as_ref() {
pre_hook_env_owned.extend(q.hook_env());
}
let pre_hook_env: Vec<(&str, &str)> = pre_hook_env_owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let update_hook_env: &[(&str, &str)] = &[];
let post_receive_env_owned = push_option_env;
let post_receive_env: Vec<(&str, &str)> = post_receive_env_owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let all_cmds: Vec<&Command> = request.commands.iter().collect();
let pre_stdin = commands_stdin(&all_cmds, algo);
let (pre_result, pre_out) = run_hook_in_git_dir(
repo,
"pre-receive",
&[],
Some(pre_stdin.as_bytes()),
&pre_hook_env,
);
relay_hook_sideband(&mut sideband_out, &pre_out)?;
hook_records.push(hook_record("pre-receive", &pre_result));
if matches!(pre_result, HookResult::Failed(_)) {
for r in results.iter_mut().filter(|r| r.error.is_none()) {
r.error = Some("pre-receive hook declined".to_owned());
}
return Ok(results);
}
if results.iter().all(|r| r.error.is_some()) {
return Ok(results);
}
if let Some(q) = quarantine {
q.migrate().map_err(ServeError::Repository)?;
repo.odb().invalidate_packs();
}
for (cmd, result) in request.commands.iter().zip(results.iter_mut()) {
if result.error.is_some() {
continue;
}
let zero = null_oid_hex(algo);
let old_hex = cmd.old.map(|o| o.to_hex()).unwrap_or_else(|| zero.clone());
let new_hex = cmd.new.map(|o| o.to_hex()).unwrap_or(zero.clone());
let args = [cmd.refname.as_str(), old_hex.as_str(), new_hex.as_str()];
let (update_result, update_out) =
run_hook_in_git_dir(repo, "update", &args, None, update_hook_env);
relay_hook_sideband(&mut sideband_out, &update_out)?;
hook_records.push(hook_record("update", &update_result));
if matches!(update_result, HookResult::Failed(_)) {
result.error = Some("hook declined".to_owned());
}
}
if request.caps.contains("atomic") && results.iter().any(|r| r.error.is_some()) {
for r in results.iter_mut().filter(|r| r.error.is_none()) {
r.error = Some("atomic transaction failed".to_owned());
}
return Ok(results);
}
let pending: Vec<(usize, crate::refs::store::RefUpdate)> = request
.commands
.iter()
.zip(results.iter())
.enumerate()
.filter(|(_, (_, r))| r.error.is_none())
.map(|(idx, (cmd, _))| (idx, receive_command_store_update(cmd)))
.collect();
if request.caps.contains("atomic") {
let batch: Vec<_> = pending.iter().map(|(_, u)| u.clone()).collect();
if let Err(e) = crate::refs::commit_ref_store_batch(repo.git_dir(), &batch) {
let msg = format!("failed to update ref: {e}");
for (idx, _) in pending {
if results[idx].error.is_none() {
results[idx].error = Some(msg.clone());
}
}
}
} else {
for (idx, update) in pending {
if let Err(e) = crate::refs::commit_store_update(repo.git_dir(), update) {
results[idx].error = Some(format!("failed to update ref: {e}"));
}
}
}
let successful: Vec<&Command> = request
.commands
.iter()
.zip(results.iter())
.filter(|(_, r)| r.error.is_none())
.map(|(c, _)| c)
.collect();
if !successful.is_empty() {
let post_stdin = commands_stdin(&successful, algo);
let (post_result, post_out) = run_hook_in_git_dir(
repo,
"post-receive",
&[],
Some(post_stdin.as_bytes()),
&post_receive_env,
);
relay_hook_sideband(&mut sideband_out, &post_out)?;
hook_records.push(hook_record("post-receive", &post_result));
let mut post_update_args: Vec<&str> = Vec::new();
for result in results.iter().filter(|r| r.error.is_none()) {
post_update_args.push(result.refname.as_str());
}
let (pu_result, pu_out) = run_hook_in_git_dir(
repo,
"post-update",
&post_update_args,
None,
update_hook_env,
);
relay_hook_sideband(&mut sideband_out, &pu_out)?;
hook_records.push(hook_record("post-update", &pu_result));
}
Ok(results)
}
fn hook_record(name: &str, result: &HookResult) -> HookRunRecord {
HookRunRecord {
name: name.to_owned(),
ran: result.was_executed(),
exit_code: match result {
HookResult::Failed(code) => Some(*code),
HookResult::Success => Some(0),
HookResult::NotFound => None,
},
}
}
fn push_option_env_owned(options: &[String]) -> Vec<(String, String)> {
let mut pairs = vec![(
"GIT_PUSH_OPTION_COUNT".to_owned(),
options.len().to_string(),
)];
for (i, opt) in options.iter().enumerate() {
pairs.push((format!("GIT_PUSH_OPTION_{i}"), opt.clone()));
}
pairs
}
fn commands_stdin(commands: &[&Command], algo: crate::objects::HashAlgo) -> String {
let zero = null_oid_hex(algo);
commands
.iter()
.map(|c| {
format!(
"{} {} {}\n",
c.old.map(|o| o.to_hex()).unwrap_or_else(|| zero.clone()),
c.new.map(|o| o.to_hex()).unwrap_or_else(|| zero.clone()),
c.refname
)
})
.collect()
}
fn null_oid_hex(algo: crate::objects::HashAlgo) -> String {
ObjectId::null(algo).to_hex()
}
fn receive_command_store_update(cmd: &Command) -> crate::refs::store::RefUpdate {
let mut flags = crate::refs::store::RefUpdateFlags::default();
if cmd.refname == "HEAD" {
flags.no_deref = true;
}
crate::refs::store::RefUpdate {
name: cmd.refname.clone(),
new_value: cmd.new.map(crate::refs::store::RawRef::Direct),
expected: match cmd.old {
Some(oid) => crate::refs::store::Expected::Oid(oid),
None => crate::refs::store::Expected::Missing,
},
reflog: None,
flags,
}
}
fn relay_hook_sideband(out: &mut Option<&mut dyn Write>, captured: &[u8]) -> Result<()> {
let Some(w) = out.as_mut() else {
return Ok(());
};
if captured.is_empty() {
return Ok(());
}
for chunk in captured.chunks(65515) {
crate::pkt_line::write_sideband_packet(*w, 2, chunk).map_err(ServeError::Io)?;
}
Ok(())
}
fn check_command(
repo: &Repository,
lookup: Option<&crate::odb::Odb>,
cmd: &Command,
opts: &ServeOptions,
policy: &ReceivePolicy,
current_branch: Option<&str>,
) -> Option<&'static str> {
if !cmd.refname.starts_with("refs/") || !crate::refs::is_valid_storable_ref_name(&cmd.refname) {
return Some("funny refname");
}
if crate::hide_refs::ref_is_hidden(&cmd.refname, &cmd.refname, &opts.hidden_refs) {
return Some("deny updating a hidden ref");
}
let current = crate::refs::resolve_ref(repo.git_dir(), &cmd.refname).ok();
if current != cmd.old {
return Some("stale info");
}
let is_current_branch = current_branch == Some(cmd.refname.as_str());
let Some(new) = cmd.new else {
if policy.deny_deletes {
return Some("deletion prohibited");
}
if is_current_branch && policy.deny_current_branch {
return Some("deletion of the current branch prohibited");
}
return None;
};
let odb = lookup.unwrap_or(repo.odb());
if !odb.exists(&new) {
return Some("missing necessary objects");
}
if is_current_branch && policy.deny_current_branch {
return Some("branch is currently checked out");
}
if let (Some(old), true) = (current, policy.deny_non_fast_forwards) {
let fast_forward = crate::merge_base::is_ancestor(repo, old, new).unwrap_or(false);
if !fast_forward {
return Some("non-fast-forward");
}
}
None
}
fn build_report(unpack_error: Option<&str>, updates: &[RefUpdateResult]) -> Result<Vec<u8>> {
let mut report = Vec::new();
match unpack_error {
None => write_line(&mut report, "unpack ok")?,
Some(e) => write_line(&mut report, &format!("unpack {}", one_line(e)))?,
}
for u in updates {
match &u.error {
None => write_line(&mut report, &format!("ok {}", u.refname))?,
Some(reason) => write_line(
&mut report,
&format!("ng {} {}", u.refname, one_line(reason)),
)?,
}
}
write_flush(&mut report)?;
Ok(report)
}
fn one_line(msg: &str) -> String {
msg.lines().next().unwrap_or_default().trim().to_owned()
}
fn read_push_pack(
input: &mut dyn Read,
max_bytes: Option<u64>,
) -> std::result::Result<Vec<u8>, String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 64 * 1024];
loop {
let n = input.read(&mut chunk).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
if let Some(limit) = max_bytes {
let next = buf.len().saturating_add(n);
if next as u64 > limit {
return Err("pack exceeds maximum input size".to_owned());
}
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
fn ingest_pushed_pack(
odb: &crate::odb::Odb,
pack: &[u8],
cfg: &ConfigSet,
max_input: Option<u64>,
) -> Result<()> {
if should_use_unpack_objects(pack, cfg) {
let mut cursor = std::io::Cursor::new(pack);
unpack_objects(
&mut cursor,
odb,
&UnpackOptions {
quiet: true,
strict: true,
max_input_bytes: max_input,
..UnpackOptions::default()
},
)?;
} else {
ingest_received_pack(
pack.to_vec(),
odb,
&IngestPackOptions {
fix_thin: true,
..Default::default()
},
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_lists_unpack_status_then_each_ref() {
let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
.unwrap_or_else(|_| ObjectId::zero());
let updates = vec![
RefUpdateResult {
refname: "refs/heads/main".into(),
old: None,
new: Some(oid),
error: None,
},
RefUpdateResult {
refname: "refs/heads/topic".into(),
old: Some(oid),
new: None,
error: Some("deletion prohibited".into()),
},
];
let report = build_report(None, &updates).unwrap_or_default();
assert_eq!(
String::from_utf8_lossy(&report),
"000eunpack ok\n0017ok refs/heads/main\n002cng refs/heads/topic deletion prohibited\n0000"
);
}
#[test]
fn deny_current_branch_values() {
let policy_for = |value: Option<&str>| {
let mut config = ConfigSet::new();
if let Some(v) = value {
let _ = config.add_command_override("receive.denyCurrentBranch", v);
}
ReceivePolicy::from_config(&config).deny_current_branch
};
assert!(policy_for(None));
assert!(!policy_for(Some("ignore")));
assert!(!policy_for(Some("updateInstead")));
assert!(policy_for(Some("refuse")));
}
}