gunnar_sendpack/advertisement.rs
1//! The reference advertisement `git-receive-pack` sends before the client
2//! speaks — parsed for the client, rendered for the server.
3//!
4//! ```text
5//! <old-oid> <refname>\0<capabilities>\n the first line, and only the first
6//! <old-oid> <refname>\n …one per ref
7//! 0000
8//! ```
9
10use bstr::{BString, ByteSlice};
11use gix_hash::{Kind, ObjectId};
12
13use crate::capabilities::{self, Capabilities};
14use crate::error::{Error, Result};
15
16/// The pseudo-ref an **empty** repository advertises so that it can still carry
17/// a capability list.
18///
19/// It is not a reference and must never become one: a client that recorded it
20/// would go on to push against `capabilities^{}`.
21pub const NO_REFS_PSEUDO_REF: &str = "capabilities^{}";
22
23/// One ref as the remote advertised it.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct RemoteRef {
26 /// Full ref name, e.g. `refs/heads/main`. Bytes, not UTF-8.
27 pub name: BString,
28 /// The object it currently points at.
29 pub oid: ObjectId,
30}
31
32impl RemoteRef {
33 /// A ref at `oid`.
34 pub fn new(name: impl Into<BString>, oid: ObjectId) -> Self {
35 RemoteRef {
36 name: name.into(),
37 oid,
38 }
39 }
40}
41
42/// Everything `git-receive-pack` says before the client speaks.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Advertisement {
45 /// The remote's refs. **Empty for an empty repository**, which the wire
46 /// signals with [`NO_REFS_PSEUDO_REF`] rather than with no lines at all,
47 /// and which is therefore not the same as "the advertisement was empty".
48 pub refs: Vec<RemoteRef>,
49 /// What the remote can do.
50 pub capabilities: Capabilities,
51 /// The remote's object format, from `object-format=` when advertised and
52 /// otherwise inferred from the width of the advertised oids.
53 pub hash_kind: Kind,
54}
55
56impl Advertisement {
57 /// The oid `name` currently points at, or the null oid. That null is the
58 /// value a create must send as its `<old>`.
59 pub fn oid_of(&self, name: impl AsRef<[u8]>) -> ObjectId {
60 let name = name.as_ref();
61 self.refs
62 .iter()
63 .find(|r| r.name == name)
64 .map(|r| r.oid)
65 .unwrap_or_else(|| ObjectId::null(self.hash_kind))
66 }
67
68 /// Was `name` advertised at all?
69 pub fn has_ref(&self, name: impl AsRef<[u8]>) -> bool {
70 let name = name.as_ref();
71 self.refs.iter().any(|r| r.name == name)
72 }
73}
74
75/// Parse an already-unframed advertisement.
76///
77/// Separated from the I/O so the shapes that matter — an empty repository,
78/// sha256 oids, `shallow` lines, a server too old to advertise
79/// `object-format` — are asserted from byte literals rather than from a live
80/// server.
81pub fn parse(payloads: &[Vec<u8>]) -> Result<Advertisement> {
82 let mut refs: Vec<RemoteRef> = Vec::new();
83 let mut caps = Capabilities::default();
84 let mut seen_first = false;
85 let mut oid_width: Option<usize> = None;
86
87 for payload in payloads {
88 let line = chomp(payload);
89 // A `version 1` line may lead the advertisement when the client asked
90 // for protocol v1. It is not a ref.
91 if line == b"version 1" {
92 continue;
93 }
94 // `ERR <msg>` may stand where the advertisement would have been: the
95 // remote is refusing, in a sentence. It must be read before anything
96 // splits the line on a space, or `ERR` becomes an oid and the refusal
97 // becomes the ref name. See [`Error::Remote`].
98 if let Some(msg) = line.strip_prefix(b"ERR ".as_slice()) {
99 return Err(Error::Remote(msg.as_bstr().to_string()));
100 }
101 let (ref_part, caps_part) = capabilities::split(line);
102 if let (false, Some(raw)) = (seen_first, caps_part) {
103 caps = Capabilities::parse_bytes(raw)?;
104 }
105 seen_first = true;
106
107 // `shallow <oid>` lines describe the remote's grafts, not its refs.
108 if ref_part.starts_with(b"shallow ") {
109 continue;
110 }
111 let Some(space) = ref_part.find_byte(b' ') else {
112 return Err(Error::protocol(format!(
113 "ref advertisement line has no space: {:?}",
114 ref_part.as_bstr()
115 )));
116 };
117 let (oid_hex, name) = (&ref_part[..space], &ref_part[space + 1..]);
118 oid_width.get_or_insert(oid_hex.len());
119 if name == NO_REFS_PSEUDO_REF.as_bytes() {
120 continue;
121 }
122 let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
123 Error::protocol(format!(
124 "bad object id {:?} for ref {:?}: {e}",
125 oid_hex.as_bstr(),
126 name.as_bstr()
127 ))
128 })?;
129 refs.push(RemoteRef {
130 name: name.into(),
131 oid,
132 });
133 }
134
135 // `object-format` is authoritative; oid width is the fallback for a server
136 // too old to advertise it. Answering the wrong hash kind does not fail
137 // loudly — it returns an empty response — so it is never guessed silently
138 // when the remote has stated it.
139 let hash_kind = match caps.value("object-format") {
140 Some(name) => object_format_kind(name)?,
141 None => match oid_width {
142 Some(64) => Kind::Sha256,
143 _ => Kind::Sha1,
144 },
145 };
146 for r in &refs {
147 if r.oid.kind() != hash_kind {
148 return Err(Error::protocol(format!(
149 "remote advertised {:?} as a {:?} oid but the repository is {hash_kind:?}",
150 r.name.as_bstr(),
151 r.oid.kind()
152 )));
153 }
154 }
155
156 Ok(Advertisement {
157 refs,
158 capabilities: caps,
159 hash_kind,
160 })
161}
162
163/// Render an advertisement as payload lines, **without** pkt-line framing and
164/// without the terminating flush-pkt: the caller owns the framer.
165///
166/// This is the server half of [`parse`], and the reason the two are in one file
167/// is that they are one format. A repository with no refs still emits exactly
168/// one line, carrying [`NO_REFS_PSEUDO_REF`] and the null oid, because a client
169/// that received nothing could not learn the hash algorithm.
170pub fn lines(refs: &[RemoteRef], caps: &[String], hash_kind: Kind) -> Vec<Vec<u8>> {
171 let mut out = Vec::with_capacity(refs.len().max(1));
172 for (i, r) in refs.iter().enumerate() {
173 let mut line = format!("{} ", r.oid.to_hex()).into_bytes();
174 line.extend_from_slice(r.name.as_slice());
175 if i == 0 {
176 capabilities::attach(&mut line, caps);
177 }
178 out.push(line);
179 }
180 if out.is_empty() {
181 let mut line = format!(
182 "{} {NO_REFS_PSEUDO_REF}",
183 ObjectId::null(hash_kind).to_hex()
184 )
185 .into_bytes();
186 capabilities::attach(&mut line, caps);
187 out.push(line);
188 }
189 out
190}
191
192/// git's name for a hash algorithm, as it appears in `object-format=`.
193///
194/// `gix_hash::Kind` is `#[non_exhaustive]`, so a hash kind this build has never
195/// heard of is an **error** rather than a silent fallback to `sha1`. A default
196/// arm here is exactly how the wrong-object-format bug comes back.
197pub fn object_format_name(kind: Kind) -> Result<&'static str> {
198 match kind {
199 Kind::Sha1 => Ok("sha1"),
200 Kind::Sha256 => Ok("sha256"),
201 other => Err(Error::protocol(format!(
202 "this build has no `object-format` name for {other:?}"
203 ))),
204 }
205}
206
207/// The inverse of [`object_format_name`].
208pub fn object_format_kind(name: &str) -> Result<Kind> {
209 match name {
210 "sha1" => Ok(Kind::Sha1),
211 "sha256" => Ok(Kind::Sha256),
212 other => Err(Error::protocol(format!(
213 "peer advertised an unknown object-format {other:?}"
214 ))),
215 }
216}
217
218/// Strip one trailing newline, the way git's own reader does.
219pub(crate) fn chomp(line: &[u8]) -> &[u8] {
220 line.strip_suffix(b"\n").unwrap_or(line)
221}