use std::collections::{HashMap, HashSet};
use std::io;
use std::sync::Arc;
use futures::io::AsyncReadExt;
use gix_hash::ObjectId;
use tokio::sync::Semaphore;
use crate::backend::{GitPackIngest, GitRefs};
use crate::duplex::{RawTail, Request};
use crate::fetch::{storage_to_session_error, ADVERTISED_OBJECT_FORMATS};
use crate::limits::Limits;
use crate::pack_source::{bridge_pack_source, PackSourceBridge};
use crate::refname::validate_ref_name;
use crate::registry::RepoRecord;
use crate::seam::{PushOptions, RefOutcome, RefUpdate};
use crate::substrate::SubstrateError;
use crate::v2::PktLineWriter;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PushRequest {
pub commands: Vec<RefUpdate>,
pub capabilities: PushCapabilities,
pub options: Option<PushOptions>,
}
impl PushRequest {
pub fn is_nothing_to_do(&self) -> bool {
self.commands.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PushCapabilities {
pub report_status: bool,
pub report_status_v2: bool,
pub delete_refs: bool,
pub side_band_64k: bool,
pub atomic: bool,
pub ofs_delta: bool,
pub push_options: bool,
pub quiet: bool,
pub agent: Option<String>,
}
pub fn parse_push_request(
request: &Request,
push_options_negotiated: bool,
) -> Result<PushRequest, SubstrateError> {
if request.pre_lines.is_empty() {
return Ok(PushRequest::default());
}
let mut commands: Vec<RefUpdate> = Vec::new();
let mut seen_refs: HashSet<String> = HashSet::new();
let mut capabilities = PushCapabilities::default();
let mut object_format: Option<&str> = None;
for (index, line) in request.pre_lines.iter().enumerate() {
let (command_text, caps_text) = match line.split_once('\0') {
Some((command_text, caps_text)) => {
if index != 0 {
return Err(SubstrateError::Protocol(format!(
"push: line {index} carries a caps string — caps ride the first command line only"
)));
}
(command_text, caps_text)
}
None => (line.as_str(), ""),
};
if index == 0 {
parse_capabilities(
caps_text,
push_options_negotiated,
&mut capabilities,
&mut object_format,
)?;
}
if command_text == "shallow" || command_text.starts_with("shallow ") {
return Err(SubstrateError::Protocol(
"push: shallow update is not served — rejected up front (ADR-013 §4)".to_owned(),
));
}
let command = parse_command(command_text, object_format_hex_size(object_format))?;
if !seen_refs.insert(command.name.clone()) {
return Err(SubstrateError::Protocol(format!(
"push: duplicate ref commands for {:?} in one push — fail closed",
command.name
)));
}
commands.push(command);
}
let options = if capabilities.push_options {
Some(parse_push_options(&request.args)?)
} else if !request.args.is_empty() {
return Err(SubstrateError::Protocol(
"push: post-command lines where no push-options section was negotiated — fail closed"
.to_owned(),
));
} else {
None
};
Ok(PushRequest {
commands,
capabilities,
options,
})
}
fn parse_capabilities<'a>(
caps_text: &'a str,
push_options_negotiated: bool,
parsed: &mut PushCapabilities,
object_format: &mut Option<&'a str>,
) -> Result<(), SubstrateError> {
for token in caps_text.split_whitespace() {
match token {
"report-status" => set_flag(&mut parsed.report_status, token)?,
"report-status-v2" => set_flag(&mut parsed.report_status_v2, token)?,
"delete-refs" => set_flag(&mut parsed.delete_refs, token)?,
"side-band-64k" => set_flag(&mut parsed.side_band_64k, token)?,
"atomic" => set_flag(&mut parsed.atomic, token)?,
"ofs-delta" => set_flag(&mut parsed.ofs_delta, token)?,
"quiet" => set_flag(&mut parsed.quiet, token)?,
"push-options" => {
if !push_options_negotiated {
return Err(SubstrateError::Protocol(
"push: push-options was not advertised — the selection is out of grammar"
.to_owned(),
));
}
set_flag(&mut parsed.push_options, token)?;
}
other => {
if let Some(agent) = other.strip_prefix("agent=") {
if agent.is_empty() {
return Err(SubstrateError::Protocol(format!(
"push: {other:?} is an invalid agent capability line"
)));
}
if parsed.agent.is_some() {
return Err(SubstrateError::Protocol(format!(
"push: duplicate capability {other:?}"
)));
}
parsed.agent = Some(agent.to_owned());
} else if let Some(format) = other.strip_prefix("object-format=") {
if object_format.is_some() {
return Err(SubstrateError::Protocol(
"push: repeated object-format capability".to_owned(),
));
}
if !ADVERTISED_OBJECT_FORMATS.contains(&format) {
return Err(SubstrateError::Protocol(format!(
"push: object-format {format:?} was not advertised — mismatch rejected (transport.md §fetch)"
)));
}
*object_format = Some(format);
} else {
return Err(SubstrateError::Protocol(format!(
"push: unknown capability {other:?} — fail closed"
)));
}
}
}
}
Ok(())
}
fn set_flag(flag: &mut bool, token: &str) -> Result<(), SubstrateError> {
if *flag {
return Err(SubstrateError::Protocol(format!(
"push: duplicate capability {token:?}"
)));
}
*flag = true;
Ok(())
}
fn object_format_hex_size(object_format: Option<&str>) -> usize {
if object_format == Some("sha256") {
64
} else {
40
}
}
fn parse_command(line: &str, hex_size: usize) -> Result<RefUpdate, SubstrateError> {
let mut fields = line.splitn(3, ' ');
let old_hex = fields.next().unwrap_or_default();
let new_hex = fields.next().unwrap_or_default();
let name = fields.next().unwrap_or_default();
if old_hex.is_empty() || new_hex.is_empty() || name.is_empty() {
return Err(SubstrateError::Protocol(format!(
"push: {line:?} is a malformed command line — <old> <new> <ref> expected"
)));
}
let old = parse_oid(old_hex, hex_size, "old", line)?;
let new = parse_oid(new_hex, hex_size, "new", line)?;
let update = match (old.is_null(), new.is_null()) {
(true, true) => {
return Err(SubstrateError::Protocol(format!(
"push: {line:?} carries the zero id on both sides — out of grammar"
)))
}
(true, false) => RefUpdate {
name: name.to_owned(),
expected: None,
new: Some(new),
},
(false, true) => RefUpdate {
name: name.to_owned(),
expected: Some(old),
new: None,
},
(false, false) => RefUpdate {
name: name.to_owned(),
expected: Some(old),
new: Some(new),
},
};
Ok(update)
}
fn parse_oid(hex: &str, size: usize, what: &str, line: &str) -> Result<ObjectId, SubstrateError> {
if hex.len() != size {
return Err(SubstrateError::Protocol(format!(
"push: {line:?} carries a malformed {what} oid {hex:?} — {size} hex characters expected"
)));
}
ObjectId::from_hex(hex.as_bytes()).map_err(|error| {
SubstrateError::Protocol(format!(
"push: {line:?} carries a malformed {what} oid {hex:?}: {error}"
))
})
}
fn parse_push_options(lines: &[String]) -> Result<PushOptions, SubstrateError> {
let mut pairs = Vec::with_capacity(lines.len());
for line in lines {
if line.is_empty() {
return Err(SubstrateError::Protocol(
"push: an empty push-option line is out of grammar".to_owned(),
));
}
let pair = match line.split_once('=') {
Some((key, value)) => (key.to_owned(), value.to_owned()),
None => (line.to_owned(), String::new()),
};
pairs.push(pair);
}
Ok(PushOptions { pairs })
}
pub const UNPACKER_ERROR_REASON: &str = "unpacker error";
pub const ATOMIC_PUSH_FAILURE_REASON: &str = "atomic push failure";
const PACK_HEADER_BYTES: usize = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PackTailDiscipline {
Duplex,
Stateless,
}
impl PackTailDiscipline {
fn peek_window(self, limits: &Limits) -> Option<std::time::Duration> {
match self {
PackTailDiscipline::Duplex => Some(limits.packless_peek),
PackTailDiscipline::Stateless => None,
}
}
fn drains_packless(self) -> bool {
matches!(self, PackTailDiscipline::Duplex)
}
fn discipline_name(self) -> &'static str {
match self {
PackTailDiscipline::Duplex => "duplex",
PackTailDiscipline::Stateless => "stateless",
}
}
}
enum PackObservation {
Header(Vec<u8>),
Packless,
Malformed(usize),
}
fn packless_or_malformed(observed: usize) -> PackObservation {
if observed == 0 {
PackObservation::Packless
} else {
PackObservation::Malformed(observed)
}
}
async fn peek_pack_header<R>(
tail: &mut RawTail<R>,
window: Option<std::time::Duration>,
) -> Result<PackObservation, SubstrateError>
where
R: futures::io::AsyncRead + Unpin,
{
let deadline = window.map(|window| tokio::time::Instant::now() + window);
let mut observed = Vec::with_capacity(PACK_HEADER_BYTES);
let mut scratch = [0u8; 1];
loop {
if observed.len() == PACK_HEADER_BYTES {
return Ok(PackObservation::Header(observed));
}
let read = tail.read(&mut scratch);
let read = match deadline {
Some(deadline) => match tokio::time::timeout_at(deadline, read).await {
Ok(outcome) => outcome,
Err(_elapsed) => return Ok(packless_or_malformed(observed.len())),
},
None => read.await,
};
match read {
Ok(0) => return Ok(packless_or_malformed(observed.len())),
Ok(n) => observed.extend_from_slice(&scratch[..n]),
Err(error) => return Err(SubstrateError::Io(error)),
}
}
}
async fn drain_pack_tail<R>(tail: &mut RawTail<R>) -> Result<(), SubstrateError>
where
R: futures::io::AsyncRead + Unpin,
{
let mut scratch = vec![0u8; 8192];
loop {
match tail.read(&mut scratch).await {
Ok(0) => return Ok(()),
Ok(_) => continue,
Err(error) => return Err(SubstrateError::Io(error)),
}
}
}
async fn write_report_maybe(
writer: &mut impl PktLineWriter,
unpack: &Result<(), String>,
outcomes: &[RefOutcome],
sideband: bool,
wants_report: bool,
) -> Result<(), SubstrateError> {
if !wants_report {
return Ok(());
}
crate::v0::write_status_report(writer, unpack, outcomes, sideband).await
}
fn merge_cas_outcomes(outcomes: &mut [RefOutcome], cas: Vec<RefOutcome>) {
let mut by_name: HashMap<String, RefOutcome> = cas
.into_iter()
.map(|outcome| (outcome.name.clone(), outcome))
.collect();
for outcome in outcomes.iter_mut() {
if outcome.result.is_ok() {
*outcome = by_name.remove(&outcome.name).unwrap_or_else(|| RefOutcome {
name: outcome.name.clone(),
result: Err(format!(
"the ref CAS returned no outcome for {}; fail closed",
outcome.name
)),
});
}
}
}
async fn apply_and_report(
writer: &mut impl PktLineWriter,
updates: &[RefUpdate],
refs: Arc<dyn GitRefs>,
repo: &RepoRecord,
atomic: bool,
sideband: bool,
wants_report: bool,
) -> Result<(), SubstrateError> {
let mut outcomes: Vec<RefOutcome> = Vec::with_capacity(updates.len());
let mut valid: Vec<RefUpdate> = Vec::with_capacity(updates.len());
let mut rejected = false;
for update in updates {
match validate_ref_name(&update.name) {
Ok(()) => {
outcomes.push(RefOutcome {
name: update.name.clone(),
result: Ok(()),
});
valid.push(update.clone());
}
Err(reason) => {
rejected = true;
outcomes.push(RefOutcome {
name: update.name.clone(),
result: Err(reason),
});
}
}
}
if atomic && rejected {
for outcome in &mut outcomes {
if outcome.result.is_ok() {
outcome.result = Err(ATOMIC_PUSH_FAILURE_REASON.to_owned());
}
}
} else if !valid.is_empty() {
let cas = refs
.apply_updates(repo, valid, atomic)
.await
.map_err(storage_to_session_error)?;
merge_cas_outcomes(&mut outcomes, cas);
}
write_report_maybe(writer, &Ok(()), &outcomes, sideband, wants_report).await
}
#[allow(clippy::too_many_arguments)]
pub async fn serve_receive_pack<R>(
writer: &mut impl PktLineWriter,
request: &Request,
tail: RawTail<R>,
push_options_negotiated: bool,
refs: Arc<dyn GitRefs>,
ingest: Arc<dyn GitPackIngest>,
repo: &RepoRecord,
limits: &Limits,
tail_discipline: PackTailDiscipline,
pipeline: Arc<Semaphore>,
) -> Result<(), SubstrateError>
where
R: futures::io::AsyncRead + Unpin + Send + 'static,
{
let parsed = parse_push_request(request, push_options_negotiated)?;
let wants_report = parsed.capabilities.report_status || parsed.capabilities.report_status_v2;
let sideband = parsed.capabilities.side_band_64k;
let atomic = parsed.capabilities.atomic;
if parsed.is_nothing_to_do() {
return writer.write_flush().await;
}
let mut tail = tail;
let observation = peek_pack_header(&mut tail, tail_discipline.peek_window(limits)).await?;
match observation {
PackObservation::Packless => {
apply_and_report(
writer,
&parsed.commands,
refs,
repo,
atomic,
sideband,
wants_report,
)
.await?;
if tail_discipline.drains_packless() {
drain_pack_tail(&mut tail).await?;
}
Ok(())
}
PackObservation::Malformed(observed) => Err(SubstrateError::Protocol(format!(
"push: only {observed} of {PACK_HEADER_BYTES} pack-header bytes reached the peek window ({}) — a half-sent pack cannot be re-interpreted as absence: malformed push, session error (ADR-020 §1 rule (iii))",
tail_discipline.discipline_name()
))),
PackObservation::Header(header) => {
use futures::io::AsyncReadExt as _;
let permit = pipeline.clone().acquire_owned().await.map_err(|_| {
SubstrateError::Protocol("the pipeline-concurrency semaphore is closed".to_owned())
})?;
let PackSourceBridge { end, fault, relay } = bridge_pack_source(
futures::io::Cursor::new(header).chain(tail),
limits,
);
let handle = tokio::runtime::Handle::current();
let owned_repo = repo.clone();
let owned_limits = *limits;
let updates = parsed.commands;
let push_options = parsed.options;
let prepared = tokio::task::spawn_blocking(move || {
let _permit = permit;
handle.block_on(ingest.prepare(
&owned_repo,
updates,
Box::new(end),
push_options,
owned_limits,
))
})
.await
.map_err(|join| {
SubstrateError::Io(io::Error::other(format!(
"the pack ingestion task failed: {join}"
)))
})?
.map_err(storage_to_session_error)?;
if let Some(failure) = fault.take_failure() {
return Err(failure);
}
match &prepared.unpack {
Ok(()) => {
apply_and_report(
writer,
&prepared.updates,
refs,
repo,
atomic,
sideband,
wants_report,
)
.await?
}
Err(_) => {
let outcomes: Vec<RefOutcome> = prepared
.updates
.iter()
.map(|update| RefOutcome {
name: update.name.clone(),
result: Err(UNPACKER_ERROR_REASON.to_owned()),
})
.collect();
write_report_maybe(writer, &prepared.unpack, &outcomes, sideband, wants_report)
.await?
}
}
relay.await.map_err(|join| {
SubstrateError::Io(io::Error::other(format!(
"the pack-source relay task failed: {join}"
)))
})?;
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duplex::Request;
fn zero() -> String {
"0".repeat(40)
}
fn hex_oid(b: u8) -> String {
let mut hex = String::with_capacity(40);
for _ in 0..20 {
hex.push_str(&format!("{b:02x}"));
}
hex
}
fn request(pre_lines: &[&str], args: &[&str]) -> Request {
Request {
command: "git-receive-pack".to_owned(),
pre_lines: pre_lines.iter().map(|l| l.to_string()).collect(),
args: args.iter().map(|l| l.to_string()).collect(),
}
}
fn parse(
request: &Request,
push_options_negotiated: bool,
) -> Result<PushRequest, SubstrateError> {
parse_push_request(request, push_options_negotiated)
}
fn protocol_error(result: Result<PushRequest, SubstrateError>) -> String {
match result {
Err(SubstrateError::Protocol(message)) => message,
Ok(_) => panic!("expected a protocol error"),
Err(other) => panic!("expected a protocol error, got {other:?}"),
}
}
#[test]
fn ordinary_commands_parse_into_the_zero_id_shapes() {
let parsed = parse(
&request(
&[
&format!(
"{} {} refs/heads/create\0report-status ofs-delta",
zero(),
hex_oid(0x0a)
),
&format!("{} {} refs/heads/update", hex_oid(0x1b), hex_oid(0x2c)),
&format!("{} {} refs/heads/delete", hex_oid(0x3d), zero()),
],
&[],
),
false,
)
.unwrap();
assert_eq!(
parsed.commands,
vec![
RefUpdate {
name: "refs/heads/create".to_owned(),
expected: None,
new: Some(ObjectId::from([0x0a; 20]))
},
RefUpdate {
name: "refs/heads/update".to_owned(),
expected: Some(ObjectId::from([0x1b; 20])),
new: Some(ObjectId::from([0x2c; 20]))
},
RefUpdate {
name: "refs/heads/delete".to_owned(),
expected: Some(ObjectId::from([0x3d; 20])),
new: None,
}
]
);
assert!(parsed.capabilities.report_status);
assert!(parsed.capabilities.ofs_delta);
assert!(!parsed.capabilities.report_status_v2);
assert_eq!(parsed.options, None);
assert!(!parsed.is_nothing_to_do());
}
#[test]
fn the_capture_shape_caps_selection_parses() {
let parsed = parse(
&request(
&[&format!(
"{} {} refs/heads/dev\0 report-status-v2 side-band-64k quiet object-format=sha1 agent=git/2.43.0",
zero(),
hex_oid(0x0a)
)],
&[],
),
false,
)
.unwrap();
assert!(parsed.capabilities.report_status_v2);
assert!(parsed.capabilities.side_band_64k);
assert!(parsed.capabilities.quiet);
assert!(!parsed.capabilities.ofs_delta);
assert_eq!(parsed.capabilities.agent.as_deref(), Some("git/2.43.0"));
}
#[test]
fn an_immediate_flush_is_the_nothing_to_do_shape() {
let parsed = parse(&request(&[], &[]), false).unwrap();
assert!(parsed.is_nothing_to_do());
assert!(parsed.commands.is_empty());
assert_eq!(parsed.capabilities, PushCapabilities::default());
assert_eq!(parsed.options, None);
}
#[test]
fn caps_ride_the_first_command_line_only() {
let message = protocol_error(parse(
&request(
&[
&format!(
"{} {} refs/heads/main\0report-status",
zero(),
hex_oid(0x0a)
),
&format!(
"{} {} refs/heads/other\0quiet",
hex_oid(0x1b),
hex_oid(0x2c)
),
],
&[],
),
false,
));
assert!(message.contains("first command line"), "{message}");
}
#[test]
fn unknown_and_duplicate_caps_and_empty_agent_fail_closed() {
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0push-cert",
zero(),
hex_oid(0x0a)
)],
&[],
),
false,
));
assert!(message.contains("unknown capability"), "{message}");
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0report-status report-status",
zero(),
hex_oid(0x0a)
)],
&[],
),
false,
));
assert!(message.contains("duplicate capability"), "{message}");
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0agent=",
zero(),
hex_oid(0x0a)
)],
&[],
),
false,
));
assert!(message.contains("agent"), "{message}");
}
#[test]
fn the_object_format_mismatch_is_rejected_and_drives_the_oid_size() {
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0object-format=sha1024",
zero(),
hex_oid(0x0a)
)],
&[],
),
false,
));
assert!(message.contains("object-format"), "{message}");
let long = hex_oid(0x0a).repeat(2)[..64].to_owned();
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0object-format=sha1",
zero(),
long
)],
&[],
),
false,
));
assert!(message.contains("40 hex"), "{message}");
}
#[cfg(feature = "sha256")]
#[test]
fn the_advertised_sha256_format_is_accepted_under_the_feature() {
let long = "0".repeat(64);
let new = hex_oid(0x0a).repeat(2)[..64].to_owned();
let parsed = parse(
&request(
&[&format!(
"{long} {new} refs/heads/main\0object-format=sha256"
)],
&[],
),
false,
)
.unwrap();
match &parsed.commands[0] {
RefUpdate {
expected: None,
new: Some(new),
..
} => assert_eq!(new.as_bytes().len(), 32),
other => panic!("expected a create, got {other:?}"),
}
}
#[test]
fn shallow_lines_are_rejected_up_front() {
let shallow = String::from("shallow ") + &hex_oid(0x0a);
for lines in [
vec![
shallow.clone(),
format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
],
vec![
format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
shallow,
],
vec![String::from("shallow")],
] {
let borrowed: Vec<&str> = lines.iter().map(String::as_str).collect();
let message = protocol_error(parse(&request(&borrowed, &[]), false));
assert!(message.contains("shallow"), "{message}");
}
}
#[test]
fn malformed_command_lines_fail_closed() {
for line in [
format!("{} {} ", zero(), hex_oid(0x0a)),
format!("{} refs/heads/short", zero()),
"zz zz refs/heads/badhex".to_owned(),
"\0report-status".to_owned(),
format!("{} {} refs/heads/main", zero(), String::from("0")),
] {
let message = protocol_error(parse(&request(&[&line], &[]), false));
assert!(
message.contains("malformed") || message.contains("carries the zero id"),
"{message}"
);
}
}
#[test]
fn duplicate_ref_commands_fail_closed() {
let message = protocol_error(parse(
&request(
&[
&format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
&format!("{} {} refs/heads/main", hex_oid(0x1b), hex_oid(0x2c)),
],
&[],
),
false,
));
assert!(message.contains("duplicate ref commands"), "{message}");
}
#[test]
fn the_push_options_section_parses_only_when_negotiated() {
let command = format!(
"{} {} refs/heads/main\0report-status push-options",
zero(),
hex_oid(0x0a)
);
let commands = [command.as_str()];
let message = protocol_error(parse(&request(&commands, &[]), false));
assert!(message.contains("not advertised"), "{message}");
let parsed = parse(
&request(&commands, &["private=yes", "bare-option", "private=no"]),
true,
)
.unwrap();
assert_eq!(
parsed.options,
Some(PushOptions {
pairs: vec![
("private".to_owned(), "yes".to_owned()),
("bare-option".to_owned(), String::new()),
("private".to_owned(), "no".to_owned()),
],
})
);
let parsed = parse(&request(&commands, &[]), true).unwrap();
assert_eq!(parsed.options, Some(PushOptions::default()));
let message = protocol_error(parse(
&request(
&[&format!(
"{} {} refs/heads/main\0report-status",
zero(),
hex_oid(0x0a)
)],
&["private=yes"],
),
false,
));
assert!(message.contains("fail closed"), "{message}");
let message = protocol_error(parse(&request(&commands, &[""]), true));
assert!(message.contains("empty push-option"), "{message}");
}
}