1use crate::config::ConfigSet;
17
18#[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
30fn is_known_field(field: &str) -> bool {
32 let f = field.to_ascii_lowercase();
33 f == FIELD_FILTER || f == FIELD_TOKEN
34}
35
36fn 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
50fn 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
66fn 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
87fn config_info_list(cfg: &ConfigSet, field_names: &[String]) -> Vec<PromisorInfo> {
92 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 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 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#[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
198fn 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
222fn 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 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
293pub struct PromisorReply {
295 pub reply: Option<String>,
297 pub accepted: Vec<String>,
299 pub accepted_filters: Vec<(String, String)>,
301}
302
303#[must_use]
306pub fn parse_advertisement(info: &str) -> Vec<PromisorInfo> {
307 info.split(';').filter_map(parse_one_advertised).collect()
308}
309
310#[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}