use std::collections::HashSet;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::AtomicBool;
use super::v2::Capabilities;
use super::{Advertisement, FetchError, FetchLimits, local_native, protocol};
use crate::packet::{Wire, packet};
use crate::refs::RefName;
use crate::transport::TransportControl;
use crate::{ObjectFormat, ObjectId, Repository};
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum ProtocolVersion {
Native,
V0,
V1,
V2,
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[non_exhaustive]
pub enum RemoteHead {
Symbolic {
branch: RefName,
id: ObjectId,
},
Unborn {
branch: RefName,
},
Inferred {
branch: RefName,
id: ObjectId,
},
Detached {
id: ObjectId,
},
Missing,
Ambiguous {
id: ObjectId,
branches: Vec<RefName>,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RemoteDiscovery {
pub advertisement: Advertisement,
pub object_format: ObjectFormat,
pub head: RemoteHead,
pub version: ProtocolVersion,
}
impl RemoteDiscovery {
pub fn from_advertisement(advertisement: Advertisement) -> Result<Self, FetchError> {
Self::from_inventory(advertisement, Inventory::Complete)
}
fn from_inventory(
advertisement: Advertisement,
inventory: Inventory,
) -> Result<Self, FetchError> {
let object_format = advertisement.object_format()?;
let mut names = HashSet::new();
for reference in &advertisement.refs {
if reference.id.is_null() || !names.insert((reference.name.clone(), reference.kind)) {
return Err(FetchError::Protocol("invalid advertised ref"));
}
}
for reference in &advertisement.refs {
if reference.kind == crate::fetch::AdvertisementKind::PeeledHint
&& (!reference.name.as_bytes().starts_with(b"refs/tags/")
|| !names
.contains(&(reference.name.clone(), crate::fetch::AdvertisementKind::Tip)))
{
return Err(FetchError::Protocol("unmatched peeled hint"));
}
}
let head = interpret_head_in(&advertisement, inventory)?;
Ok(Self {
advertisement,
object_format,
head,
version: ProtocolVersion::V0,
})
}
pub fn require_format(&self, expected: ObjectFormat) -> Result<(), FetchError> {
if self.object_format == expected {
Ok(())
} else {
Err(crate::ObjectFormatError {
expected,
actual: self.object_format,
}
.into())
}
}
pub fn advertises(&self, capability: &[u8]) -> bool {
self.advertisement.has(capability)
}
pub fn supports_current_fetch(&self) -> bool {
match self.version {
ProtocolVersion::Native => true,
ProtocolVersion::V0 | ProtocolVersion::V1 => self.advertises(b"side-band-64k"),
ProtocolVersion::V2 => self
.advertisement
.capabilities
.iter()
.any(|capability| capability == b"fetch" || capability.starts_with(b"fetch=")),
}
}
}
pub fn discover(
reader: &mut impl Read,
limits: FetchLimits,
cancel: &AtomicBool,
) -> Result<RemoteDiscovery, FetchError> {
traced("stream", || {
let mut wire = Wire {
reader,
remaining: limits.max_wire_bytes,
cancel,
};
let (advertisement, version) = protocol::advertise_with_version(&mut wire, limits)?;
wire.end()?;
let mut discovered = RemoteDiscovery::from_advertisement(advertisement)?;
discovered.version = version;
Ok(discovered)
})
}
pub fn discover_session(
reader: &mut impl Read,
writer: &mut impl Write,
limits: FetchLimits,
cancel: &AtomicBool,
) -> Result<RemoteDiscovery, FetchError> {
discover_session_with_prefixes(reader, writer, &[], limits, cancel)
}
pub fn discover_session_with_prefixes(
reader: &mut impl Read,
writer: &mut impl Write,
prefixes: &[&[u8]],
limits: FetchLimits,
cancel: &AtomicBool,
) -> Result<RemoteDiscovery, FetchError> {
traced("session", || {
super::v2::validate_prefixes(prefixes, limits)?;
let budget = limits.max_wire_bytes.min(limits.max_advertisement_bytes);
let mut wire = Wire {
reader,
remaining: budget,
cancel,
};
let first = wire
.packet()?
.ok_or(FetchError::Protocol("empty protocol advertisement"))?;
if first == b"version 2\n" {
return discover_v2(wire, writer, prefixes, limits);
}
let mut prefix = Vec::new();
packet(&mut prefix, &first, cancel)?;
let mut stream = std::io::Cursor::new(prefix).chain(wire.reader);
let mut legacy = Wire {
reader: &mut stream,
remaining: budget,
cancel,
};
let (advertisement, version) = protocol::advertise_with_version(&mut legacy, limits)?;
let mut discovered = RemoteDiscovery::from_advertisement(advertisement)?;
discovered.version = version;
Ok(discovered)
})
}
fn discover_v2(
mut wire: Wire<'_, impl Read>,
writer: &mut impl Write,
prefixes: &[&[u8]],
limits: FetchLimits,
) -> Result<RemoteDiscovery, FetchError> {
let capabilities = Capabilities::read(&mut wire, limits)?;
let advertisement = capabilities.ls_refs(&mut wire, writer, prefixes, limits)?;
let inventory = if prefixes.is_empty() {
Inventory::Complete
} else {
Inventory::Selected
};
let mut discovered = RemoteDiscovery::from_inventory(advertisement, inventory)?;
discovered.version = ProtocolVersion::V2;
Ok(discovered)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Inventory {
Complete,
Selected,
}
pub fn discover_local(
source: impl AsRef<Path>,
limits: FetchLimits,
control: TransportControl<'_>,
) -> Result<RemoteDiscovery, FetchError> {
traced("local", || {
control.check()?;
let source =
Repository::open(source).map_err(|error| FetchError::Source(Box::new(error)))?;
let advertisement = local_native::advertise(&source, limits, control)?;
let mut discovered = RemoteDiscovery::from_advertisement(advertisement)?;
discovered.version = ProtocolVersion::Native;
let head = RefName::new(b"HEAD").expect("valid HEAD");
if let Some(crate::refs::Target::Direct(id)) = source
.references()
.map_err(|_| FetchError::Protocol("local references"))?
.read(&head)
.map_err(|_| FetchError::Protocol("local HEAD"))?
&& !discovered.advertisement.refs.iter().any(|reference| {
reference.name == head
&& reference.id == id
&& reference.kind == crate::fetch::AdvertisementKind::Tip
})
{
return Err(FetchError::Protocol("local HEAD changed during discovery"));
}
control.check()?;
Ok(discovered)
})
}
fn traced(
endpoint: &'static str,
operation: impl FnOnce() -> Result<RemoteDiscovery, FetchError>,
) -> Result<RemoteDiscovery, FetchError> {
#[cfg(feature = "tracing")]
{
let span = tracing::debug_span!(
target: "girt",
"fetch.discover",
endpoint,
outcome = "incomplete",
failure_class = tracing::field::Empty,
);
let result = span.in_scope(operation);
crate::trace::finish(&span, &result, crate::trace::fetch);
result
}
#[cfg(not(feature = "tracing"))]
{
let _ = endpoint;
operation()
}
}
#[cfg(feature = "http")]
pub async fn discover_http(
remote: &crate::transport::http::HttpRemote,
limits: FetchLimits,
control: TransportControl<'_>,
) -> Result<RemoteDiscovery, FetchError> {
traced_async("http", async {
control.check()?;
let (advertisement, _, version, _) = super::http::open(remote, limits, control).await?;
let mut discovered = RemoteDiscovery::from_advertisement(advertisement)?;
discovered.version = version;
Ok(discovered)
})
.await
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
pub async fn discover_ssh(
remote: &crate::transport::ssh::SshRemote,
limits: FetchLimits,
control: TransportControl<'_>,
) -> Result<RemoteDiscovery, FetchError> {
discover_ssh_with_diagnostics(remote, limits, control, |_| {}).await
}
#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
pub async fn discover_ssh_with_diagnostics(
remote: &crate::transport::ssh::SshRemote,
limits: FetchLimits,
control: TransportControl<'_>,
mut diagnostics: impl FnMut(&[u8]),
) -> Result<RemoteDiscovery, FetchError> {
traced_async("ssh", async {
control.check()?;
let mut session = remote.connect("git-upload-pack", control)?;
let (advertisement, _, version, used) = super::ssh::open(
&mut session,
remote.protocol(),
limits,
control,
&mut diagnostics,
)
.await?;
let mut discovered = RemoteDiscovery::from_advertisement(advertisement)?;
discovered.version = version;
let (body, result, _) = session
.exchange_with_diagnostics(
b"0000",
&[],
limits.max_wire_bytes - used,
control,
&mut diagnostics,
)
.await;
result?;
if !body.is_empty() {
return Err(FetchError::Protocol("trailing discovery response"));
}
Ok(discovered)
})
.await
}
#[cfg(any(
feature = "http",
all(feature = "ssh", any(target_os = "macos", target_os = "linux"))
))]
async fn traced_async(
endpoint: &'static str,
operation: impl std::future::Future<Output = Result<RemoteDiscovery, FetchError>>,
) -> Result<RemoteDiscovery, FetchError> {
#[cfg(feature = "tracing")]
{
let span = tracing::debug_span!(
target: "girt",
"fetch.discover",
endpoint,
outcome = "incomplete",
failure_class = tracing::field::Empty,
);
let result = tracing::Instrument::instrument(operation, span.clone()).await;
crate::trace::finish(&span, &result, crate::trace::fetch);
result
}
#[cfg(not(feature = "tracing"))]
{
let _ = endpoint;
operation.await
}
}
pub(crate) fn interpret_head(advertisement: &Advertisement) -> Result<RemoteHead, FetchError> {
interpret_head_in(advertisement, Inventory::Complete)
}
fn interpret_head_in(
advertisement: &Advertisement,
inventory: Inventory,
) -> Result<RemoteHead, FetchError> {
let mut hint = None;
for capability in &advertisement.capabilities {
if let Some(target) = capability.strip_prefix(b"symref=HEAD:") {
let branch =
RefName::new(target).map_err(|_| FetchError::Protocol("HEAD symbolic target"))?;
if !branch.as_bytes().starts_with(b"refs/heads/") || hint.replace(branch).is_some() {
return Err(FetchError::Protocol("HEAD symbolic target"));
}
}
}
let head = advertisement
.refs
.iter()
.filter(|reference| {
reference.name.as_bytes() == b"HEAD"
&& reference.kind == crate::fetch::AdvertisementKind::Tip
})
.map(|reference| reference.id)
.next();
if let Some(branch) = hint {
let target = advertisement
.refs
.iter()
.find(|reference| {
reference.name == branch && reference.kind == crate::fetch::AdvertisementKind::Tip
})
.map(|reference| reference.id);
return match (head, target) {
(Some(id), Some(target)) if id == target => Ok(RemoteHead::Symbolic { branch, id }),
(Some(id), None) if inventory == Inventory::Selected => {
Ok(RemoteHead::Symbolic { branch, id })
}
(None, None) => Ok(RemoteHead::Unborn { branch }),
_ => Err(FetchError::Protocol("inconsistent symbolic HEAD")),
};
}
let Some(id) = head else {
return Ok(RemoteHead::Missing);
};
let mut branches: Vec<_> = advertisement
.refs
.iter()
.filter(|reference| {
reference.kind == crate::fetch::AdvertisementKind::Tip
&& reference.name.as_bytes().starts_with(b"refs/heads/")
&& reference.id == id
})
.map(|reference| reference.name.clone())
.collect();
branches.sort();
match branches.len() {
0 => Ok(RemoteHead::Detached { id }),
1 => Ok(RemoteHead::Inferred {
branch: branches.remove(0),
id,
}),
_ => Ok(RemoteHead::Ambiguous { id, branches }),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn reference(name: &[u8], id: ObjectId) -> super::super::AdvertisedRef {
super::super::AdvertisedRef {
name: RefName::new(name).unwrap(),
id,
kind: crate::fetch::AdvertisementKind::Tip,
}
}
#[rstest]
#[case::unborn(vec![], vec![b"symref=HEAD:refs/heads/main".to_vec()], RemoteHead::Unborn { branch: RefName::new(b"refs/heads/main").unwrap() })]
#[case::missing(vec![], vec![], RemoteHead::Missing)]
#[case::symbolic(vec![reference(b"HEAD", ObjectId::Sha1([1; 20])), reference(b"refs/heads/main", ObjectId::Sha1([1; 20]))], vec![b"symref=HEAD:refs/heads/main".to_vec()], RemoteHead::Symbolic { branch: RefName::new(b"refs/heads/main").unwrap(), id: ObjectId::Sha1([1; 20]) })]
#[case::inferred(vec![reference(b"HEAD", ObjectId::Sha1([1; 20])), reference(b"refs/heads/main", ObjectId::Sha1([1; 20]))], vec![], RemoteHead::Inferred { branch: RefName::new(b"refs/heads/main").unwrap(), id: ObjectId::Sha1([1; 20]) })]
#[case::detached(vec![reference(b"HEAD", ObjectId::Sha1([1; 20]))], vec![], RemoteHead::Detached { id: ObjectId::Sha1([1; 20]) })]
#[case::ambiguous(vec![reference(b"HEAD", ObjectId::Sha1([1; 20])), reference(b"refs/heads/z", ObjectId::Sha1([1; 20])), reference(b"refs/heads/a", ObjectId::Sha1([1; 20]))], vec![], RemoteHead::Ambiguous { id: ObjectId::Sha1([1; 20]), branches: vec![RefName::new(b"refs/heads/a").unwrap(), RefName::new(b"refs/heads/z").unwrap()] })]
fn interprets_head_without_guessing(
#[case] refs: Vec<super::super::AdvertisedRef>,
#[case] capabilities: Vec<Vec<u8>>,
#[case] expected: RemoteHead,
) {
let discovered =
RemoteDiscovery::from_advertisement(Advertisement { refs, capabilities }).unwrap();
assert_eq!(discovered.head, expected);
}
#[test]
fn omitted_target_is_symbolic_only_in_a_selected_inventory() {
let advertisement = Advertisement {
refs: vec![reference(b"HEAD", ObjectId::Sha1([1; 20]))],
capabilities: vec![b"symref=HEAD:refs/heads/main".to_vec()],
};
assert!(matches!(
interpret_head_in(&advertisement, Inventory::Selected),
Ok(RemoteHead::Symbolic { .. })
));
assert!(matches!(
interpret_head(&advertisement),
Err(FetchError::Protocol("inconsistent symbolic HEAD"))
));
}
#[test]
fn rejects_conflicting_symbolic_head() {
let advertisement = Advertisement {
refs: vec![
reference(b"HEAD", ObjectId::Sha1([1; 20])),
reference(b"refs/heads/main", ObjectId::Sha1([2; 20])),
],
capabilities: vec![b"symref=HEAD:refs/heads/main".to_vec()],
};
assert!(matches!(
RemoteDiscovery::from_advertisement(advertisement),
Err(FetchError::Protocol("inconsistent symbolic HEAD"))
));
}
#[test]
fn format_mismatch_preserves_both_formats_in_error() {
let discovery = RemoteDiscovery::from_advertisement(Advertisement {
refs: vec![],
capabilities: vec![b"object-format=sha256".to_vec()],
})
.unwrap();
assert!(matches!(
discovery.require_format(ObjectFormat::Sha1),
Err(FetchError::ObjectFormat(crate::ObjectFormatError {
expected: ObjectFormat::Sha1,
actual: ObjectFormat::Sha256
}))
));
}
fn v2_discovery(capabilities: &[&[u8]], refs: &[&[u8]]) -> Result<RemoteDiscovery, FetchError> {
let cancel = AtomicBool::new(false);
let mut input = Vec::new();
packet(&mut input, b"version 2\n", &cancel).unwrap();
for line in capabilities {
packet(&mut input, line, &cancel).unwrap();
}
input.extend_from_slice(b"0000");
for line in refs {
packet(&mut input, line, &cancel).unwrap();
}
input.extend_from_slice(b"0000");
discover_session(
&mut input.as_slice(),
&mut Vec::new(),
FetchLimits::default(),
&cancel,
)
}
#[test]
fn v2_symref_capability_cannot_invent_unborn_head() {
let discovered = v2_discovery(
&[
b"ls-refs\n",
b"symref=HEAD:refs/heads/main\n",
b"other=kept\n",
],
&[],
)
.unwrap();
assert_eq!(discovered.head, RemoteHead::Missing);
assert!(discovered.advertises(b"other=kept"));
assert!(!discovered.advertises(b"symref=HEAD:refs/heads/main"));
}
#[test]
fn v2_symref_capability_does_not_duplicate_ls_refs_head() {
let id = "1111111111111111111111111111111111111111";
let head = format!("{id} HEAD symref-target:refs/heads/main\n");
let main = format!("{id} refs/heads/main\n");
let discovered = v2_discovery(
&[b"ls-refs\n", b"symref=HEAD:refs/heads/other\n"],
&[head.as_bytes(), main.as_bytes()],
)
.unwrap();
assert!(matches!(
discovered.head,
RemoteHead::Symbolic { branch, .. } if branch.as_bytes() == b"refs/heads/main"
));
}
#[test]
fn v2_unborn_sha256_requests_advertised_options() {
let cancel = AtomicBool::new(false);
let mut input = Vec::new();
for line in [
b"version 2\n".as_slice(),
b"ls-refs=unborn\n",
b"object-format=sha256\n",
] {
packet(&mut input, line, &cancel).unwrap();
}
input.extend_from_slice(b"0000");
packet(
&mut input,
b"unborn HEAD symref-target:refs/heads/main\n",
&cancel,
)
.unwrap();
input.extend_from_slice(b"0000");
let mut request = Vec::new();
let discovered = discover_session(
&mut input.as_slice(),
&mut request,
FetchLimits::default(),
&cancel,
)
.unwrap();
assert_eq!(discovered.object_format, ObjectFormat::Sha256);
assert_eq!(discovered.version, ProtocolVersion::V2);
assert!(matches!(discovered.head, RemoteHead::Unborn { .. }));
assert!(
request
.windows(b"object-format=sha256".len())
.any(|w| w == b"object-format=sha256")
);
assert!(request.windows(b"unborn\n".len()).any(|w| w == b"unborn\n"));
}
#[rstest]
#[case::unknown_format(b"object-format=sha999\n")]
#[case::duplicate_format(b"object-format=sha1\n")]
fn rejects_invalid_v2_format_before_request(#[case] extra: &[u8]) {
let cancel = AtomicBool::new(false);
let mut input = Vec::new();
for line in [
b"version 2\n".as_slice(),
b"ls-refs\n",
b"object-format=sha1\n",
extra,
] {
packet(&mut input, line, &cancel).unwrap();
}
input.extend_from_slice(b"0000");
let mut request = Vec::new();
assert!(
discover_session(
&mut input.as_slice(),
&mut request,
FetchLimits::default(),
&cancel
)
.is_err()
);
assert!(request.is_empty());
}
#[test]
fn cancelled_session_does_not_read_or_write() {
let cancel = AtomicBool::new(true);
let mut output = Vec::new();
let result = discover_session(
&mut b"000e version".as_slice(),
&mut output,
FetchLimits::default(),
&cancel,
);
assert!(matches!(result, Err(FetchError::Cancelled)));
assert!(output.is_empty());
}
#[test]
fn v2_ref_limit_rejects_second_entry() {
let cancel = AtomicBool::new(false);
let mut input = Vec::new();
packet(&mut input, b"version 2\n", &cancel).unwrap();
packet(&mut input, b"ls-refs\n", &cancel).unwrap();
input.extend_from_slice(b"0000");
packet(
&mut input,
b"1111111111111111111111111111111111111111 refs/heads/a\n",
&cancel,
)
.unwrap();
packet(
&mut input,
b"2222222222222222222222222222222222222222 refs/heads/b\n",
&cancel,
)
.unwrap();
input.extend_from_slice(b"0000");
let limits = FetchLimits {
max_refs: 1,
..FetchLimits::default()
};
let result = discover_session(&mut input.as_slice(), &mut Vec::new(), limits, &cancel);
assert!(matches!(result, Err(FetchError::Limit("advertised refs"))));
}
#[test]
fn v2_advertisement_byte_limit_prevents_request() {
let cancel = AtomicBool::new(false);
let mut request = Vec::new();
let result = discover_session(
&mut b"000eversion 2\n0000".as_slice(),
&mut request,
FetchLimits {
max_advertisement_bytes: 8,
..FetchLimits::default()
},
&cancel,
);
assert!(matches!(result, Err(FetchError::Limit(_))));
assert!(request.is_empty());
}
}