use bstr::{BString, ByteSlice as _};
use gix_hash::ObjectId;
use gix_protocol::command::Feature;
use gix_protocol::fetch::response::Acknowledgement;
use gix_protocol::fetch::{Arguments, Response};
use gix_transport::client::Capabilities;
use gix_transport::Protocol;
use crate::seam::RefLine;
use crate::session::{
drain_banded_payload, drain_bounded_lines, map_client_error, AdvertisementBounds, GitSession,
HandshakeResponse, SessionError, CLIENT_AGENT,
};
use crate::substrate::Service;
use crate::v2::LsRefsArgs;
impl GitSession {
pub async fn ls_refs(&mut self, args: &LsRefsArgs) -> Result<Vec<RefLine>, SessionError> {
if let Some(cached) = &self.terminal {
return Err(cached.clone());
}
if self.service != Service::UploadPack {
let error = SessionError::InvalidInput(format!(
"ls-refs rides the upload-pack service; this session is {:?}",
self.service
));
self.terminal = Some(error.clone());
return Err(error);
}
let handshake = self.handshake().await?;
let bounds = AdvertisementBounds {
max_refs: self.limits.max_advertisement_refs,
max_bytes: self.limits.max_request_body,
};
let prepared = prepare_ls_refs_args(args)?;
let remaining = self.remaining_wall_clock();
match tokio::time::timeout(remaining, self.ls_refs_once(&handshake, prepared, bounds)).await
{
Ok(inner) => {
if let Err(error) = &inner {
self.terminal = Some(error.clone());
}
inner
}
Err(_elapsed) => Err(self.typed_wall_clock_breach()),
}
}
async fn ls_refs_once(
&mut self,
handshake: &HandshakeResponse,
prepared: Vec<BString>,
bounds: AdvertisementBounds,
) -> Result<Vec<RefLine>, SessionError> {
use gix_transport::client::async_io::TransportV2Ext as _;
let empty = prepared.is_empty();
let transport = self.transport_mut();
let mut reader = transport
.invoke(
"ls-refs",
command_features(&handshake.capabilities).into_iter(),
if empty {
None
} else {
Some(prepared.into_iter())
},
false,
)
.await
.map_err(map_client_error)?;
drain_bounded_lines(&mut reader, bounds, "the ls-refs response", |line| {
parse_ref_line(line.trim_end())
})
.await
}
pub async fn fetch<S>(
&mut self,
rounds: Vec<FetchRound>,
pack: &mut S,
) -> Result<Vec<FetchRoundReport>, SessionError>
where
S: tokio::io::AsyncWrite + Unpin,
{
if let Some(cached) = &self.terminal {
return Err(cached.clone());
}
if self.service != Service::UploadPack {
let error = SessionError::InvalidInput(format!(
"fetch rides the upload-pack service; this session is {:?}",
self.service
));
self.terminal = Some(error.clone());
return Err(error);
}
if rounds.is_empty() {
let error = SessionError::InvalidInput(
"fetch: the round plan is empty — one round is the minimum grammar shape"
.to_owned(),
);
self.terminal = Some(error.clone());
return Err(error);
}
let cap = self.limits.max_negotiation_rounds;
if rounds.len() > cap {
let error = SessionError::BudgetExceeded {
what: "the fetch negotiation".to_owned(),
budget_name: "max_negotiation_rounds",
limit: cap as u64,
};
self.terminal = Some(error.clone());
return Err(error);
}
for (index, round) in rounds.iter().enumerate() {
if !round.done && round.haves.is_empty() {
let error = SessionError::InvalidInput(
"fetch: a no-done round carries at least one have (the ack-loop grammar's shape)"
.to_owned(),
);
self.terminal = Some(error.clone());
return Err(error);
}
if round.done && index + 1 < rounds.len() {
let error = SessionError::InvalidInput(
"fetch: the done round terminates the negotiation — no rounds may follow it"
.to_owned(),
);
self.terminal = Some(error.clone());
return Err(error);
}
}
let handshake = self.handshake().await?;
let features = command_features(&handshake.capabilities);
let max_pack_size = self.limits.max_pack_size;
let remaining = self.remaining_wall_clock();
match tokio::time::timeout(
remaining,
self.fetch_once(rounds, pack, features, max_pack_size),
)
.await
{
Ok(inner) => {
if let Err(error) = &inner {
self.terminal = Some(error.clone());
}
inner
}
Err(_elapsed) => Err(self.typed_wall_clock_breach()),
}
}
async fn fetch_once<S>(
&mut self,
rounds: Vec<FetchRound>,
pack: &mut S,
features: Vec<Feature>,
max_pack_size: u64,
) -> Result<Vec<FetchRoundReport>, SessionError>
where
S: tokio::io::AsyncWrite + Unpin,
{
use tokio_util::compat::TokioAsyncWriteCompatExt as _;
let mut sink = pack.compat_write();
let mut reports = Vec::with_capacity(rounds.len());
for round in rounds {
let mut arguments = Arguments::new(Protocol::V2, features.clone(), false);
for want in &round.wants {
arguments.want(*want);
}
for have in &round.haves {
arguments.have(*have);
}
let mut reader = arguments
.send(self.transport_mut(), round.done)
.await
.map_err(map_client_error)?;
let response =
Response::from_line_reader(Protocol::V2, &mut reader, round.done, !round.done)
.await
.map_err(|error| {
SessionError::Protocol(format!("malformed fetch response: {error}"))
})?;
let mut acks = Vec::new();
for ack in response.acknowledgements() {
match ack {
Acknowledgement::Common(oid) => acks.push(*oid),
Acknowledgement::Nak => {}
Acknowledgement::Ready => {
return Err(SessionError::Protocol(
"the peer sent `ready` — never in the negotiation grammar, the \
wait-for-done posture has no ready (ADR-014 §4)"
.to_owned(),
));
}
}
}
if response.has_pack() {
if !round.done {
return Err(SessionError::Protocol(
"the peer sent a packfile section on a no-done round — the \
wait-for-done grammar's pack is the done round's alone (ADR-014 §2/§4)"
.to_owned(),
));
}
let pack_bytes = pump_pack(&mut reader, &mut sink, max_pack_size).await?;
reports.push(FetchRoundReport {
acks,
pack_bytes: Some(pack_bytes),
});
} else {
if round.done && !round.wants.is_empty() {
return Err(SessionError::Protocol(
"the done round's response carried no packfile section — the peer \
violated the wait-for-done grammar (ADR-014 §2/§4)"
.to_owned(),
));
}
reports.push(FetchRoundReport {
acks,
pack_bytes: None,
});
}
}
Ok(reports)
}
}
#[derive(Clone, Debug)]
pub struct FetchRound {
pub wants: Vec<ObjectId>,
pub haves: Vec<ObjectId>,
pub done: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FetchRoundReport {
pub acks: Vec<ObjectId>,
pub pack_bytes: Option<u64>,
}
fn command_features(capabilities: &Capabilities) -> Vec<Feature> {
let mut features = vec![("agent", Some(CLIENT_AGENT.to_owned()))];
if let Some(object_format) = capabilities
.capability("object-format")
.and_then(|capability| capability.value())
.and_then(|value| value.to_str().ok().map(ToOwned::to_owned))
{
features.push(("object-format", Some(object_format)));
}
features
}
fn prepare_ls_refs_args(args: &LsRefsArgs) -> Result<Vec<BString>, SessionError> {
let mut arguments: Vec<BString> = Vec::new();
if args.symrefs {
arguments.push("symrefs".into());
}
if args.peel {
arguments.push("peel".into());
}
if args.unborn {
arguments.push("unborn".into());
}
for prefix in &args.ref_prefixes {
if prefix.is_empty() {
return Err(SessionError::InvalidInput(
"ls-refs: a ref-prefix is a value-bearing argument; the empty prefix is out of grammar"
.to_owned(),
));
}
arguments.push(format!("ref-prefix {prefix}").into());
}
Ok(arguments)
}
fn parse_ref_line(line: &str) -> Result<RefLine, SessionError> {
let invalid = |line: &str| {
SessionError::Protocol(format!(
"the ls-refs response line {line:?} is not a V2 ref line \
(<'oid> <name>[ symref-target:..][ peeled:..]') — fail closed"
))
};
let mut segments = line.split(' ');
let (name, oid, attributes): (String, Option<ObjectId>, Vec<&str>) = match segments.next() {
Some("unborn") => match segments.next() {
Some(name) if !name.is_empty() => (name.to_owned(), None, segments.collect()),
_ => return Err(invalid(line)),
},
Some(hex)
if !hex.is_empty()
&& hex.bytes().all(|byte| byte.is_ascii_hexdigit())
&& (hex.len() == 40 || hex.len() == 64) =>
{
let oid = ObjectId::from_hex(hex.as_bytes())
.map_err(|_| invalid(line))
.map(|oid| (!oid.as_bytes().iter().all(|byte| *byte == 0)).then_some(oid))?;
match segments.next() {
Some(name) if !name.is_empty() => (name.to_owned(), oid, segments.collect()),
_ => return Err(invalid(line)),
}
}
Some(name) => match segments.next() {
Some(attribute) if !name.is_empty() => (
name.to_owned(),
None,
Some(attribute).into_iter().chain(segments).collect(),
),
_ => return Err(invalid(line)),
},
None => return Err(invalid(line)),
};
let peeled_oid = |hex: &str| ObjectId::from_hex(hex.as_bytes()).map_err(|_| invalid(line));
let mut symref_target = None;
let mut peeled = None;
for segment in attributes {
let (token, value) = match segment.split_once(':') {
Some(pair) => pair,
None => return Err(invalid(line)),
};
if value.is_empty() {
return Err(invalid(line));
}
match token {
"symref-target" => symref_target = (value != "(null)").then(|| value.to_owned()),
"peeled" => peeled = Some(peeled_oid(value)?),
_ => return Err(invalid(line)),
}
}
Ok(RefLine {
name,
oid,
peeled,
symref_target,
})
}
async fn pump_pack(
reader: &mut Box<dyn gix_transport::client::async_io::ExtendedBufRead<'_> + Unpin + '_>,
sink: &mut (impl futures::io::AsyncWrite + Unpin),
max_pack_size: u64,
) -> Result<u64, SessionError> {
drain_banded_payload(
reader,
sink,
true,
max_pack_size,
"the response pack",
"the packfile section",
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_lines_parse_back_with_every_unborn_shape() {
let oid = |byte: u8| ObjectId::from([byte; 20]);
let line = format!("{} refs/heads/main symref-target:refs/heads/x", oid(0x1a));
let parsed = parse_ref_line(line.trim_end()).expect("the ordinary line");
assert_eq!(
parsed,
RefLine {
name: "refs/heads/main".to_owned(),
oid: Some(oid(0x1a)),
peeled: None,
symref_target: Some("refs/heads/x".to_owned()),
}
);
let line = format!("{} refs/tags/v1 peeled:{}", oid(0x2b), oid(0x1a));
assert_eq!(
parse_ref_line(line.trim_end())
.expect("the peeled line")
.peeled,
Some(oid(0x1a))
);
for shape in [
"unborn HEAD symref-target:refs/heads/main".to_owned(),
format!("{} HEAD symref-target:refs/heads/main", "0".repeat(40)),
"HEAD symref-target:refs/heads/main".to_owned(),
] {
let parsed = parse_ref_line(shape.trim_end()).expect(shape.as_str());
assert_eq!(
parsed.oid, None,
"the unborn shape parses oid-less: {shape}"
);
assert_eq!(parsed.name, "HEAD");
assert_eq!(parsed.symref_target, Some("refs/heads/main".to_owned()));
}
let parsed =
parse_ref_line("0000000000000000000000000000000000000000 HEAD symref-target:(null)")
.expect("the (null) symref edge");
assert_eq!(parsed.name, "HEAD");
assert_eq!(parsed.oid, None);
assert_eq!(parsed.symref_target, None);
}
#[test]
fn ref_line_parse_refuses_off_grammar_lines_fail_closed() {
for line in ["", "HEAD", "zz HEAD", &format!("{} x", "0".repeat(41))] {
match parse_ref_line(line.trim_end()) {
Err(SessionError::Protocol(_)) => {}
other => panic!("expected the protocol error for {line:?}, got {other:?}"),
}
}
let bad = format!("{} refs/heads/x peeled:zz", "1".repeat(40));
match parse_ref_line(&bad) {
Err(SessionError::Protocol(_)) => {}
other => panic!("expected the protocol error for the undecodable peel, got {other:?}"),
}
}
#[test]
fn the_command_capabilities_are_the_agent_and_the_object_format_echo() {
let fixture = Capabilities::from_lines(
"version 2\nls-refs=unborn\nfetch=wait-for-done\nobject-format=sha1\n".into(),
)
.expect("the fixture capability list");
assert_eq!(
command_features(&fixture),
vec![
("agent", Some(CLIENT_AGENT.to_owned())),
("object-format", Some("sha1".to_owned())),
],
"the crate agent and the peer's advertised object-format echo"
);
let no_format = Capabilities::from_lines("version 2\nls-refs=unborn\n".into())
.expect("the fixture capability list");
assert_eq!(
command_features(&no_format),
vec![("agent", Some(CLIENT_AGENT.to_owned()))],
"no object-format echo when the peer advertised none"
);
assert_eq!(
prepare_ls_refs_args(&LsRefsArgs {
symrefs: true,
peel: true,
ref_prefixes: vec!["refs/heads/".to_owned()],
unborn: false,
})
.expect("the args")
.iter()
.map(|arg| String::from_utf8_lossy(arg).into_owned())
.collect::<Vec<_>>(),
vec!["symrefs", "peel", "ref-prefix refs/heads/"]
);
match prepare_ls_refs_args(&LsRefsArgs {
ref_prefixes: vec![String::new()],
..LsRefsArgs::default()
}) {
Err(SessionError::InvalidInput(message)) => {
assert!(message.contains("ref-prefix"), "{message}");
}
other => panic!("the empty prefix refuses locally, got {other:?}"),
}
}
}