use bstr::{BString, ByteSlice as _};
use gix_hash::ObjectId;
use gix_transport::client::{Capabilities, MessageKind, WriteMode};
use crate::seam::{RefOutcome, RefUpdate};
use crate::session::{
drain_banded_payload, map_client_error, GitSession, SessionError, CLIENT_AGENT,
};
use crate::substrate::Service;
impl GitSession {
pub async fn push_advertisement(&mut self) -> Result<Vec<PushAdvertisedRef>, SessionError> {
if let Some(cached) = &self.terminal {
return Err(cached.clone());
}
if self.service != Service::ReceivePack {
let error = SessionError::InvalidInput(format!(
"push rides the receive-pack service; this session is {:?}",
self.service
));
self.terminal = Some(error.clone());
return Err(error);
}
let handshake = self.handshake().await?;
match parse_v0_advertisement(&handshake.refs) {
Ok(refs) => Ok(refs),
Err(error) => {
self.terminal = Some(error.clone());
Err(error)
}
}
}
pub async fn push<S>(
&mut self,
commands: Vec<RefUpdate>,
pack: Option<&mut S>,
options: &PushSendOptions,
) -> Result<PushReport, SessionError>
where
S: tokio::io::AsyncRead + Unpin,
{
if let Some(cached) = &self.terminal {
return Err(cached.clone());
}
if self.service != Service::ReceivePack {
let error = SessionError::InvalidInput(format!(
"push rides the receive-pack service; this session is {:?}",
self.service
));
self.terminal = Some(error.clone());
return Err(error);
}
if commands.is_empty() {
let error = SessionError::InvalidInput(
"push: the command set is empty — the caller computes the diff and pushes \
only a nonzero one (nothing-to-do is the caller's decision)"
.to_owned(),
);
self.terminal = Some(error.clone());
return Err(error);
}
if let Some(agent) = &options.agent {
if agent.is_empty()
|| agent
.bytes()
.any(|byte| byte.is_ascii_whitespace() || byte == 0)
{
let error = SessionError::InvalidInput(format!(
"push: the agent token {agent:?} carries grammar-breaking bytes (whitespace/NUL)"
));
self.terminal = Some(error.clone());
return Err(error);
}
}
let handshake = self.handshake().await?;
let format = push_object_format(&commands, &handshake.capabilities)?;
let remaining = self.remaining_wall_clock();
match tokio::time::timeout(remaining, self.push_once(commands, pack, options, format)).await
{
Ok(inner) => {
if let Err(error) = &inner {
self.terminal = Some(error.clone());
}
inner
}
Err(_elapsed) => Err(self.typed_wall_clock_breach()),
}
}
async fn push_once<S>(
&mut self,
commands: Vec<RefUpdate>,
mut pack: Option<&mut S>,
options: &PushSendOptions,
format: PushObjectFormat,
) -> Result<PushReport, SessionError>
where
S: tokio::io::AsyncRead + Unpin,
{
use futures::io::AsyncWriteExt as FuturesAsyncWriteExt;
use gix_transport::client::async_io::Transport as _;
let max_pack_size = self.limits.max_pack_size;
let caps =
push_capabilities_text(options, format, commands.iter().any(|c| c.new.is_none()));
let zero = "0".repeat(format.hex_size());
let mut writer = self
.transport_mut()
.request(
WriteMode::OneLfTerminatedLinePerWriteCall,
MessageKind::Flush,
false,
)
.map_err(map_client_error)?;
for (index, command) in commands.iter().enumerate() {
let old = command
.expected
.map(|oid| oid.to_string())
.unwrap_or_else(|| zero.clone());
let new = command
.new
.map(|oid| oid.to_string())
.unwrap_or_else(|| zero.clone());
let mut line = format!("{old} {new} {}", command.name);
if index == 0 {
line.push('\0');
line.push(' ');
line.push_str(&caps);
}
FuturesAsyncWriteExt::write_all(&mut writer, line.as_bytes())
.await
.map_err(|error| {
SessionError::Transport(format!("the command section failed to write: {error}"))
})?;
}
writer
.write_message(MessageKind::Flush)
.await
.map_err(|error| {
SessionError::Transport(format!("the command flush failed to write: {error}"))
})?;
FuturesAsyncWriteExt::flush(&mut writer)
.await
.map_err(|error| {
SessionError::Transport(format!("the command section failed to flush: {error}"))
})?;
let (mut raw_writer, mut reader) = writer.into_parts();
if let Some(pack) = pack.as_mut() {
stream_caller_pack(pack, &mut raw_writer).await?;
}
drop(raw_writer);
read_push_report(&mut reader, options.side_band_64k, max_pack_size).await
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PushAdvertisedRef {
pub oid: ObjectId,
pub name: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PushSendOptions {
pub atomic: bool,
pub report_status_v2: bool,
pub side_band_64k: bool,
pub quiet: bool,
pub agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PushReport {
pub unpack: Result<(), String>,
pub refs: Vec<RefOutcome>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PushObjectFormat {
Sha1,
Sha256,
}
impl PushObjectFormat {
fn hex_size(self) -> usize {
match self {
PushObjectFormat::Sha1 => 40,
PushObjectFormat::Sha256 => 64,
}
}
fn token(self) -> &'static str {
match self {
PushObjectFormat::Sha1 => "sha1",
PushObjectFormat::Sha256 => "sha256",
}
}
}
fn advertised_object_formats(capabilities: &Capabilities) -> Vec<String> {
capabilities
.iter()
.filter(|capability| capability.name() == b"object-format")
.filter_map(|capability| {
capability
.value()
.and_then(|value| value.to_str().ok().map(ToOwned::to_owned))
})
.collect()
}
fn push_object_format(
commands: &[RefUpdate],
advertised: &Capabilities,
) -> Result<PushObjectFormat, SessionError> {
let mut size: Option<usize> = None;
let mut seen = std::collections::HashSet::new();
for command in commands {
if command.name.is_empty()
|| command
.name
.bytes()
.any(|byte| byte == b' ' || byte == 0 || byte == b'\n')
{
return Err(SessionError::InvalidInput(format!(
"push: the ref name {:?} is off the command grammar (empty, or carries space/NUL/LF)",
command.name
)));
}
if command.expected.is_none() && command.new.is_none() {
return Err(SessionError::InvalidInput(format!(
"push: the command for {} carries the zero id on both sides — the \
create/delete shapes carry exactly one update side (ADR-013 §3)",
command.name
)));
}
if !seen.insert(command.name.clone()) {
return Err(SessionError::InvalidInput(format!(
"push: duplicate ref commands for {:?} in one push — fail closed before the wire",
command.name
)));
}
for oid in [command.expected, command.new].into_iter().flatten() {
let oid_size = oid.as_bytes().len();
match size {
Some(existing) if existing != oid_size => {
return Err(SessionError::InvalidInput(
"push: the command set mixes object formats (20- and 32-byte oids) — \
one push carries one format (ADR-013 §2's object-format capability \
is a single value), fail closed"
.to_owned(),
));
}
None => size = Some(oid_size),
_ => {}
}
}
}
match size {
Some(20) => Ok(PushObjectFormat::Sha1),
Some(32) => {
if !advertised_object_formats(advertised)
.iter()
.any(|value| value == "sha256")
{
return Err(SessionError::InvalidInput(
"push: the commands carry sha256 (32-byte) oids but the peer advertised \
no object-format=sha256 — fail closed before the wire"
.to_owned(),
));
}
Ok(PushObjectFormat::Sha256)
}
other => Err(SessionError::InvalidInput(format!(
"push: the command oids carry {other:?}-byte ids — outside the sha1 (20) / \
sha256 (32) grammar, fail closed"
))),
}
}
fn push_capabilities_text(
options: &PushSendOptions,
format: PushObjectFormat,
has_deletes: bool,
) -> String {
let mut tokens = Vec::new();
tokens.push(
if options.report_status_v2 {
"report-status-v2"
} else {
"report-status"
}
.to_owned(),
);
if options.side_band_64k {
tokens.push("side-band-64k".to_owned());
}
if options.atomic {
tokens.push("atomic".to_owned());
}
if has_deletes {
tokens.push("delete-refs".to_owned());
}
tokens.push(format!("object-format={}", format.token()));
if options.quiet {
tokens.push("quiet".to_owned());
}
tokens.push(match &options.agent {
Some(agent) => format!("agent={agent}"),
None => format!("agent={CLIENT_AGENT}"),
});
tokens.join(" ")
}
fn parse_v0_advertisement(refs: &[BString]) -> Result<Vec<PushAdvertisedRef>, SessionError> {
let invalid = |line: &[u8]| {
SessionError::Protocol(format!(
"the push advertisement line {:?} is not a V0 ref row ('<oid> <ref>') — fail closed",
String::from_utf8_lossy(line)
))
};
let mut advertised = Vec::with_capacity(refs.len());
let mut saw_sentinel = false;
for line in refs {
let text = String::from_utf8_lossy(line);
let text = text.trim_end();
let Some((oid_hex, name)) = text.split_once(' ') else {
return Err(invalid(line));
};
let oid = match ObjectId::from_hex(oid_hex.as_bytes()) {
Ok(oid) => oid,
Err(_) => return Err(invalid(line)),
};
if name.is_empty() {
return Err(invalid(line));
}
if name == "capabilities^{}" {
if !oid.is_null() {
return Err(SessionError::Protocol(format!(
"the push advertisement's capabilities^{{}} sentinel carries a non-zero \
oid {oid} — fail closed"
)));
}
saw_sentinel = true;
continue;
}
if saw_sentinel {
return Err(SessionError::Protocol(
"the push advertisement carries ref lines beside the capabilities^{} \
sentinel — one shape or the other, never both, fail closed"
.to_owned(),
));
}
if oid.is_null() {
return Err(SessionError::Protocol(format!(
"the push advertisement line {text:?} carries a zero oid — the zero id is the \
sentinel's shape alone, fail closed"
)));
}
advertised.push(PushAdvertisedRef {
oid,
name: name.to_owned(),
});
}
Ok(advertised)
}
async fn stream_caller_pack<S>(
pack: &mut S,
raw: &mut (impl futures::io::AsyncWrite + Unpin),
) -> Result<(), SessionError>
where
S: tokio::io::AsyncRead + Unpin,
{
use futures::io::AsyncWriteExt as _;
use tokio::io::AsyncReadExt as _;
let mut buffer = vec![0u8; 64 * 1024];
loop {
let read = pack.read(&mut buffer).await.map_err(|error| {
SessionError::Transport(format!("the caller's pack stream failed to read: {error}"))
})?;
if read == 0 {
return raw.flush().await.map_err(|error| {
SessionError::Transport(format!("the pack stream failed to flush: {error}"))
});
}
raw.write_all(&buffer[..read]).await.map_err(|error| {
SessionError::Transport(format!("the pack stream failed to write: {error}"))
})?;
}
}
async fn read_push_report(
reader: &mut Box<dyn gix_transport::client::async_io::ExtendedBufRead<'_> + Unpin + '_>,
sideband: bool,
max_pack_size: u64,
) -> Result<PushReport, SessionError> {
let mut inner = Vec::new();
drain_banded_payload(
reader,
&mut inner,
sideband,
max_pack_size,
"the push report",
"the push report",
)
.await?;
parse_push_report(&inner, sideband)
}
fn framed_report_lines(payload: &[u8]) -> Result<Vec<String>, SessionError> {
let invalid = |why: String| {
SessionError::Protocol(format!(
"the push report's inner sideband framing is malformed: {why} — fail closed"
))
};
let mut lines = Vec::new();
let mut cursor = payload;
while !cursor.is_empty() {
if cursor.len() < 4 {
return Err(invalid(format!(
"truncated at a {}-byte pkt-line header",
cursor.len()
)));
}
let hex = std::str::from_utf8(&cursor[..4])
.map_err(|_| invalid("the pkt-line header is not hex".to_owned()))?;
let len = usize::from_str_radix(hex, 16)
.map_err(|_| invalid(format!("the pkt-line header {hex:?} is not a hex length")))?;
cursor = &cursor[4..];
if len < 4 {
break;
}
let take = len - 4;
if cursor.len() < take {
return Err(invalid(format!("truncated at a {take}-byte line body")));
}
let (line, rest) = cursor.split_at(take);
cursor = rest;
let text = line.strip_suffix(b"\n").unwrap_or(line);
lines.push(String::from_utf8_lossy(text).into_owned());
}
Ok(lines)
}
fn parse_push_report(inner: &[u8], sideband: bool) -> Result<PushReport, SessionError> {
let lines: Vec<String> = if sideband {
framed_report_lines(inner)?
} else {
String::from_utf8_lossy(inner)
.lines()
.map(ToOwned::to_owned)
.collect()
};
let mut lines = lines.into_iter().filter(|line| !line.is_empty());
let unpack = match lines.next() {
Some(line) if line == "unpack ok" => Ok(()),
Some(line) if line.starts_with("unpack ng") => {
let reason = line
.strip_prefix("unpack ng")
.and_then(|rest| rest.strip_prefix(' ').or(Some(rest)))
.unwrap_or_default();
Err(reason.to_owned())
}
Some(line) => {
return Err(SessionError::Protocol(format!(
"the push report opens with {line:?} — 'unpack ok|ng <reason>' expected, \
fail closed"
)));
}
None => {
return Err(SessionError::Protocol(
"the push report carries no lines — fail closed".to_owned(),
));
}
};
let mut refs = Vec::new();
for line in lines {
if let Some(name) = line.strip_prefix("ok ") {
refs.push(RefOutcome {
name: name.to_owned(),
result: Ok(()),
});
} else if let Some(rest) = line.strip_prefix("ng ") {
let (name, reason) = match rest.split_once(' ') {
Some((name, reason)) => (name, reason),
None => (rest, ""),
};
refs.push(RefOutcome {
name: name.to_owned(),
result: Err(reason.to_owned()),
});
} else {
return Err(SessionError::Protocol(format!(
"the push report line {line:?} is not a report line ('ok <ref>' / \
'ng <ref> <reason>') — fail closed"
)));
}
}
Ok(PushReport { unpack, refs })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_push_capabilities_line_is_the_grammar_the_client_speaks() {
assert_eq!(
push_capabilities_text(&PushSendOptions::default(), PushObjectFormat::Sha1, false),
format!("report-status object-format=sha1 agent={CLIENT_AGENT}"),
"the default: report-status only, the crate agent, bare framing"
);
let options = PushSendOptions {
atomic: true,
report_status_v2: true,
side_band_64k: true,
quiet: true,
agent: Some("replicator/7".to_owned()),
};
assert_eq!(
push_capabilities_text(&options, PushObjectFormat::Sha256, true),
"report-status-v2 side-band-64k atomic delete-refs object-format=sha256 quiet \
agent=replicator/7",
"every option rides, delete-refs auto-ridden on deletes, format follows the oids"
);
assert!(
!push_capabilities_text(&PushSendOptions::default(), PushObjectFormat::Sha1, false)
.contains("push-options"),
"the push-options section is sent never — the gate-off posture mirrored (ADR-013 §11)"
);
}
#[test]
fn the_push_command_set_validates_before_the_wire() {
let oid20 = |byte: u8| ObjectId::from([byte; 20]);
let advertised_sha1 = Capabilities::from_lines("version 2\nobject-format=sha1\n".into())
.expect("the fixture capability list");
assert_eq!(
push_object_format(
&[RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid20(7)),
}],
&advertised_sha1,
)
.expect("the create shape"),
PushObjectFormat::Sha1
);
assert_eq!(
push_object_format(
&[
RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid20(1)),
},
RefUpdate {
name: "refs/heads/dev".to_owned(),
expected: Some(oid20(1)),
new: Some(oid20(2)),
},
RefUpdate {
name: "refs/heads/old".to_owned(),
expected: Some(oid20(3)),
new: None,
},
],
&advertised_sha1,
)
.expect("create + update + delete shapes"),
PushObjectFormat::Sha1
);
for (why, commands) in [
(
"the both-zero command shape",
vec![RefUpdate {
name: "refs/heads/void".to_owned(),
expected: None,
new: None,
}],
),
(
"duplicate ref commands",
vec![
RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid20(1)),
},
RefUpdate {
name: "refs/heads/main".to_owned(),
expected: Some(oid20(1)),
new: Some(oid20(2)),
},
],
),
(
"a name with grammar-breaking bytes",
vec![RefUpdate {
name: "refs/heads/bad name".to_owned(),
expected: None,
new: Some(oid20(1)),
}],
),
] {
match push_object_format(&commands, &advertised_sha1) {
Err(SessionError::InvalidInput(message)) => {
assert!(
message.contains("push:"),
"{why}: the refusal names the rule: {message}"
);
}
other => panic!("{why}: expected the local refusal, got {other:?}"),
}
}
#[cfg(feature = "sha256")]
{
let oid32 = ObjectId::null(gix_hash::Kind::Sha256);
let advertised_both = Capabilities::from_lines(
"version 2\nobject-format=sha1\nobject-format=sha256\n".into(),
)
.expect("the fixture capability list");
assert_eq!(
push_object_format(
&[RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid32),
}],
&advertised_both,
)
.expect("sha256 rides where advertised"),
PushObjectFormat::Sha256
);
match push_object_format(
&[RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid32),
}],
&advertised_sha1,
) {
Err(SessionError::InvalidInput(message)) => {
assert!(message.contains("sha256"), "{message}");
}
other => panic!("sha256 without the advertisement refuses, got {other:?}"),
}
match push_object_format(
&[
RefUpdate {
name: "refs/heads/a".to_owned(),
expected: None,
new: Some(oid20(1)),
},
RefUpdate {
name: "refs/heads/b".to_owned(),
expected: None,
new: Some(oid32),
},
],
&advertised_both,
) {
Err(SessionError::InvalidInput(message)) => {
assert!(message.contains("mixes"), "{message}");
}
other => panic!("mixed formats refuse, got {other:?}"),
}
}
}
#[test]
fn the_v0_advertisement_parses_its_rows_and_the_sentinel() {
let oid = |byte: u8| ObjectId::from([byte; 20]);
assert_eq!(
parse_v0_advertisement(&[]).expect("the empty drain is the empty repo"),
Vec::new()
);
assert_eq!(
parse_v0_advertisement(&[BString::from(format!(
"{} capabilities^{{}}\n",
"0".repeat(40)
))])
.expect("the sentinel"),
Vec::new(),
"the empty repo's advertisement is the empty set"
);
assert_eq!(
parse_v0_advertisement(&[
BString::from(format!("{} HEAD\n", oid(1))),
BString::from(format!("{} refs/heads/main\n", oid(2))),
])
.expect("the ref rows"),
vec![
PushAdvertisedRef {
oid: oid(1),
name: "HEAD".to_owned(),
},
PushAdvertisedRef {
oid: oid(2),
name: "refs/heads/main".to_owned(),
},
]
);
#[cfg(feature = "sha256")]
{
let sha256_zero_sentinel = format!("{} capabilities^{{}}\n", "0".repeat(64));
assert_eq!(
parse_v0_advertisement(&[BString::from(sha256_zero_sentinel)])
.expect("the sha256-sized sentinel oid"),
Vec::new()
);
}
for (why, lines) in [
(
"refs beside the sentinel",
vec![
BString::from(format!("{} capabilities^{{}}\n", "0".repeat(40))),
BString::from(format!("{} refs/heads/x\n", oid(1))),
],
),
(
"a zero-oid ref row",
vec![BString::from(format!("{} refs/heads/x\n", "0".repeat(40)))],
),
("a line without <oid> <ref>", vec![BString::from("HEAD\n")]),
(
"an undecodable oid",
vec![BString::from("zz refs/heads/x\n")],
),
] {
match parse_v0_advertisement(&lines) {
Err(SessionError::Protocol(message)) => {
assert!(message.contains("fail closed"), "{why}: {message}");
}
other => panic!("{why}: expected the protocol error, got {other:?}"),
}
}
}
#[test]
fn the_push_report_parses_both_framings() {
let bare: &[u8] = b"unpack ok\nok refs/heads/main\nng refs/heads/dev funny refname\n";
assert_eq!(
parse_push_report(bare, false).expect("the bare report"),
PushReport {
unpack: Ok(()),
refs: vec![
RefOutcome {
name: "refs/heads/main".to_owned(),
result: Ok(()),
},
RefOutcome {
name: "refs/heads/dev".to_owned(),
result: Err("funny refname".to_owned()),
},
],
}
);
let framed_inner = |texts: &[&str]| {
let mut out = Vec::new();
for text in texts {
let payload = format!("{text}\n");
out.extend_from_slice(format!("{:04x}", payload.len() + 4).as_bytes());
out.extend_from_slice(payload.as_bytes());
}
out.extend_from_slice(b"0000");
out
};
let sideband = framed_inner(&[
"unpack ok",
"ok refs/heads/main",
"ng refs/heads/dev funny refname",
]);
assert_eq!(
parse_push_report(&sideband, true).expect("the band-wrapped report"),
parse_push_report(bare, false).expect("the bare report"),
"both framations carry the same report"
);
let unpack_ng: &[u8] =
b"unpack ng eof before pack header was fully read\nng refs/heads/x\n";
assert_eq!(
parse_push_report(unpack_ng, false).expect("the ng leg"),
PushReport {
unpack: Err("eof before pack header was fully read".to_owned()),
refs: vec![RefOutcome {
name: "refs/heads/x".to_owned(),
result: Err(String::new()),
}],
}
);
for (why, payload, sideband) in [
(
"opens without the unpack leg",
b"ok refs/heads/x\n" as &[u8],
false,
),
(
"an off-grammar ref line",
b"unpack ok\nhuh refs/heads/x\n" as &[u8],
false,
),
("an entirely empty report", b"" as &[u8], false),
("malformed inner framing", b"0zz0unpack ok\n" as &[u8], true),
("truncated inner framing", b"0010unpack" as &[u8], true),
] {
match parse_push_report(payload, sideband) {
Err(SessionError::Protocol(message)) => {
assert!(message.contains("fail closed"), "{why}: {message}");
}
other => panic!("{why}: expected the protocol error, got {other:?}"),
}
}
}
}