Skip to main content

grit_lib/
protocol_v2.rs

1//! Pure protocol-v2 capability parsing and request-fragment building.
2//!
3//! These helpers operate purely on the textual capability/feature lines exchanged during a
4//! Git wire-protocol-v2 conversation (the lines between `version 2` and the first flush, plus
5//! the space-separated feature list inside a `fetch=` capability). They have no I/O, no
6//! environment access, and no transport-backend coupling, so they are shared by every v2 client
7//! transport (file://, git://, ssh, smart-HTTP) rather than duplicated per backend.
8//!
9//! See `Documentation/gitprotocol-v2.txt` and `serve.c` / `connect.c` / `fetch-pack.c` in Git.
10
11use std::collections::HashSet;
12
13/// A section header in a protocol-v2 `command=fetch` response.
14///
15/// See `Documentation/gitprotocol-v2.txt` ("The response of fetch is broken into a number of
16/// sections…").
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum FetchResponseSection {
20    /// Negotiation round-trips (`ACK`/`NAK`/`ready`); omitted when the server jumps straight to the pack.
21    Acknowledgments,
22    /// Shallow boundary updates when the client sent `deepen` / related lines.
23    ShallowInfo,
24    /// OID resolution for client `want-ref` lines when ref-in-want is enabled.
25    WantedRefs,
26    /// Alternate pack URIs when the client requested `packfile-uris`.
27    PackfileUris,
28    /// Side-band-64k framed pack data.
29    Packfile,
30}
31
32impl FetchResponseSection {
33    /// PKT-LINE section header written/read on the wire.
34    #[must_use]
35    pub const fn header(self) -> &'static str {
36        match self {
37            Self::Acknowledgments => "acknowledgments",
38            Self::ShallowInfo => "shallow-info",
39            Self::WantedRefs => "wanted-refs",
40            Self::PackfileUris => "packfile-uris",
41            Self::Packfile => "packfile",
42        }
43    }
44}
45
46/// Section order after negotiation completes: optional prelude sections, then `packfile`.
47///
48/// Matches gitprotocol-v2 (and upstream `upload-pack.c` after shallow/wanted-ref order was fixed).
49/// Returns true when `shallow-info` appears before `wanted-refs` in a fetch response prelude.
50#[must_use]
51pub fn fetch_prelude_shallow_before_wanted_refs(section_headers: &[&str]) -> bool {
52    let shallow = section_headers
53        .iter()
54        .position(|h| *h == FetchResponseSection::ShallowInfo.header());
55    let wanted = section_headers
56        .iter()
57        .position(|h| *h == FetchResponseSection::WantedRefs.header());
58    match (shallow, wanted) {
59        (Some(s), Some(w)) => s < w,
60        _ => true,
61    }
62}
63
64/// `FETCH_RESPONSE_SECTION_ORDER` constant.
65pub const FETCH_RESPONSE_SECTION_ORDER: &[FetchResponseSection] = &[
66    FetchResponseSection::Acknowledgments,
67    FetchResponseSection::ShallowInfo,
68    FetchResponseSection::WantedRefs,
69    FetchResponseSection::PackfileUris,
70    FetchResponseSection::Packfile,
71];
72
73/// True when the server's v2 capability advertisement offers the `bundle-uri` command.
74///
75/// Advertised either bare (`bundle-uri`) or with a value (`bundle-uri=...`).
76#[must_use]
77pub fn server_advertises_bundle_uri(caps: &[String]) -> bool {
78    caps.iter()
79        .any(|c| c == "bundle-uri" || c.starts_with("bundle-uri="))
80}
81
82/// True when the server's v2 capability advertisement includes `ls-refs=unborn`.
83///
84/// Required to learn the default branch symref on empty repositories over v2
85/// (`unborn HEAD symref-target:…` response lines).
86#[must_use]
87pub(crate) fn server_advertises_ls_refs_unborn(caps: &[String]) -> bool {
88    caps.iter().any(|c| {
89        c == "ls-refs=unborn"
90            || c.strip_prefix("ls-refs=")
91                .is_some_and(|rest| rest.split_whitespace().any(|tok| tok == "unborn"))
92    })
93}
94
95/// Build the capability lines a client echoes back when issuing a follow-up v2 command
96/// (e.g. `command=bundle-uri` or `command=fetch`).
97///
98/// Mirrors the client capability handling in `connect.c`: the `agent=` line is forwarded
99/// verbatim and `object-format=<hash>` is re-emitted. Other advertised capabilities are not
100/// echoed in the per-command capability list.
101#[must_use]
102pub fn cap_lines_for_command_request(caps: &[String]) -> Vec<String> {
103    let mut out = Vec::new();
104    for line in caps {
105        if line.starts_with("agent=") {
106            out.push(line.clone());
107        } else if let Some(fmt) = line.strip_prefix("object-format=") {
108            out.push(format!("object-format={fmt}"));
109        }
110    }
111    out
112}
113
114/// Collect the space-separated feature tokens advertised by the server's v2 `fetch=` capability.
115///
116/// Returns an empty set when the server advertises no `fetch=` capability.
117#[must_use]
118pub fn fetch_features(caps: &[String]) -> HashSet<String> {
119    let mut features = HashSet::new();
120    for line in caps {
121        if let Some(rest) = line.strip_prefix("fetch=") {
122            for feature in rest.split_whitespace() {
123                features.insert(feature.to_string());
124            }
125        }
126    }
127    features
128}
129
130/// True when the server's v2 `fetch=` capability lists `<feature>`.
131#[must_use]
132pub fn fetch_supports_feature(caps: &[String], feature: &str) -> bool {
133    caps.iter().any(|c| {
134        c.strip_prefix("fetch=")
135            .is_some_and(|rest| rest.split_whitespace().any(|w| w == feature))
136    })
137}
138
139/// True when the server's `fetch=` capability advertises `sideband-all`.
140#[must_use]
141pub fn fetch_supports_sideband_all(caps: &[String]) -> bool {
142    fetch_supports_feature(caps, "sideband-all")
143}
144
145/// True when the server's v2 `fetch=` capability advertises `ref-in-want` (so the client may send
146/// `want-ref <name>` lines instead of resolving named refspecs to OIDs itself).
147#[must_use]
148pub fn fetch_supports_ref_in_want(caps: &[String]) -> bool {
149    fetch_supports_feature(caps, "ref-in-want")
150}
151
152/// True when the server's v2 `fetch=` capability advertises `filter` (so the client may send a
153/// `filter <spec>` line).
154///
155/// Mirrors `fetch-pack.c` `send_filter`, which only writes the `filter` request line when the
156/// server advertised filtering support. A promisor remote without `uploadpack.allowFilter` does
157/// not advertise it, so the client must silently drop the filter and fetch unfiltered rather than
158/// send a line the server rejects with "unexpected line".
159#[must_use]
160pub fn fetch_supports_filter(caps: &[String]) -> bool {
161    fetch_supports_feature(caps, "filter")
162}
163
164/// Name of the protocol v2 capability a server uses to advertise its promisor remotes, and a
165/// client uses to say which of them it accepted (see
166/// [`crate::promisor_remote::PromisorAdvertisement`]).
167pub const PROMISOR_REMOTE_CAPABILITY: &str = "promisor-remote";
168
169/// The value of the server's `promisor-remote=<value>` capability, if it advertised one.
170///
171/// # Parameters
172///
173/// - `caps`: the v2 capability lines from the server's advertisement.
174///
175/// # Returns
176///
177/// The text after `promisor-remote=`, or `None` when the capability is absent.
178#[must_use]
179pub fn server_promisor_remote_info(caps: &[String]) -> Option<&str> {
180    caps.iter().find_map(|c| {
181        c.strip_prefix(PROMISOR_REMOTE_CAPABILITY)
182            .and_then(|rest| rest.strip_prefix('='))
183    })
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn s(v: &[&str]) -> Vec<String> {
191        v.iter().map(|x| (*x).to_owned()).collect()
192    }
193
194    #[test]
195    fn fetch_prelude_order_helper() {
196        assert!(fetch_prelude_shallow_before_wanted_refs(&[
197            "shallow-info",
198            "wanted-refs",
199            "packfile"
200        ]));
201        assert!(!fetch_prelude_shallow_before_wanted_refs(&[
202            "wanted-refs",
203            "shallow-info"
204        ]));
205    }
206
207    #[test]
208    fn fetch_response_section_order_matches_protocol_v2() {
209        assert_eq!(
210            FETCH_RESPONSE_SECTION_ORDER
211                .iter()
212                .map(|s| s.header())
213                .collect::<Vec<_>>(),
214            [
215                "acknowledgments",
216                "shallow-info",
217                "wanted-refs",
218                "packfile-uris",
219                "packfile"
220            ]
221        );
222    }
223
224    #[test]
225    fn ls_refs_unborn_capability() {
226        assert!(server_advertises_ls_refs_unborn(&s(&["ls-refs=unborn"])));
227        assert!(!server_advertises_ls_refs_unborn(&s(&["ls-refs"])));
228    }
229
230    #[test]
231    fn bundle_uri_bare_and_valued() {
232        assert!(server_advertises_bundle_uri(&s(&[
233            "agent=git/2",
234            "bundle-uri"
235        ])));
236        assert!(server_advertises_bundle_uri(&s(&["bundle-uri=foo"])));
237        assert!(!server_advertises_bundle_uri(&s(&[
238            "agent=git/2",
239            "ls-refs"
240        ])));
241        assert!(!server_advertises_bundle_uri(&s(&[])));
242    }
243
244    #[test]
245    fn cap_lines_forwards_agent_and_object_format() {
246        let caps = s(&[
247            "version 2",
248            "agent=git/2.43",
249            "ls-refs=unborn",
250            "object-format=sha256",
251            "fetch=shallow filter",
252        ]);
253        assert_eq!(
254            cap_lines_for_command_request(&caps),
255            s(&["agent=git/2.43", "object-format=sha256"])
256        );
257    }
258
259    #[test]
260    fn cap_lines_empty_when_no_agent_or_format() {
261        assert_eq!(
262            cap_lines_for_command_request(&s(&["version 2", "ls-refs"])),
263            Vec::<String>::new()
264        );
265    }
266
267    #[test]
268    fn fetch_features_splits_on_whitespace() {
269        let caps = s(&["fetch=shallow filter ref-in-want sideband-all"]);
270        let f = fetch_features(&caps);
271        assert!(f.contains("shallow"));
272        assert!(f.contains("filter"));
273        assert!(f.contains("ref-in-want"));
274        assert!(f.contains("sideband-all"));
275        assert_eq!(f.len(), 4);
276    }
277
278    #[test]
279    fn fetch_features_empty_without_fetch_cap() {
280        assert!(fetch_features(&s(&["ls-refs", "agent=x"])).is_empty());
281    }
282
283    #[test]
284    fn promisor_remote_info_value() {
285        let caps = s(&["agent=x", "promisor-remote=name=lop,url=file:///lop"]);
286        assert_eq!(
287            server_promisor_remote_info(&caps),
288            Some("name=lop,url=file:///lop")
289        );
290        assert_eq!(server_promisor_remote_info(&s(&["promisor-remote"])), None);
291        assert_eq!(server_promisor_remote_info(&s(&["agent=x"])), None);
292    }
293
294    #[test]
295    fn per_feature_helpers() {
296        let caps = s(&["fetch=ref-in-want filter sideband-all"]);
297        assert!(fetch_supports_sideband_all(&caps));
298        assert!(fetch_supports_ref_in_want(&caps));
299        assert!(fetch_supports_filter(&caps));
300        assert!(fetch_supports_feature(&caps, "ref-in-want"));
301        assert!(!fetch_supports_feature(&caps, "shallow"));
302
303        let none = s(&["fetch=shallow", "ls-refs"]);
304        assert!(!fetch_supports_sideband_all(&none));
305        assert!(!fetch_supports_ref_in_want(&none));
306        assert!(!fetch_supports_filter(&none));
307    }
308}