use gix_hash::{Kind, ObjectId};
use gix_packetline::async_io::encode;
use crate::limits::Limits;
use crate::seam::{RefLine, RefOutcome};
use crate::substrate::SubstrateError;
use crate::v2::PktLineWriter;
pub const PUSH_SERVED_CAPABILITIES: &[&str] = &[
"report-status",
"report-status-v2",
"delete-refs",
"side-band-64k",
"atomic",
"ofs-delta",
"object-format=sha1",
];
const AGENT_CAPABILITY: &str = concat!(
"agent=",
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
);
pub(crate) fn capabilities_text(push_options_negotiated: bool) -> String {
let mut tokens: Vec<&str> = PUSH_SERVED_CAPABILITIES.to_vec();
if push_options_negotiated {
tokens.push("push-options");
}
#[cfg(feature = "sha256")]
tokens.push("object-format=sha256");
tokens.push(AGENT_CAPABILITY);
tokens.join(" ")
}
fn zero_oid_hex() -> String {
ObjectId::null(Kind::Sha1).to_string()
}
pub async fn advertise_v0(
writer: &mut impl PktLineWriter,
refs: Vec<RefLine>,
push_options_negotiated: bool,
limits: &Limits,
) -> Result<(), SubstrateError> {
if refs.len() > limits.max_advertisement_refs {
return Err(SubstrateError::Protocol(format!(
"push advertisement: ref cap of {} exceeded by {} refs — fail closed, no partial list (ADR-009, review 001 N-1)",
limits.max_advertisement_refs,
refs.len()
)));
}
let caps = capabilities_text(push_options_negotiated);
let zero = zero_oid_hex();
let mut listed = Vec::with_capacity(refs.len());
for line in &refs {
if let Some(oid) = line.oid {
listed.push((oid.to_string(), line.name.as_str()));
}
}
if listed.is_empty() {
writer
.write_text_line(format!("{zero} capabilities^{{}}\0{caps}").as_bytes())
.await?;
return writer.write_flush().await;
}
let Some((first, rest)) = listed.split_first() else {
return writer.write_flush().await;
};
writer
.write_text_line(format!("{} {}\0{caps}", first.0, first.1).as_bytes())
.await?;
for (hex, name) in rest {
writer
.write_text_line(format!("{hex} {name}").as_bytes())
.await?;
}
writer.write_flush().await
}
fn unpack_leg_line(unpack: &Result<(), String>) -> String {
match unpack {
Ok(()) => "unpack ok".to_owned(),
Err(reason) => format!("unpack ng {reason}"),
}
}
fn ref_report_line(outcome: &RefOutcome) -> String {
match &outcome.result {
Ok(()) => format!("ok {}", outcome.name),
Err(reason) => format!("ng {} {reason}", outcome.name),
}
}
pub(crate) async fn write_status_report(
writer: &mut impl PktLineWriter,
unpack: &Result<(), String>,
refs: &[RefOutcome],
sideband: bool,
) -> Result<(), SubstrateError> {
if sideband {
let mut payload = Vec::new();
encode::text_to_write(unpack_leg_line(unpack).as_bytes(), &mut payload).await?;
for outcome in refs {
encode::text_to_write(ref_report_line(outcome).as_bytes(), &mut payload).await?;
}
encode::flush_to_write(&mut payload).await?;
writer.write_data_band(&payload).await?;
return writer.write_flush().await;
}
writer
.write_text_line(unpack_leg_line(unpack).as_bytes())
.await?;
for outcome in refs {
writer
.write_text_line(ref_report_line(outcome).as_bytes())
.await?;
}
writer.write_flush().await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::substrate::SubstrateError;
use gix_packetline::async_io::encode;
use gix_packetline::Channel;
const CAPS: &str = "report-status report-status-v2 delete-refs side-band-64k atomic ofs-delta object-format=sha1";
const AGENT: &str = AGENT_CAPABILITY;
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
fn hex_oid(b: u8) -> String {
oid(b).to_string()
}
fn ref_line(name: &str, oid: Option<ObjectId>) -> RefLine {
RefLine {
name: name.to_owned(),
oid,
peeled: None,
symref_target: None,
}
}
struct ByteSink(Vec<u8>);
#[async_trait::async_trait]
impl PktLineWriter for ByteSink {
async fn write_text_line(&mut self, line: &[u8]) -> Result<(), SubstrateError> {
encode::text_to_write(line, &mut self.0).await?;
Ok(())
}
async fn write_flush(&mut self) -> Result<(), SubstrateError> {
encode::flush_to_write(&mut self.0).await?;
Ok(())
}
async fn write_data_band(&mut self, data: &[u8]) -> Result<(), SubstrateError> {
encode::band_to_write(Channel::Data, data, &mut self.0).await?;
Ok(())
}
}
fn framed_lines(buffer: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut cursor = buffer;
while !cursor.is_empty() {
let (header, rest) = cursor.split_at(4);
let len = usize::from_str_radix(std::str::from_utf8(header).expect("hex header"), 16)
.expect("hex length");
cursor = rest;
if len < 4 {
out.push(Vec::new());
continue;
}
let (payload, rest) = cursor.split_at(len - 4);
cursor = rest;
out.push(payload.to_vec());
}
out
}
fn framed_texts(buffer: &[u8]) -> Vec<String> {
framed_lines(buffer)
.into_iter()
.map(|payload| {
String::from_utf8_lossy(payload.strip_suffix(b"\n").unwrap_or(&payload))
.into_owned()
})
.collect()
}
#[test]
fn the_served_set_is_the_pinned_constant() {
assert_eq!(
PUSH_SERVED_CAPABILITIES,
&[
"report-status",
"report-status-v2",
"delete-refs",
"side-band-64k",
"atomic",
"ofs-delta",
"object-format=sha1",
]
);
let declined = ["quiet", "push-cert", "push-options", "shallow"];
for capability in declined {
assert!(
!PUSH_SERVED_CAPABILITIES
.iter()
.any(|line| line.contains(capability)),
"{capability} is declined by omission (ADR-013 §2) or gate-gated (§11)"
);
}
assert_eq!(
capabilities_text(false),
format!("{CAPS}{} {AGENT}", sha256_rider()),
"the pinned default advertisement text (the sha256 rider rides the feature)"
);
assert_eq!(
capabilities_text(true),
format!("{CAPS} push-options{} {AGENT}", sha256_rider()),
"the gate adds the push-options token, nothing else"
);
}
fn sha256_rider() -> &'static str {
if cfg!(feature = "sha256") {
" object-format=sha256"
} else {
""
}
}
#[tokio::test]
async fn the_first_ref_line_carries_the_caps_and_the_rest_are_bare() {
let refs = vec![
ref_line("HEAD", Some(oid(1))),
ref_line("refs/heads/main", Some(oid(2))),
ref_line("refs/tags/v1", Some(oid(3))),
];
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, refs, false, &Limits::default())
.await
.unwrap();
let caps = capabilities_text(false);
let lines = framed_texts(&sink.0);
assert_eq!(
lines,
vec![
format!("{} HEAD\0{caps}", hex_oid(1)),
format!("{} refs/heads/main", hex_oid(2)),
format!("{} refs/tags/v1", hex_oid(3)),
String::new(),
],
"caps NUL-attached on the FIRST ref line only; bare rest; flush-terminated"
);
assert_eq!(
lines[0].matches('\0').count(),
1,
"exactly one NUL, on the first line"
);
}
#[tokio::test]
async fn an_empty_repo_advertises_exactly_the_sentinel_line() {
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, Vec::new(), false, &Limits::default())
.await
.unwrap();
let caps = capabilities_text(false);
let lines = framed_texts(&sink.0);
assert_eq!(
lines,
vec![
format!("{} capabilities^{{}}\0{caps}", "0".repeat(40)),
String::new(),
],
"the zero-oid sentinel replaces the ref lines — one line carrying the caps"
);
}
#[tokio::test]
async fn a_repo_with_refs_never_emits_the_sentinel() {
let refs = vec![
ref_line("HEAD", Some(oid(1))),
ref_line("refs/heads/main", Some(oid(2))),
];
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, refs, false, &Limits::default())
.await
.unwrap();
let body = String::from_utf8_lossy(&sink.0).into_owned();
assert!(
!body.contains("capabilities^{}"),
"a repo with refs never emits the sentinel: {body}"
);
let caps = capabilities_text(false);
let lines = framed_texts(&sink.0);
assert_eq!(
lines,
vec![
format!("{} HEAD\0{caps}", hex_oid(1)),
format!("{} refs/heads/main", hex_oid(2)),
String::new(),
],
"the caps NUL rides the first ref line only"
);
}
#[tokio::test]
async fn no_oid_rows_are_filtered() {
let refs = vec![
ref_line("HEAD", None),
ref_line("refs/heads/main", Some(oid(2))),
];
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, refs, false, &Limits::default())
.await
.unwrap();
let body = String::from_utf8_lossy(&sink.0).into_owned();
assert!(
!body.contains("HEAD"),
"the no-oid row is filtered out: {body}"
);
}
#[tokio::test]
async fn a_fully_filtered_repo_takes_the_sentinel_never_a_bare_dump() {
let refs = vec![ref_line("HEAD", None)];
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, refs, false, &Limits::default())
.await
.unwrap();
let caps = capabilities_text(false);
let lines = framed_texts(&sink.0);
assert_eq!(
lines,
vec![
format!("{} capabilities^{{}}\0{caps}", "0".repeat(40)),
String::new(),
],
);
}
#[tokio::test]
async fn the_sideband_report_is_band1_framed_packetlines_to_the_outer_flush() {
let 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 mut sink = ByteSink(Vec::new());
write_status_report(&mut sink, &Ok(()), &refs, true)
.await
.unwrap();
assert!(
sink.0.starts_with(b"0052\x01000eunpack ok\n"),
"band-1 payload is itself pkt-line-framed: {}",
String::from_utf8_lossy(&sink.0)
);
let outer = framed_lines(&sink.0);
assert_eq!(
outer.last().map(|v| v.is_empty()),
Some(true),
"one outer flush, terminal"
);
let bands = &outer[..outer.len() - 1];
assert!(
bands.iter().all(|payload| payload.first() == Some(&0x01)),
"every report byte rides band-1 (Channel::Data)"
);
let mut reassembled = Vec::new();
for payload in bands {
reassembled.extend_from_slice(&payload[1..]);
}
assert_eq!(
framed_texts(&reassembled),
vec![
"unpack ok",
"ok refs/heads/main",
"ng refs/heads/dev funny refname",
"",
],
"unpack line, per-ref lines in request order, the inner flush inside the band"
);
}
#[tokio::test]
async fn the_bare_report_is_pktlines_to_a_single_flush() {
let refs = vec![
RefOutcome {
name: "refs/heads/main".to_owned(),
result: Ok(()),
},
RefOutcome {
name: "refs/heads/dev".to_owned(),
result: Err("funny refname".to_owned()),
},
RefOutcome {
name: "refs/heads/third".to_owned(),
result: Ok(()),
},
];
let mut sink = ByteSink(Vec::new());
write_status_report(&mut sink, &Ok(()), &refs, false)
.await
.unwrap();
let lines = framed_lines(&sink.0);
assert_eq!(
lines.iter().filter(|p| p.is_empty()).count(),
1,
"exactly one flush — the report section's terminator"
);
assert_eq!(
framed_texts(&sink.0),
vec![
"unpack ok",
"ok refs/heads/main",
"ng refs/heads/dev funny refname",
"ok refs/heads/third",
"",
],
"bare pkt-lines in request order, single-flush terminated"
);
assert!(
framed_lines(&sink.0)
.iter()
.all(|payload| payload.first() != Some(&0x01)),
"no band framing without the sideband selection"
);
}
#[tokio::test]
async fn pack_level_failure_frames_unpack_ng_with_the_all_refs_unpacker_error_map() {
let unpack: Result<(), String> =
Err("unpack protocol error (pack signature mismatch detected)".to_owned());
let refs = vec![
RefOutcome {
name: "refs/heads/main".to_owned(),
result: Err("unpacker error".to_owned()),
},
RefOutcome {
name: "refs/heads/dev".to_owned(),
result: Err("unpacker error".to_owned()),
},
];
let mut sink = ByteSink(Vec::new());
write_status_report(&mut sink, &unpack, &refs, false)
.await
.unwrap();
assert_eq!(
framed_texts(&sink.0),
vec![
"unpack ng unpack protocol error (pack signature mismatch detected)",
"ng refs/heads/main unpacker error",
"ng refs/heads/dev unpacker error",
"",
],
"the ng leg frames from the unpack line; per-ref lines carry the mapped text"
);
}
#[tokio::test]
async fn reason_strings_pass_through_verbatim() {
let unpack: Result<(), String> = Err("eof before pack header was fully read".to_owned());
let refs = vec![
RefOutcome {
name: "refs/heads/main".to_owned(),
result: Err("atomic push failure".to_owned()),
},
RefOutcome {
name: "refs/heads/other".to_owned(),
result: Err("stale info ".to_owned()),
},
RefOutcome {
name: "refs/heads/good".to_owned(),
result: Ok(()),
},
];
let mut sink = ByteSink(Vec::new());
write_status_report(&mut sink, &unpack, &refs, false)
.await
.unwrap();
assert_eq!(
framed_texts(&sink.0),
vec![
"unpack ng eof before pack header was fully read",
"ng refs/heads/main atomic push failure",
"ng refs/heads/other stale info ",
"ok refs/heads/good",
"",
],
"reasons are client-displayed verbatim text — trailing spaces survive (no rewriting, no normalization)"
);
}
#[tokio::test]
async fn the_sideband_report_of_a_failed_pack_follows_the_same_rule() {
let unpack: Result<(), String> = Err("index-pack failed".to_owned());
let refs = vec![RefOutcome {
name: "refs/heads/main".to_owned(),
result: Err("unpacker error".to_owned()),
}];
let mut sink = ByteSink(Vec::new());
write_status_report(&mut sink, &unpack, &refs, true)
.await
.unwrap();
let outer = framed_lines(&sink.0);
let mut reassembled = Vec::new();
for payload in &outer[..outer.len() - 1] {
assert_eq!(payload.first(), Some(&0x01));
reassembled.extend_from_slice(&payload[1..]);
}
assert_eq!(
framed_texts(&reassembled),
vec![
"unpack ng index-pack failed",
"ng refs/heads/main unpacker error",
"",
],
);
assert_eq!(outer.last().map(|v| v.is_empty()), Some(true));
}
#[tokio::test]
async fn the_ref_cap_fails_closed_with_nothing_emitted() {
let refs = vec![
ref_line("refs/heads/a", Some(oid(1))),
ref_line("refs/heads/b", Some(oid(2))),
ref_line("refs/heads/c", Some(oid(3))),
];
let limits = Limits {
max_advertisement_refs: 2,
..Limits::default()
};
let mut sink = ByteSink(Vec::new());
let error = advertise_v0(&mut sink, refs, false, &limits)
.await
.unwrap_err();
assert!(error.is_terminal(), "a cap breach fails closed: {error:?}");
match &error {
SubstrateError::Protocol(message) => {
assert!(message.contains("ref cap"), "{message}");
}
other => panic!("a cap breach is protocol-shaped: {other:?}"),
}
assert!(
sink.0.is_empty(),
"no partial list can be emitted — the check precedes the first written line"
);
}
#[tokio::test]
async fn the_emission_carries_no_service_prefix() {
let refs = vec![ref_line("refs/heads/main", Some(oid(2)))];
let mut sink = ByteSink(Vec::new());
advertise_v0(&mut sink, refs, false, &Limits::default())
.await
.unwrap();
let lines = framed_texts(&sink.0);
assert!(
lines.iter().all(|line| !line.contains("# service=")),
"the duplex shape has no service prefix — the emission speaks refs first"
);
assert!(
lines.iter().all(|line| !line.contains("version")),
"no version negotiation exists on the push path (ADR-013 §1)"
);
}
#[cfg(feature = "sha256")]
#[test]
fn the_sha256_rider_rides_the_feature() {
let text = capabilities_text(false);
assert!(
text.contains("object-format=sha1 object-format=sha256"),
"the passthrough rider follows the pinned default: {text}"
);
}
}