Skip to main content

grit_lib/
promisor_remote.rs

1//! The "promisor-remote" protocol v2 capability (see `gitprotocol-v2(5)` and
2//! `Documentation/config/promisor.adoc`).
3//!
4//! A server that uses promisor remotes (large-object promisors / LOPs) can advertise them to a
5//! client, which may then accept some of them and lazily fetch the omitted objects from those
6//! remotes directly instead of forcing the server to back-fill and serve them.
7//!
8//! This module provides Git-compatible promisor-remote handling:
9//!
10//! - [`promisor_remote_info`] builds the server's advertisement from config
11//!   (`promisor.advertise`, `promisor.sendFields`, `remote.<name>.{url,partialCloneFilter,token}`).
12//! - [`promisor_remote_reply`] is the client side: given the server advertisement and the client's
13//!   config (`promisor.acceptFromServer`, `promisor.checkFields`), decide which advertised remotes
14//!   to accept and produce the reply string.
15
16use crate::config::ConfigSet;
17
18/// One advertised or configured promisor remote.
19#[derive(Debug, Clone, Default)]
20pub struct PromisorInfo {
21    pub name: String,
22    pub url: Option<String>,
23    pub filter: Option<String>,
24    pub token: Option<String>,
25}
26
27const FIELD_FILTER: &str = "partialclonefilter";
28const FIELD_TOKEN: &str = "token";
29
30/// Whether `field` (case-insensitive) is one of the optional fields we understand.
31fn is_known_field(field: &str) -> bool {
32    let f = field.to_ascii_lowercase();
33    f == FIELD_FILTER || f == FIELD_TOKEN
34}
35
36/// Parse a comma/space separated `promisor.{send,check,store}Fields` list into normalized
37/// (lowercased) known field names, in order, dropping unknown fields.
38fn parse_fields_config(cfg: &ConfigSet, key: &str) -> Vec<String> {
39    let Some(raw) = cfg.get(key) else {
40        return Vec::new();
41    };
42    raw.split([',', ' ', '\t'])
43        .map(str::trim)
44        .filter(|s| !s.is_empty())
45        .filter(|s| is_known_field(s))
46        .map(|s| s.to_ascii_lowercase())
47        .collect()
48}
49
50/// URL-encode using Git's `allow_unsanitized` predicate (`promisor-remote.c`): only `,`, `;`, `%`
51/// and non-printable / non-ASCII bytes are percent-encoded; everything else passes through.
52fn urlencode(s: &str) -> String {
53    let mut out = String::with_capacity(s.len());
54    for &b in s.as_bytes() {
55        let allow = b != b',' && b != b';' && b != b'%' && b > 32 && b < 127;
56        if allow {
57            out.push(b as char);
58        } else {
59            out.push('%');
60            out.push_str(&format!("{b:02X}"));
61        }
62    }
63    out
64}
65
66/// Percent-decode a field value (matches Git's `url_percent_decode`).
67fn urldecode(s: &str) -> String {
68    let bytes = s.as_bytes();
69    let mut out = Vec::with_capacity(bytes.len());
70    let mut i = 0;
71    while i < bytes.len() {
72        if bytes[i] == b'%' && i + 2 < bytes.len() {
73            let hi = (bytes[i + 1] as char).to_digit(16);
74            let lo = (bytes[i + 2] as char).to_digit(16);
75            if let (Some(hi), Some(lo)) = (hi, lo) {
76                out.push((hi * 16 + lo) as u8);
77                i += 3;
78                continue;
79            }
80        }
81        out.push(bytes[i]);
82        i += 1;
83    }
84    String::from_utf8_lossy(&out).into_owned()
85}
86
87/// All promisor remotes configured on this repository, in config order, that also have a non-empty
88/// `remote.<name>.url`. Matching Git's `promisor_remote_config`, a remote is a promisor remote when
89/// `remote.<name>.promisor` is true OR `remote.<name>.partialCloneFilter` is set. Each remote's
90/// `partialCloneFilter` and `token` are populated only when listed in `field_names`.
91fn config_info_list(cfg: &ConfigSet, field_names: &[String]) -> Vec<PromisorInfo> {
92    // Discover promisor remote names in first-seen config order.
93    let mut names: Vec<String> = Vec::new();
94    for e in cfg.entries() {
95        let Some(rest) = e.key.strip_prefix("remote.") else {
96            continue;
97        };
98        let is_promisor = if let Some(name) = rest.strip_suffix(".promisor") {
99            // Bare boolean keys store None -> treated as "true".
100            let val = e.value.clone().unwrap_or_else(|| "true".to_owned());
101            val.eq_ignore_ascii_case("true").then(|| name.to_string())
102        } else if let Some(name) = rest.strip_suffix(".partialclonefilter") {
103            Some(name.to_string())
104        } else {
105            None
106        };
107        if let Some(name) = is_promisor {
108            if !names.contains(&name) {
109                names.push(name);
110            }
111        }
112    }
113
114    let mut out = Vec::new();
115    for name in names {
116        let url = cfg.get(&format!("remote.{name}.url"));
117        // Only advertise remotes with a non-empty URL.
118        let Some(url) = url.filter(|u| !u.is_empty()) else {
119            continue;
120        };
121
122        let mut info = PromisorInfo {
123            name: name.clone(),
124            url: Some(url),
125            ..Default::default()
126        };
127        for field in field_names {
128            let key = format!("remote.{name}.{field}");
129            if let Some(v) = cfg.get(&key).filter(|v| !v.is_empty()) {
130                match field.as_str() {
131                    FIELD_FILTER => info.filter = Some(v),
132                    FIELD_TOKEN => info.token = Some(v),
133                    _ => {}
134                }
135            }
136        }
137        out.push(info);
138    }
139    out
140}
141
142/// Build the server's `promisor-remote=<info>` advertisement value, or `None` when
143/// `promisor.advertise` is not true or there are no advertisable promisor remotes.
144#[must_use]
145pub fn promisor_remote_info(cfg: &ConfigSet) -> Option<String> {
146    let advertise = cfg
147        .get_bool("promisor.advertise")
148        .and_then(|r| r.ok())
149        .unwrap_or(false);
150    if !advertise {
151        return None;
152    }
153
154    let fields = parse_fields_config(cfg, "promisor.sendFields");
155    let list = config_info_list(cfg, &fields);
156    if list.is_empty() {
157        return None;
158    }
159
160    let mut sb = String::new();
161    for (i, p) in list.iter().enumerate() {
162        if i != 0 {
163            sb.push(';');
164        }
165        sb.push_str("name=");
166        sb.push_str(&urlencode(&p.name));
167        sb.push_str(",url=");
168        sb.push_str(&urlencode(p.url.as_deref().unwrap_or("")));
169        if let Some(f) = &p.filter {
170            sb.push_str(",partialCloneFilter=");
171            sb.push_str(&urlencode(f));
172        }
173        if let Some(t) = &p.token {
174            sb.push_str(",token=");
175            sb.push_str(&urlencode(t));
176        }
177    }
178    Some(sb)
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182enum Accept {
183    None,
184    KnownUrl,
185    KnownName,
186    All,
187}
188
189fn parse_accept(cfg: &ConfigSet) -> Accept {
190    match cfg.get("promisor.acceptFromServer") {
191        Some(s) if s.eq_ignore_ascii_case("knownurl") => Accept::KnownUrl,
192        Some(s) if s.eq_ignore_ascii_case("knownname") => Accept::KnownName,
193        Some(s) if s.eq_ignore_ascii_case("all") => Accept::All,
194        _ => Accept::None,
195    }
196}
197
198/// Parse one `pr-fields` element (e.g. `name=lop,url=...,partialCloneFilter=...`) from the
199/// server advertisement. Returns `None` when the mandatory `name`/`url` are missing.
200fn parse_one_advertised(remote_info: &str) -> Option<PromisorInfo> {
201    let mut info = PromisorInfo::default();
202    for elem in remote_info.split(',') {
203        let Some(eq) = elem.find('=') else {
204            continue;
205        };
206        let (field, value) = (&elem[..eq], &elem[eq + 1..]);
207        let value = urldecode(value);
208        match field {
209            "name" => info.name = value,
210            "url" => info.url = Some(value),
211            "partialCloneFilter" => info.filter = Some(value),
212            "token" => info.token = Some(value),
213            _ => {}
214        }
215    }
216    if info.name.is_empty() || info.url.is_none() {
217        return None;
218    }
219    Some(info)
220}
221
222/// Does the advertised remote satisfy `promisor.checkFields`? Each checked field must be
223/// advertised and match the value configured locally for the corresponding remote.
224fn all_fields_match(
225    advertised: &PromisorInfo,
226    config_info: &[PromisorInfo],
227    checked: &[String],
228    in_list: bool,
229) -> bool {
230    for field in checked {
231        let adv_value = match field.as_str() {
232            FIELD_FILTER => advertised.filter.as_deref(),
233            FIELD_TOKEN => advertised.token.as_deref(),
234            _ => None,
235        };
236        let Some(adv_value) = adv_value else {
237            return false;
238        };
239        let matches = if in_list {
240            config_info
241                .iter()
242                .any(|p| field_matches_config(field, adv_value, p))
243        } else {
244            config_info
245                .iter()
246                .find(|p| p.name == advertised.name)
247                .is_some_and(|p| field_matches_config(field, adv_value, p))
248        };
249        if !matches {
250            return false;
251        }
252    }
253    true
254}
255
256fn field_matches_config(field: &str, value: &str, p: &PromisorInfo) -> bool {
257    match field {
258        FIELD_FILTER => p.filter.as_deref() == Some(value),
259        FIELD_TOKEN => p.token.as_deref() == Some(value),
260        _ => false,
261    }
262}
263
264fn should_accept(
265    accept: Accept,
266    advertised: &PromisorInfo,
267    config_info: &[PromisorInfo],
268    checked: &[String],
269) -> bool {
270    if accept == Accept::All {
271        return all_fields_match(advertised, config_info, checked, true);
272    }
273
274    let Some(local) = config_info.iter().find(|p| p.name == advertised.name) else {
275        return false;
276    };
277
278    if accept == Accept::KnownName {
279        return all_fields_match(advertised, config_info, checked, false);
280    }
281
282    // KnownUrl
283    let adv_url = advertised.url.as_deref().unwrap_or("");
284    if adv_url.is_empty() {
285        return false;
286    }
287    if local.url.as_deref() == Some(adv_url) {
288        return all_fields_match(advertised, config_info, checked, false);
289    }
290    false
291}
292
293/// The client's reply to the server's `promisor-remote` advertisement.
294pub struct PromisorReply {
295    /// `promisor-remote=<names>` value to send back, or `None` to send nothing.
296    pub reply: Option<String>,
297    /// Names of accepted remotes (decoded), in advertisement order.
298    pub accepted: Vec<String>,
299    /// For each accepted remote, its advertised `partialCloneFilter` (if any).
300    pub accepted_filters: Vec<(String, String)>,
301}
302
303/// Parse a full server advertisement (`;`-separated `pr-fields`) into [`PromisorInfo`] entries,
304/// dropping any that lack a name or URL.
305#[must_use]
306pub fn parse_advertisement(info: &str) -> Vec<PromisorInfo> {
307    info.split(';').filter_map(parse_one_advertised).collect()
308}
309
310/// Client side: decide which advertised promisor remotes to accept and build the reply.
311///
312/// `info` is the raw value of the server's `promisor-remote=` capability.
313#[must_use]
314pub fn promisor_remote_reply(cfg: &ConfigSet, info: &str) -> PromisorReply {
315    let accept = parse_accept(cfg);
316    if accept == Accept::None {
317        return PromisorReply {
318            reply: None,
319            accepted: Vec::new(),
320            accepted_filters: Vec::new(),
321        };
322    }
323
324    let checked = parse_fields_config(cfg, "promisor.checkFields");
325    let config_info = config_info_list(cfg, &checked);
326
327    let mut accepted = Vec::new();
328    let mut accepted_filters = Vec::new();
329    for elem in info.split(';') {
330        let Some(advertised) = parse_one_advertised(elem) else {
331            continue;
332        };
333        if should_accept(accept, &advertised, &config_info, &checked) {
334            if let Some(f) = &advertised.filter {
335                accepted_filters.push((advertised.name.clone(), f.clone()));
336            }
337            accepted.push(advertised.name);
338        }
339    }
340
341    let reply = if accepted.is_empty() {
342        None
343    } else {
344        Some(
345            accepted
346                .iter()
347                .map(|n| urlencode(n))
348                .collect::<Vec<_>>()
349                .join(";"),
350        )
351    };
352
353    PromisorReply {
354        reply,
355        accepted,
356        accepted_filters,
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn cfg(pairs: &[(&str, &str)]) -> ConfigSet {
365        let mut c = ConfigSet::default();
366        for (k, v) in pairs {
367            c.add_command_override(k, v).unwrap();
368        }
369        c
370    }
371
372    #[test]
373    fn no_advertise_when_disabled() {
374        let c = cfg(&[
375            ("remote.lop.promisor", "true"),
376            ("remote.lop.url", "file:///lop"),
377        ]);
378        assert_eq!(promisor_remote_info(&c), None);
379    }
380
381    #[test]
382    fn advertise_name_and_url() {
383        let c = cfg(&[
384            ("promisor.advertise", "true"),
385            ("remote.lop.promisor", "true"),
386            ("remote.lop.url", "file:///lop"),
387        ]);
388        assert_eq!(
389            promisor_remote_info(&c).as_deref(),
390            Some("name=lop,url=file:///lop")
391        );
392    }
393
394    #[test]
395    fn url_space_encoded() {
396        let c = cfg(&[
397            ("promisor.advertise", "true"),
398            ("remote.lop.promisor", "true"),
399            ("remote.lop.url", "file:///a b"),
400        ]);
401        assert_eq!(
402            promisor_remote_info(&c).as_deref(),
403            Some("name=lop,url=file:///a%20b")
404        );
405    }
406
407    #[test]
408    fn advertise_send_fields() {
409        let c = cfg(&[
410            ("promisor.advertise", "true"),
411            ("promisor.sendFields", "partialCloneFilter, token"),
412            ("remote.lop.promisor", "true"),
413            ("remote.lop.url", "file:///lop"),
414            ("remote.lop.partialCloneFilter", "blob:none"),
415            ("remote.lop.token", "fooBar"),
416        ]);
417        assert_eq!(
418            promisor_remote_info(&c).as_deref(),
419            Some("name=lop,url=file:///lop,partialCloneFilter=blob:none,token=fooBar")
420        );
421    }
422
423    #[test]
424    fn accept_all() {
425        let server = cfg(&[
426            ("promisor.advertise", "true"),
427            ("remote.lop.promisor", "true"),
428            ("remote.lop.url", "file:///lop"),
429        ]);
430        let info = promisor_remote_info(&server).unwrap();
431        let client = cfg(&[
432            ("promisor.acceptFromServer", "All"),
433            ("remote.lop.promisor", "true"),
434            ("remote.lop.url", "file:///lop"),
435        ]);
436        let r = promisor_remote_reply(&client, &info);
437        assert_eq!(r.reply.as_deref(), Some("lop"));
438    }
439
440    #[test]
441    fn accept_none_default() {
442        let client = cfg(&[
443            ("remote.lop.promisor", "true"),
444            ("remote.lop.url", "file:///lop"),
445        ]);
446        let r = promisor_remote_reply(&client, "name=lop,url=file:///lop");
447        assert_eq!(r.reply, None);
448    }
449
450    #[test]
451    fn known_url_mismatch_rejected() {
452        let client = cfg(&[
453            ("promisor.acceptFromServer", "KnownUrl"),
454            ("remote.lop.promisor", "true"),
455            ("remote.lop.url", "file:///other"),
456        ]);
457        let r = promisor_remote_reply(&client, "name=lop,url=file:///lop");
458        assert_eq!(r.reply, None);
459    }
460}