Skip to main content

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}