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 keeps some objects on other remotes, typically large blobs on a
5//! *large-object promisor* (LOP), can tell clients about those remotes. A client that
6//! accepts one lets the server leave out objects the LOP can provide; the client then
7//! fetches them from the LOP when it needs them.
8//!
9//! The exchange has three parts, each with a typed model here:
10//!
11//! - The server builds a [`PromisorAdvertisement`] from its own promisor remotes
12//!   ([`PromisorAdvertisement::from_server_config`]) and sends it as
13//!   `promisor-remote=<value>` ([`PromisorAdvertisement::to_capability_value`]).
14//! - The client parses it ([`PromisorAdvertisement::parse`]), reads its
15//!   [`PromisorAcceptPolicy`] (`promisor.acceptFromServer`, `promisor.checkFields`,
16//!   `promisor.storeFields`) and runs [`PromisorDecision::decide`] against the promisor
17//!   remotes it already has ([`configured_promisor_remotes`]). The names it accepted go
18//!   back to the server ([`PromisorDecision::reply_value`]).
19//! - The server reads that reply with [`parse_accepted_reply`].
20//!
21//! Accepting a remote never creates it: only remotes the client already configured can be
22//! accepted by name, and lazy fetches always use the locally configured URL, never the
23//! advertised one. [`store_advertised_fields`] can update the `partialCloneFilter` and
24//! `token` of an accepted, already configured remote.
25//!
26//! The older string-based helpers ([`promisor_remote_info`], [`promisor_remote_reply`],
27//! [`parse_advertisement`]) are thin wrappers over these types.
28
29use std::path::Path;
30
31use crate::config::{parse_bool, ConfigFile, ConfigScope, ConfigSet};
32use crate::error::{Error, Result};
33use crate::partial_clone::ObjectFilterSpec;
34use crate::rev_list::ObjectFilter;
35
36/// An optional per-remote field the `promisor-remote` capability can carry besides
37/// `name` and `url`.
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
41#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
42pub enum PromisorField {
43    /// The partial-clone filter the remote is meant to be used with
44    /// (`remote.<name>.partialCloneFilter`).
45    PartialCloneFilter,
46    /// An authentication token for the remote (`remote.<name>.token`).
47    Token,
48}
49
50impl PromisorField {
51    /// Every field, in the order Git writes them in an advertisement.
52    pub const ALL: [Self; 2] = [Self::PartialCloneFilter, Self::Token];
53
54    /// The field name as it appears on the wire (case-sensitive).
55    ///
56    /// # Returns
57    ///
58    /// `"partialCloneFilter"` or `"token"`.
59    #[must_use]
60    pub const fn wire_name(self) -> &'static str {
61        match self {
62            Self::PartialCloneFilter => "partialCloneFilter",
63            Self::Token => "token",
64        }
65    }
66
67    /// The canonical (lowercase) last component of the `remote.<name>.<field>` config key.
68    ///
69    /// # Returns
70    ///
71    /// `"partialclonefilter"` or `"token"`.
72    #[must_use]
73    pub const fn config_key(self) -> &'static str {
74        match self {
75            Self::PartialCloneFilter => "partialclonefilter",
76            Self::Token => "token",
77        }
78    }
79
80    /// Parse a comma or whitespace separated field list, as in `promisor.sendFields`.
81    ///
82    /// Names are matched case-insensitively. Duplicates are kept only once.
83    ///
84    /// # Parameters
85    ///
86    /// - `raw`: the config value, for example `"partialCloneFilter, token"`.
87    ///
88    /// # Returns
89    ///
90    /// The known fields in the order given, and every name that is not a known field.
91    #[must_use]
92    pub fn parse_list(raw: &str) -> (Vec<Self>, Vec<String>) {
93        let mut known = Vec::new();
94        let mut unknown = Vec::new();
95        for name in raw
96            .split([',', ' ', '\t'])
97            .map(str::trim)
98            .filter(|s| !s.is_empty())
99        {
100            match Self::ALL
101                .into_iter()
102                .find(|f| f.wire_name().eq_ignore_ascii_case(name))
103            {
104                Some(field) if !known.contains(&field) => known.push(field),
105                Some(_) => {}
106                None => unknown.push(name.to_owned()),
107            }
108        }
109        (known, unknown)
110    }
111
112    fn value_of(self, info: &PromisorInfo) -> Option<&str> {
113        match self {
114            Self::PartialCloneFilter => info.filter.as_deref(),
115            Self::Token => info.token.as_deref(),
116        }
117    }
118
119    fn set_on(self, info: &mut PromisorInfo, value: String) {
120        match self {
121            Self::PartialCloneFilter => info.filter = Some(value),
122            Self::Token => info.token = Some(value),
123        }
124    }
125}
126
127/// Which advertised promisor remotes a client accepts (`promisor.acceptFromServer`).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
130#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
131pub enum AcceptFromServer {
132    /// Accept nothing (the default).
133    #[default]
134    None,
135    /// Accept a remote configured locally under the same name with the same URL.
136    KnownUrl,
137    /// Accept a remote configured locally under the same name, whatever its URL.
138    KnownName,
139    /// Accept every advertised remote. Only for fully trusted servers.
140    All,
141}
142
143impl AcceptFromServer {
144    /// Parse a `promisor.acceptFromServer` value (case-insensitive).
145    ///
146    /// # Returns
147    ///
148    /// `None` when `value` is not one of `none`, `knownUrl`, `knownName` or `all`.
149    #[must_use]
150    pub fn parse(value: &str) -> Option<Self> {
151        let value = value.trim();
152        [
153            ("none", Self::None),
154            ("knownurl", Self::KnownUrl),
155            ("knownname", Self::KnownName),
156            ("all", Self::All),
157        ]
158        .into_iter()
159        .find_map(|(name, policy)| value.eq_ignore_ascii_case(name).then_some(policy))
160    }
161}
162
163/// A promisor-related config value the library ignored.
164#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq)]
166#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
167#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
168pub enum PromisorConfigWarning {
169    /// `promisor.acceptFromServer` has a value that is not a known policy; nothing is accepted.
170    UnknownAcceptValue(String),
171    /// A field list (`promisor.checkFields`, `promisor.storeFields`) names an unknown field.
172    UnknownField {
173        /// The config key holding the list.
174        key: &'static str,
175        /// The unknown field name.
176        field: String,
177    },
178    /// An advertised field value was not stored because it is not valid: a filter that does
179    /// not parse, or a token containing control characters.
180    InvalidAdvertisedValue {
181        /// The remote the value was advertised for.
182        remote: String,
183        /// Which field.
184        field: PromisorField,
185    },
186    /// An accepted remote advertised a filter other than `blob:none` or `blob:limit=<n>`,
187    /// which cannot be used as the automatic filter.
188    UnusableAutoFilter(String),
189}
190
191/// The client's rules for accepting advertised promisor remotes.
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
193#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
194pub struct PromisorAcceptPolicy {
195    /// `promisor.acceptFromServer`.
196    pub accept: AcceptFromServer,
197    /// `promisor.checkFields`: fields whose advertised value must equal the local one.
198    pub check_fields: Vec<PromisorField>,
199    /// `promisor.storeFields`: fields copied from the advertisement into local config.
200    pub store_fields: Vec<PromisorField>,
201}
202
203impl PromisorAcceptPolicy {
204    /// Read the policy from config.
205    ///
206    /// # Parameters
207    ///
208    /// - `cfg`: the client's merged config (local, global, `-c` overrides).
209    ///
210    /// # Returns
211    ///
212    /// The policy and any values that were ignored. An unknown `acceptFromServer` value
213    /// means [`AcceptFromServer::None`].
214    #[must_use]
215    pub fn from_config(cfg: &ConfigSet) -> (Self, Vec<PromisorConfigWarning>) {
216        let mut warnings = Vec::new();
217        let accept = match cfg.get("promisor.acceptFromServer") {
218            None => AcceptFromServer::None,
219            Some(raw) => AcceptFromServer::parse(&raw).unwrap_or_else(|| {
220                warnings.push(PromisorConfigWarning::UnknownAcceptValue(raw));
221                AcceptFromServer::None
222            }),
223        };
224        let mut fields = |key: &'static str| {
225            let (known, unknown) = PromisorField::parse_list(&cfg.get(key).unwrap_or_default());
226            warnings.extend(
227                unknown
228                    .into_iter()
229                    .map(|field| PromisorConfigWarning::UnknownField { key, field }),
230            );
231            known
232        };
233        let check_fields = fields("promisor.checkFields");
234        let store_fields = fields("promisor.storeFields");
235        (
236            Self {
237                accept,
238                check_fields,
239                store_fields,
240            },
241            warnings,
242        )
243    }
244}
245
246/// One advertised or configured promisor remote.
247///
248/// `Debug` output and serialization never show the token, nor a user name or password
249/// in the URL.
250#[derive(Clone, Default, PartialEq, Eq)]
251#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
252pub struct PromisorInfo {
253    /// Remote name.
254    pub name: String,
255    /// Remote URL, as configured or advertised. Serialized with any user name and
256    /// password removed (see [`Self::display_url`]).
257    #[cfg_attr(
258        feature = "serde-models",
259        serde(serialize_with = "serialize_redacted_url")
260    )]
261    pub url: Option<String>,
262    /// Partial-clone filter (`partialCloneFilter` field).
263    pub filter: Option<String>,
264    /// Authentication token (`token` field). Never serialized.
265    #[cfg_attr(feature = "serde-models", serde(skip))]
266    pub token: Option<String>,
267}
268
269impl PromisorInfo {
270    /// The URL with any user name and password removed, for showing to people.
271    ///
272    /// # Returns
273    ///
274    /// An empty string when there is no URL.
275    #[must_use]
276    pub fn display_url(&self) -> String {
277        crate::transport_path::redact_url_for_display(self.url.as_deref().unwrap_or_default())
278    }
279}
280
281#[cfg(feature = "serde-models")]
282fn serialize_redacted_url<S: serde::Serializer>(
283    url: &Option<String>,
284    serializer: S,
285) -> std::result::Result<S::Ok, S::Error> {
286    match url {
287        Some(url) => serializer.serialize_some(&crate::transport_path::redact_url_for_display(url)),
288        None => serializer.serialize_none(),
289    }
290}
291
292impl std::fmt::Debug for PromisorInfo {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct("PromisorInfo")
295            .field("name", &self.name)
296            .field("url", &self.url.as_ref().map(|_| self.display_url()))
297            .field("filter", &self.filter)
298            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
299            .finish()
300    }
301}
302
303/// The promisor remotes a server advertises with the `promisor-remote` capability.
304#[derive(Debug, Clone, Default, PartialEq, Eq)]
305#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
306pub struct PromisorAdvertisement {
307    /// Advertised remotes in advertisement order. Each has a name and a non-empty URL.
308    pub remotes: Vec<PromisorInfo>,
309}
310
311impl PromisorAdvertisement {
312    /// Build the advertisement a server sends.
313    ///
314    /// Only promisor remotes with a non-empty URL are advertised; the fields listed in
315    /// `promisor.sendFields` are included when set to a non-empty value.
316    ///
317    /// # Parameters
318    ///
319    /// - `cfg`: the served repository's config.
320    ///
321    /// # Returns
322    ///
323    /// `None` when `promisor.advertise` is not true or there is no remote to advertise.
324    #[must_use]
325    pub fn from_server_config(cfg: &ConfigSet) -> Option<Self> {
326        let advertise = cfg
327            .get_bool("promisor.advertise")
328            .and_then(|r| r.ok())
329            .unwrap_or(false);
330        if !advertise {
331            return None;
332        }
333        let (fields, _unknown) =
334            PromisorField::parse_list(&cfg.get("promisor.sendFields").unwrap_or_default());
335        let remotes = configured_promisor_remotes(cfg, &fields);
336        (!remotes.is_empty()).then_some(Self { remotes })
337    }
338
339    /// Parse the value of a server's `promisor-remote=` capability.
340    ///
341    /// Remotes are separated by `;` and fields by `,`; values are percent-decoded. An entry
342    /// without a name or with a missing or empty URL is dropped, and unknown fields are
343    /// ignored, as the protocol requires.
344    ///
345    /// # Parameters
346    ///
347    /// - `value`: the text after `promisor-remote=`.
348    #[must_use]
349    pub fn parse(value: &str) -> Self {
350        Self {
351            remotes: value.split(';').filter_map(parse_one_advertised).collect(),
352        }
353    }
354
355    /// Serialize as the value of the `promisor-remote=` capability.
356    ///
357    /// # Returns
358    ///
359    /// `name=<n>,url=<u>[,partialCloneFilter=<f>][,token=<t>]` per remote, joined with `;`.
360    #[must_use]
361    pub fn to_capability_value(&self) -> String {
362        let mut out = String::new();
363        for (i, p) in self.remotes.iter().enumerate() {
364            if i != 0 {
365                out.push(';');
366            }
367            out.push_str("name=");
368            out.push_str(&urlencode(&p.name));
369            out.push_str(",url=");
370            out.push_str(&urlencode(p.url.as_deref().unwrap_or("")));
371            for field in PromisorField::ALL {
372                if let Some(v) = field.value_of(p) {
373                    out.push(',');
374                    out.push_str(field.wire_name());
375                    out.push('=');
376                    out.push_str(&urlencode(v));
377                }
378            }
379        }
380        out
381    }
382
383    /// Names of the advertised remotes, in order.
384    pub fn names(&self) -> impl Iterator<Item = &str> {
385        self.remotes.iter().map(|r| r.name.as_str())
386    }
387}
388
389/// URL-encode a field value: only `,`, `;`, `%` and bytes outside the printable ASCII range
390/// are escaped, which is what the capability grammar requires.
391fn urlencode(s: &str) -> String {
392    use std::fmt::Write as _;
393    let mut out = String::with_capacity(s.len());
394    for &b in s.as_bytes() {
395        if b != b',' && b != b';' && b != b'%' && (33..=126).contains(&b) {
396            out.push(char::from(b));
397        } else {
398            let _ = write!(out, "%{b:02X}");
399        }
400    }
401    out
402}
403
404/// Percent-decode a field value. Malformed escapes are kept as they are.
405fn urldecode(s: &str) -> String {
406    let bytes = s.as_bytes();
407    let mut out = Vec::with_capacity(bytes.len());
408    let mut i = 0;
409    while i < bytes.len() {
410        if bytes[i] == b'%' && i + 2 < bytes.len() {
411            let hex = |b: u8| char::from(b).to_digit(16);
412            if let (Some(hi), Some(lo)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
413                out.push((hi * 16 + lo) as u8);
414                i += 3;
415                continue;
416            }
417        }
418        out.push(bytes[i]);
419        i += 1;
420    }
421    String::from_utf8_lossy(&out).into_owned()
422}
423
424/// Parse one `name=…,url=…` element. `None` without a name or a non-empty URL.
425fn parse_one_advertised(remote_info: &str) -> Option<PromisorInfo> {
426    let mut info = PromisorInfo::default();
427    for elem in remote_info.split(',') {
428        let Some((field, value)) = elem.split_once('=') else {
429            continue;
430        };
431        let value = urldecode(value);
432        match field {
433            "name" => info.name = value,
434            "url" => info.url = Some(value),
435            other => {
436                if let Some(f) = PromisorField::ALL
437                    .into_iter()
438                    .find(|f| f.wire_name() == other)
439                {
440                    f.set_on(&mut info, value);
441                }
442            }
443        }
444    }
445    if info.name.is_empty() || info.url.as_deref().is_none_or(str::is_empty) {
446        return None;
447    }
448    Some(info)
449}
450
451/// Names of the promisor remotes in `cfg`, in first-seen config order, with the
452/// `extensions.partialClone` remote moved to the end.
453///
454/// A remote is a promisor remote when `remote.<name>.promisor` is true or
455/// `remote.<name>.partialCloneFilter` is set, or when it is named by `extensions.partialClone`.
456fn promisor_remote_names(cfg: &ConfigSet) -> Vec<String> {
457    let mut names: Vec<String> = Vec::new();
458    for e in cfg.entries() {
459        let Some(rest) = e.key.strip_prefix("remote.") else {
460            continue;
461        };
462        let name = if let Some(name) = rest.strip_suffix(".promisor") {
463            let on = e.value.as_deref().map_or(Ok(true), parse_bool);
464            on.unwrap_or(false).then_some(name)
465        } else {
466            rest.strip_suffix(".partialclonefilter")
467        };
468        if let Some(name) = name.filter(|n| !n.is_empty()) {
469            if !names.iter().any(|n| n == name) {
470                names.push(name.to_owned());
471            }
472        }
473    }
474    if let Some(pc) = cfg.get("extensions.partialclone").filter(|s| !s.is_empty()) {
475        names.retain(|n| *n != pc);
476        names.push(pc);
477    }
478    names
479}
480
481/// Whether a fetch filtered with `request` leaves out every blob the promisor
482/// remotes in `cfg` may hold in this repository's place.
483///
484/// A promisor remote's `partialCloneFilter` says which blobs the repository may
485/// lack: `blob:none` any blob, `blob:limit=<n>` blobs of at least `n` bytes. The
486/// request leaves those out when its filter is `blob:none`, or `blob:limit=<m>`
487/// with `m` no larger than every remote's `n` (and no remote is `blob:none`), or a
488/// `combine:` filter with such a part. A promisor remote without a blob filter
489/// could stand in for any object, so the answer is then `false`, as it is for a
490/// repository without promisor remotes.
491pub(crate) fn request_filter_omits_promised_blobs(cfg: &ConfigSet, request: &ObjectFilter) -> bool {
492    let names = promisor_remote_names(cfg);
493    if names.is_empty() {
494        return false;
495    }
496    // The smallest blob a promisor remote may hold; `0` for `blob:none`.
497    let mut floor = u64::MAX;
498    for name in names {
499        let filter = cfg
500            .get(&format!("remote.{name}.partialclonefilter"))
501            .and_then(|spec| ObjectFilter::parse(&spec).ok());
502        match filter {
503            Some(ObjectFilter::BlobNone) => floor = 0,
504            Some(ObjectFilter::BlobLimit(n)) => floor = floor.min(n),
505            _ => return false,
506        }
507    }
508    omits_blobs_from(request, floor)
509}
510
511/// Whether `filter` leaves out every blob of at least `floor` bytes.
512fn omits_blobs_from(filter: &ObjectFilter, floor: u64) -> bool {
513    match filter {
514        ObjectFilter::BlobNone => true,
515        ObjectFilter::BlobLimit(limit) => *limit <= floor,
516        ObjectFilter::Combine(parts) => parts.iter().any(|part| omits_blobs_from(part, floor)),
517        _ => false,
518    }
519}
520
521/// Whether `name` is a promisor remote in `cfg`: `remote.<name>.promisor` is true,
522/// `remote.<name>.partialCloneFilter` is set, or `extensions.partialClone` names it.
523pub(crate) fn is_promisor_remote(cfg: &ConfigSet, name: &str) -> bool {
524    promisor_remote_names(cfg).iter().any(|n| n == name)
525}
526
527/// The promisor remotes configured in `cfg` that have a non-empty URL.
528///
529/// Remotes come in first-seen config order with the `extensions.partialClone` remote last,
530/// which is the order Git advertises them and tries them for lazy fetches.
531///
532/// # Parameters
533///
534/// - `cfg`: merged config.
535/// - `fields`: optional fields to read for each remote (`remote.<name>.<field>`); unset or
536///   empty values are left `None`.
537#[must_use]
538pub fn configured_promisor_remotes(cfg: &ConfigSet, fields: &[PromisorField]) -> Vec<PromisorInfo> {
539    promisor_remote_names(cfg)
540        .into_iter()
541        .filter_map(|name| {
542            let url = cfg
543                .get(&format!("remote.{name}.url"))
544                .filter(|u| !u.is_empty())?;
545            let mut info = PromisorInfo {
546                name,
547                url: Some(url),
548                ..Default::default()
549            };
550            for &field in fields {
551                if let Some(v) = cfg
552                    .get(&format!("remote.{}.{}", info.name, field.config_key()))
553                    .filter(|v| !v.is_empty())
554                {
555                    field.set_on(&mut info, v);
556                }
557            }
558            Some(info)
559        })
560        .collect()
561}
562
563/// The order in which to try promisor remotes for a lazy fetch.
564///
565/// Names in `preferred` that are still configured promisor remotes come first, then the rest
566/// in config order, with the `extensions.partialClone` remote last.
567///
568/// # Parameters
569///
570/// - `cfg`: merged config.
571/// - `preferred`: remotes to try first, such as the ones a server just had the client accept.
572#[must_use]
573pub fn lazy_fetch_order(cfg: &ConfigSet, preferred: &[String]) -> Vec<String> {
574    let all: Vec<String> = configured_promisor_remotes(cfg, &[])
575        .into_iter()
576        .map(|p| p.name)
577        .collect();
578    let mut order: Vec<String> =
579        preferred
580            .iter()
581            .filter(|p| all.contains(p))
582            .fold(Vec::new(), |mut acc, p| {
583                if !acc.contains(p) {
584                    acc.push(p.clone());
585                }
586                acc
587            });
588    for name in all {
589        if !order.contains(&name) {
590            order.push(name);
591        }
592    }
593    order
594}
595
596/// Why an advertised promisor remote was not accepted.
597#[non_exhaustive]
598#[derive(Debug, Clone, PartialEq, Eq)]
599#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
600#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
601pub enum PromisorRejection {
602    /// `promisor.acceptFromServer` is `none` (or unset).
603    PolicyNone,
604    /// No local promisor remote has the advertised name.
605    UnknownRemote,
606    /// A local remote has the name but a different URL (`knownUrl` policy).
607    UrlMismatch {
608        /// The locally configured URL, with any user name and password removed.
609        configured: String,
610    },
611    /// A `promisor.checkFields` field was not advertised or is not set locally.
612    FieldMissing(PromisorField),
613    /// A `promisor.checkFields` field differs between the advertisement and local config.
614    FieldMismatch(PromisorField),
615    /// The fetch is not a partial fetch, so the client cannot do without the objects the
616    /// server would leave out.
617    NotPartialFetch,
618}
619
620/// One advertised remote and the client's verdict on it.
621#[derive(Debug, Clone, PartialEq, Eq)]
622#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
623pub struct PromisorCandidate {
624    /// The remote as the server advertised it.
625    pub advertised: PromisorInfo,
626    /// `None` when accepted, otherwise why not.
627    pub rejection: Option<PromisorRejection>,
628}
629
630impl PromisorCandidate {
631    /// Whether the client accepted this remote.
632    #[must_use]
633    pub fn is_accepted(&self) -> bool {
634        self.rejection.is_none()
635    }
636}
637
638/// The client's verdict on every advertised promisor remote.
639#[derive(Debug, Clone, Default, PartialEq, Eq)]
640#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
641pub struct PromisorDecision {
642    /// Every advertised remote, in advertisement order.
643    pub candidates: Vec<PromisorCandidate>,
644}
645
646impl PromisorDecision {
647    /// Decide which advertised remotes to accept.
648    ///
649    /// Follows `promisor.acceptFromServer`: `knownUrl` needs a local promisor remote with the
650    /// same name and the same URL, `knownName` the same name, and `all` accepts any remote.
651    /// Accepted remotes must then pass `promisor.checkFields`: each checked field must be
652    /// advertised, set locally, and equal. Under `all`, a remote not known locally passes a
653    /// field check when any local promisor remote has that value. Comparisons are
654    /// case-sensitive.
655    ///
656    /// # Parameters
657    ///
658    /// - `policy`: the client's policy.
659    /// - `local`: the client's promisor remotes, with the checked fields filled
660    ///   ([`configured_promisor_remotes`] with `policy.check_fields`).
661    /// - `adv`: what the server advertised.
662    #[must_use]
663    pub fn decide(
664        policy: &PromisorAcceptPolicy,
665        local: &[PromisorInfo],
666        adv: &PromisorAdvertisement,
667    ) -> Self {
668        let candidates = adv
669            .remotes
670            .iter()
671            .map(|advertised| PromisorCandidate {
672                advertised: advertised.clone(),
673                rejection: rejection_for(policy, local, advertised),
674            })
675            .collect();
676        Self { candidates }
677    }
678
679    /// The accepted remotes, in advertisement order.
680    pub fn accepted(&self) -> impl Iterator<Item = &PromisorInfo> {
681        self.candidates
682            .iter()
683            .filter(|c| c.is_accepted())
684            .map(|c| &c.advertised)
685    }
686
687    /// Names of the accepted remotes, in advertisement order.
688    #[must_use]
689    pub fn accepted_names(&self) -> Vec<String> {
690        self.accepted().map(|p| p.name.clone()).collect()
691    }
692
693    /// Whether any remote was accepted.
694    #[must_use]
695    pub fn any_accepted(&self) -> bool {
696        self.accepted().next().is_some()
697    }
698
699    /// The value of the client's `promisor-remote=` reply.
700    ///
701    /// # Returns
702    ///
703    /// The URL-encoded accepted names joined with `;`, or `None` when nothing was accepted
704    /// (then no reply is sent).
705    #[must_use]
706    pub fn reply_value(&self) -> Option<String> {
707        let names: Vec<String> = self.accepted().map(|p| urlencode(&p.name)).collect();
708        (!names.is_empty()).then(|| names.join(";"))
709    }
710
711    /// Reject every remote that is still accepted, for `why`.
712    pub fn decline_all(&mut self, why: PromisorRejection) {
713        for c in &mut self.candidates {
714            if c.rejection.is_none() {
715                c.rejection = Some(why.clone());
716            }
717        }
718    }
719
720    /// The partial-clone filter implied by the accepted remotes' advertised filters, for a
721    /// fetch that asked to pick its filter automatically (Git's `--filter=auto`).
722    ///
723    /// `blob:none` wins over any limit; otherwise the smallest `blob:limit` is used, which
724    /// leaves out every blob any accepted remote expects to hold.
725    ///
726    /// # Returns
727    ///
728    /// The filter (`None` when no accepted remote advertised a usable one) and the
729    /// advertised filters that could not be used because they are not `blob:none` or
730    /// `blob:limit=<n>`.
731    #[must_use]
732    pub fn combined_filter(&self) -> (Option<ObjectFilterSpec>, Vec<String>) {
733        let mut best: Option<ObjectFilterSpec> = None;
734        let mut unsupported = Vec::new();
735        for spec in self.accepted().filter_map(|p| p.filter.as_deref()) {
736            let Ok(parsed) = ObjectFilterSpec::parse(spec) else {
737                unsupported.push(spec.to_owned());
738                continue;
739            };
740            best = Some(match (best, parsed) {
741                (None, p) => p,
742                (Some(ObjectFilterSpec::BlobNone), _) | (_, ObjectFilterSpec::BlobNone) => {
743                    ObjectFilterSpec::BlobNone
744                }
745                (Some(ObjectFilterSpec::BlobLimit(a)), ObjectFilterSpec::BlobLimit(b)) => {
746                    ObjectFilterSpec::BlobLimit(a.min(b))
747                }
748            });
749        }
750        (best, unsupported)
751    }
752}
753
754fn rejection_for(
755    policy: &PromisorAcceptPolicy,
756    local: &[PromisorInfo],
757    advertised: &PromisorInfo,
758) -> Option<PromisorRejection> {
759    let known = local.iter().find(|p| p.name == advertised.name);
760    match (policy.accept, known) {
761        (AcceptFromServer::None, _) => Some(PromisorRejection::PolicyNone),
762        (AcceptFromServer::All, _) => fields_rejection(policy, local, advertised, None),
763        (_, None) => Some(PromisorRejection::UnknownRemote),
764        (AcceptFromServer::KnownName, Some(p)) => {
765            fields_rejection(policy, local, advertised, Some(p))
766        }
767        (AcceptFromServer::KnownUrl, Some(p)) => {
768            if p.url != advertised.url {
769                return Some(PromisorRejection::UrlMismatch {
770                    configured: p.display_url(),
771                });
772            }
773            fields_rejection(policy, local, advertised, Some(p))
774        }
775    }
776}
777
778/// Check `promisor.checkFields` for one advertised remote, against `entry` when the remote is
779/// known locally, otherwise against any local promisor remote.
780fn fields_rejection(
781    policy: &PromisorAcceptPolicy,
782    local: &[PromisorInfo],
783    advertised: &PromisorInfo,
784    entry: Option<&PromisorInfo>,
785) -> Option<PromisorRejection> {
786    for &field in &policy.check_fields {
787        let Some(value) = field.value_of(advertised) else {
788            return Some(PromisorRejection::FieldMissing(field));
789        };
790        let matches = match entry {
791            Some(p) => match field.value_of(p) {
792                None => return Some(PromisorRejection::FieldMissing(field)),
793                Some(local_value) => local_value == value,
794            },
795            None => local.iter().any(|p| field.value_of(p) == Some(value)),
796        };
797        if !matches {
798            return Some(PromisorRejection::FieldMismatch(field));
799        }
800    }
801    None
802}
803
804/// A field [`store_advertised_fields`] wrote into local config.
805#[derive(Debug, Clone, PartialEq, Eq)]
806#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
807pub struct StoredPromisorField {
808    /// The remote whose config changed.
809    pub remote: String,
810    /// The field written to `remote.<remote>.<field>`.
811    pub field: PromisorField,
812    /// Whether a different value was there before (otherwise the key was unset).
813    pub replaced: bool,
814}
815
816/// Store the advertised values of `promisor.storeFields` for accepted remotes.
817///
818/// Only remotes already configured locally as promisor remotes are touched; nothing is
819/// created and no URL is ever written. A filter that does not parse and a token containing
820/// control characters are skipped with a warning. Unchanged values are left alone.
821///
822/// # Parameters
823///
824/// - `git_dir`: the repository whose local `config` file is updated.
825/// - `policy`: the client's policy; only `store_fields` is used.
826/// - `cfg`: the client's merged config, for the current values.
827/// - `decision`: the decision made for this fetch.
828///
829/// # Returns
830///
831/// The fields written, and the values skipped as invalid.
832///
833/// # Errors
834///
835/// Returns [`Error`] when the local config file cannot be read or written.
836pub fn store_advertised_fields(
837    git_dir: &Path,
838    policy: &PromisorAcceptPolicy,
839    cfg: &ConfigSet,
840    decision: &PromisorDecision,
841) -> Result<(Vec<StoredPromisorField>, Vec<PromisorConfigWarning>)> {
842    let mut stored = Vec::new();
843    let mut warnings = Vec::new();
844    if policy.store_fields.is_empty() {
845        return Ok((stored, warnings));
846    }
847    let local = configured_promisor_remotes(cfg, &policy.store_fields);
848    let mut writes: Vec<(String, String)> = Vec::new();
849    for advertised in decision.accepted() {
850        let Some(known) = local.iter().find(|p| p.name == advertised.name) else {
851            continue;
852        };
853        for &field in &policy.store_fields {
854            let Some(value) = field.value_of(advertised) else {
855                continue;
856            };
857            let current = field.value_of(known);
858            if current == Some(value) {
859                continue;
860            }
861            let valid = match field {
862                PromisorField::PartialCloneFilter => {
863                    crate::rev_list::ObjectFilter::parse(value).is_ok()
864                }
865                PromisorField::Token => !value.chars().any(char::is_control),
866            };
867            if !valid {
868                warnings.push(PromisorConfigWarning::InvalidAdvertisedValue {
869                    remote: advertised.name.clone(),
870                    field,
871                });
872                continue;
873            }
874            writes.push((
875                format!("remote.{}.{}", advertised.name, field.config_key()),
876                value.to_owned(),
877            ));
878            stored.push(StoredPromisorField {
879                remote: advertised.name.clone(),
880                field,
881                replaced: current.is_some(),
882            });
883        }
884    }
885    if writes.is_empty() {
886        return Ok((stored, warnings));
887    }
888    let path = git_dir.join("config");
889    let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
890    let mut file = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
891    for (key, value) in &writes {
892        file.set(key, value)?;
893    }
894    file.write()?;
895    Ok((stored, warnings))
896}
897
898/// What the `promisor-remote` exchange decided for one fetch.
899///
900/// Built by the fetch driver when the server advertises promisor remotes and returned in
901/// [`FetchOutcome::promisor`](crate::transfer::FetchOutcome::promisor).
902#[derive(Debug, Clone, Default, PartialEq, Eq)]
903#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
904pub struct PromisorHandshake {
905    /// The verdict on every advertised remote.
906    pub decision: PromisorDecision,
907    /// Fields written into local config (`promisor.storeFields`).
908    pub stored: Vec<StoredPromisorField>,
909    /// The filter chosen from the accepted remotes, when the fetch asked for one
910    /// automatically ([`FetchOptions::auto_filter`](crate::transfer::FetchOptions::auto_filter)).
911    pub resolved_filter: Option<ObjectFilterSpec>,
912    /// Config values that were ignored, advertised values that were not stored, and
913    /// advertised filters too complex to use as the automatic filter.
914    pub warnings: Vec<PromisorConfigWarning>,
915}
916
917impl PromisorHandshake {
918    /// Decide the client side of the exchange.
919    ///
920    /// Reads the client's policy from `cfg`, decides against its configured promisor remotes,
921    /// and declines everything with [`PromisorRejection::NotPartialFetch`] unless
922    /// `partial_fetch`: a client that is not doing a partial fetch needs every object from
923    /// this server. With `auto_filter`, also picks the filter for the fetch from the accepted
924    /// remotes ([`PromisorDecision::combined_filter`]). Writes nothing; see
925    /// [`Self::store_fields`].
926    ///
927    /// # Parameters
928    ///
929    /// - `cfg`: the client's merged config.
930    /// - `advertisement`: what the server advertised.
931    /// - `partial_fetch`: whether the fetch is filtered or is from a promisor remote.
932    /// - `auto_filter`: whether to resolve the filter from the accepted remotes.
933    #[must_use]
934    pub fn evaluate(
935        cfg: &ConfigSet,
936        advertisement: &PromisorAdvertisement,
937        partial_fetch: bool,
938        auto_filter: bool,
939    ) -> Self {
940        let (policy, mut warnings) = PromisorAcceptPolicy::from_config(cfg);
941        let local = configured_promisor_remotes(cfg, &policy.check_fields);
942        let mut decision = PromisorDecision::decide(&policy, &local, advertisement);
943        if !partial_fetch {
944            decision.decline_all(PromisorRejection::NotPartialFetch);
945        }
946        let resolved_filter = if auto_filter {
947            let (filter, unsupported) = decision.combined_filter();
948            warnings.extend(
949                unsupported
950                    .into_iter()
951                    .map(PromisorConfigWarning::UnusableAutoFilter),
952            );
953            filter
954        } else {
955            None
956        };
957        Self {
958            decision,
959            stored: Vec::new(),
960            resolved_filter,
961            warnings,
962        }
963    }
964
965    /// Write `promisor.storeFields` values of the accepted remotes into `git_dir`'s config
966    /// (see [`store_advertised_fields`]), recording what was written in [`Self::stored`].
967    ///
968    /// # Parameters
969    ///
970    /// - `git_dir`: the repository being fetched into.
971    /// - `cfg`: the client's merged config, loaded before the fetch.
972    ///
973    /// # Errors
974    ///
975    /// Returns [`Error`] when the local config file cannot be read or written.
976    pub fn store_fields(&mut self, git_dir: &Path, cfg: &ConfigSet) -> Result<()> {
977        let (policy, _) = PromisorAcceptPolicy::from_config(cfg);
978        let (stored, warnings) = store_advertised_fields(git_dir, &policy, cfg, &self.decision)?;
979        self.stored.extend(stored);
980        self.warnings.extend(warnings);
981        Ok(())
982    }
983}
984
985/// A malformed client `promisor-remote=` reply.
986#[non_exhaustive]
987#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
988pub enum PromisorReplyError {
989    /// The reply carried no remote names.
990    #[error("empty promisor-remote reply")]
991    Empty,
992}
993
994/// Server side: parse the value of a client's `promisor-remote=` reply into remote names.
995///
996/// # Parameters
997///
998/// - `value`: the text after `promisor-remote=`; `;`-separated, URL-encoded names.
999///
1000/// # Errors
1001///
1002/// [`PromisorReplyError::Empty`] when no name is given.
1003pub fn parse_accepted_reply(value: &str) -> std::result::Result<Vec<String>, PromisorReplyError> {
1004    let mut names: Vec<String> = Vec::new();
1005    for name in value.split(';').map(urldecode).filter(|n| !n.is_empty()) {
1006        if !names.contains(&name) {
1007            names.push(name);
1008        }
1009    }
1010    if names.is_empty() {
1011        return Err(PromisorReplyError::Empty);
1012    }
1013    Ok(names)
1014}
1015
1016/// Build the server's `promisor-remote=<info>` advertisement value.
1017///
1018/// Same as [`PromisorAdvertisement::from_server_config`] followed by
1019/// [`PromisorAdvertisement::to_capability_value`].
1020///
1021/// # Returns
1022///
1023/// `None` when `promisor.advertise` is not true or there are no advertisable promisor remotes.
1024#[must_use]
1025pub fn promisor_remote_info(cfg: &ConfigSet) -> Option<String> {
1026    PromisorAdvertisement::from_server_config(cfg).map(|a| a.to_capability_value())
1027}
1028
1029/// The client's reply to the server's `promisor-remote` advertisement.
1030pub struct PromisorReply {
1031    /// `promisor-remote=<names>` value to send back, or `None` to send nothing.
1032    pub reply: Option<String>,
1033    /// Names of accepted remotes (decoded), in advertisement order.
1034    pub accepted: Vec<String>,
1035    /// For each accepted remote, its advertised `partialCloneFilter` (if any).
1036    pub accepted_filters: Vec<(String, String)>,
1037}
1038
1039/// Parse a full server advertisement (`;`-separated `pr-fields`) into [`PromisorInfo`] entries,
1040/// dropping any that lack a name or URL.
1041///
1042/// Same as [`PromisorAdvertisement::parse`].
1043#[must_use]
1044pub fn parse_advertisement(info: &str) -> Vec<PromisorInfo> {
1045    PromisorAdvertisement::parse(info).remotes
1046}
1047
1048/// Client side: decide which advertised promisor remotes to accept and build the reply.
1049///
1050/// A string-based wrapper over [`PromisorAcceptPolicy::from_config`] and
1051/// [`PromisorDecision::decide`].
1052///
1053/// # Parameters
1054///
1055/// - `cfg`: the client's config.
1056/// - `info`: the raw value of the server's `promisor-remote=` capability.
1057#[must_use]
1058pub fn promisor_remote_reply(cfg: &ConfigSet, info: &str) -> PromisorReply {
1059    let (policy, _warnings) = PromisorAcceptPolicy::from_config(cfg);
1060    let local = configured_promisor_remotes(cfg, &policy.check_fields);
1061    let decision = PromisorDecision::decide(&policy, &local, &PromisorAdvertisement::parse(info));
1062    PromisorReply {
1063        reply: decision.reply_value(),
1064        accepted: decision.accepted_names(),
1065        accepted_filters: decision
1066            .accepted()
1067            .filter_map(|p| p.filter.clone().map(|f| (p.name.clone(), f)))
1068            .collect(),
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    fn cfg(pairs: &[(&str, &str)]) -> ConfigSet {
1077        let mut c = ConfigSet::default();
1078        for (k, v) in pairs {
1079            c.add_command_override(k, v).unwrap();
1080        }
1081        c
1082    }
1083
1084    #[test]
1085    fn request_filters_that_leave_out_promised_blobs() {
1086        let parse = |spec: &str| ObjectFilter::parse(spec).unwrap();
1087        let lop = cfg(&[
1088            ("remote.lop.url", "https://lop.example.com/r"),
1089            ("remote.lop.promisor", "true"),
1090            ("remote.lop.partialCloneFilter", "blob:limit=1m"),
1091        ]);
1092        for (spec, omits) in [
1093            ("blob:none", true),
1094            ("blob:limit=1m", true),
1095            ("blob:limit=4k", true),
1096            ("blob:limit=2m", false),
1097            ("tree:0", false),
1098            ("combine:tree:3+blob:limit=512k", true),
1099            ("combine:tree:3+blob:limit=2m", false),
1100        ] {
1101            assert_eq!(
1102                request_filter_omits_promised_blobs(&lop, &parse(spec)),
1103                omits,
1104                "{spec}"
1105            );
1106        }
1107        // The strictest remote decides; one that may hold any blob needs blob:none.
1108        let two = cfg(&[
1109            ("remote.lop.promisor", "true"),
1110            ("remote.lop.partialCloneFilter", "blob:limit=1m"),
1111            ("remote.small.partialCloneFilter", "blob:limit=64k"),
1112        ]);
1113        assert!(request_filter_omits_promised_blobs(
1114            &two,
1115            &parse("blob:limit=64k")
1116        ));
1117        assert!(!request_filter_omits_promised_blobs(
1118            &two,
1119            &parse("blob:limit=1m")
1120        ));
1121        let none = cfg(&[("remote.origin.partialCloneFilter", "blob:none")]);
1122        assert!(request_filter_omits_promised_blobs(
1123            &none,
1124            &parse("blob:none")
1125        ));
1126        assert!(!request_filter_omits_promised_blobs(
1127            &none,
1128            &parse("blob:limit=1")
1129        ));
1130        // Without a blob filter a promisor remote could hold anything.
1131        let unfiltered = cfg(&[("remote.lop.promisor", "true")]);
1132        assert!(!request_filter_omits_promised_blobs(
1133            &unfiltered,
1134            &parse("blob:none")
1135        ));
1136        assert!(!request_filter_omits_promised_blobs(
1137            &ConfigSet::default(),
1138            &parse("blob:none")
1139        ));
1140    }
1141
1142    fn adv(value: &str) -> PromisorAdvertisement {
1143        PromisorAdvertisement::parse(value)
1144    }
1145
1146    fn decide(client: &[(&str, &str)], value: &str) -> PromisorDecision {
1147        let c = cfg(client);
1148        let (policy, _) = PromisorAcceptPolicy::from_config(&c);
1149        let local = configured_promisor_remotes(&c, &policy.check_fields);
1150        PromisorDecision::decide(&policy, &local, &adv(value))
1151    }
1152
1153    fn rejection(d: &PromisorDecision) -> Option<PromisorRejection> {
1154        d.candidates[0].rejection.clone()
1155    }
1156
1157    #[test]
1158    fn no_advertise_when_disabled() {
1159        let c = cfg(&[
1160            ("remote.lop.promisor", "true"),
1161            ("remote.lop.url", "file:///lop"),
1162        ]);
1163        assert_eq!(promisor_remote_info(&c), None);
1164        let c = cfg(&[
1165            ("promisor.advertise", "false"),
1166            ("remote.lop.promisor", "true"),
1167            ("remote.lop.url", "file:///lop"),
1168        ]);
1169        assert_eq!(PromisorAdvertisement::from_server_config(&c), None);
1170    }
1171
1172    #[test]
1173    fn no_advertise_without_promisor_remotes() {
1174        let c = cfg(&[
1175            ("promisor.advertise", "true"),
1176            ("remote.origin.url", "file:///o"),
1177        ]);
1178        assert_eq!(promisor_remote_info(&c), None);
1179    }
1180
1181    #[test]
1182    fn advertise_name_and_url() {
1183        let c = cfg(&[
1184            ("promisor.advertise", "true"),
1185            ("remote.lop.promisor", "true"),
1186            ("remote.lop.url", "file:///lop"),
1187        ]);
1188        assert_eq!(
1189            promisor_remote_info(&c).as_deref(),
1190            Some("name=lop,url=file:///lop")
1191        );
1192    }
1193
1194    #[test]
1195    fn advertise_escapes_separators_percent_and_space() {
1196        let c = cfg(&[
1197            ("promisor.advertise", "true"),
1198            ("remote.lop.promisor", "true"),
1199            ("remote.lop.url", "file:///a b,c;d%e"),
1200        ]);
1201        assert_eq!(
1202            promisor_remote_info(&c).as_deref(),
1203            Some("name=lop,url=file:///a%20b%2Cc%3Bd%25e")
1204        );
1205    }
1206
1207    #[test]
1208    fn advertise_send_fields() {
1209        let c = cfg(&[
1210            ("promisor.advertise", "true"),
1211            ("promisor.sendFields", "partialCloneFilter, token"),
1212            ("remote.lop.promisor", "true"),
1213            ("remote.lop.url", "file:///lop"),
1214            ("remote.lop.partialCloneFilter", "blob:none"),
1215            ("remote.lop.token", "fooBar"),
1216        ]);
1217        assert_eq!(
1218            promisor_remote_info(&c).as_deref(),
1219            Some("name=lop,url=file:///lop,partialCloneFilter=blob:none,token=fooBar")
1220        );
1221    }
1222
1223    #[test]
1224    fn advertise_skips_empty_url_and_lists_partial_clone_remote_last() {
1225        let c = cfg(&[
1226            ("promisor.advertise", "true"),
1227            ("extensions.partialClone", "origin"),
1228            ("remote.origin.promisor", "true"),
1229            ("remote.origin.url", "file:///origin"),
1230            ("remote.empty.promisor", "true"),
1231            ("remote.empty.url", ""),
1232            ("remote.lop.promisor", "true"),
1233            ("remote.lop.url", "file:///lop"),
1234        ]);
1235        assert_eq!(
1236            promisor_remote_info(&c).as_deref(),
1237            Some("name=lop,url=file:///lop;name=origin,url=file:///origin")
1238        );
1239    }
1240
1241    #[test]
1242    fn partial_clone_extension_alone_makes_a_promisor_remote() {
1243        let c = cfg(&[
1244            ("extensions.partialClone", "origin"),
1245            ("remote.origin.url", "file:///origin"),
1246            ("remote.other.promisor", "false"),
1247            ("remote.other.url", "file:///other"),
1248        ]);
1249        let names: Vec<String> = configured_promisor_remotes(&c, &[])
1250            .into_iter()
1251            .map(|p| p.name)
1252            .collect();
1253        assert_eq!(names, ["origin"]);
1254    }
1255
1256    #[test]
1257    fn parse_ignores_unknown_fields_and_drops_bad_entries() {
1258        let a = adv(
1259            "name=lop,url=file:///l%20p,foo=bar,partialCloneFilter=blob:limit%3D5k;\
1260             name=nourl;name=empty,url=;url=file:///noname",
1261        );
1262        assert_eq!(a.remotes.len(), 1);
1263        assert_eq!(a.remotes[0].name, "lop");
1264        assert_eq!(a.remotes[0].url.as_deref(), Some("file:///l p"));
1265        assert_eq!(a.remotes[0].filter.as_deref(), Some("blob:limit=5k"));
1266        assert_eq!(parse_advertisement("name=x").len(), 0);
1267    }
1268
1269    #[test]
1270    fn parse_round_trips_capability_value() {
1271        let original = PromisorAdvertisement {
1272            remotes: vec![
1273                PromisorInfo {
1274                    name: "a;b".into(),
1275                    url: Some("https://h/p,q".into()),
1276                    filter: Some("blob:none".into()),
1277                    token: Some("t%k".into()),
1278                },
1279                PromisorInfo {
1280                    name: "c".into(),
1281                    url: Some("file:///c".into()),
1282                    ..Default::default()
1283                },
1284            ],
1285        };
1286        let value = original.to_capability_value();
1287        assert_eq!(PromisorAdvertisement::parse(&value), original);
1288        assert_eq!(original.names().collect::<Vec<_>>(), ["a;b", "c"]);
1289    }
1290
1291    #[test]
1292    fn field_list_parsing() {
1293        let (known, unknown) = PromisorField::parse_list("PartialCloneFilter token,foo token");
1294        assert_eq!(
1295            known,
1296            [PromisorField::PartialCloneFilter, PromisorField::Token]
1297        );
1298        assert_eq!(unknown, ["foo"]);
1299        assert_eq!(PromisorField::Token.config_key(), "token");
1300        assert_eq!(
1301            PromisorField::PartialCloneFilter.config_key(),
1302            "partialclonefilter"
1303        );
1304    }
1305
1306    #[test]
1307    fn policy_values_are_case_insensitive_and_unknown_warns() {
1308        for (raw, want) in [
1309            ("ALL", AcceptFromServer::All),
1310            ("knownurl", AcceptFromServer::KnownUrl),
1311            ("KnownName", AcceptFromServer::KnownName),
1312            ("None", AcceptFromServer::None),
1313        ] {
1314            let (p, w) =
1315                PromisorAcceptPolicy::from_config(&cfg(&[("promisor.acceptFromServer", raw)]));
1316            assert_eq!(p.accept, want);
1317            assert!(w.is_empty());
1318        }
1319        let (p, w) = PromisorAcceptPolicy::from_config(&cfg(&[
1320            ("promisor.acceptFromServer", "sometimes"),
1321            ("promisor.checkFields", "bogus"),
1322        ]));
1323        assert_eq!(p.accept, AcceptFromServer::None);
1324        assert_eq!(
1325            w,
1326            [
1327                PromisorConfigWarning::UnknownAcceptValue("sometimes".into()),
1328                PromisorConfigWarning::UnknownField {
1329                    key: "promisor.checkFields",
1330                    field: "bogus".into()
1331                }
1332            ]
1333        );
1334    }
1335
1336    const LOP: [(&str, &str); 2] = [
1337        ("remote.lop.promisor", "true"),
1338        ("remote.lop.url", "file:///lop"),
1339    ];
1340
1341    #[test]
1342    fn accept_none_by_default() {
1343        let d = decide(&LOP, "name=lop,url=file:///lop");
1344        assert_eq!(rejection(&d), Some(PromisorRejection::PolicyNone));
1345        assert_eq!(d.reply_value(), None);
1346        assert!(!d.any_accepted());
1347    }
1348
1349    #[test]
1350    fn known_url_needs_matching_url() {
1351        let mut c = LOP.to_vec();
1352        c.push(("promisor.acceptFromServer", "KnownUrl"));
1353        let d = decide(&c, "name=lop,url=file:///lop");
1354        assert_eq!(d.reply_value().as_deref(), Some("lop"));
1355        let d = decide(&c, "name=lop,url=file:///elsewhere");
1356        assert_eq!(
1357            rejection(&d),
1358            Some(PromisorRejection::UrlMismatch {
1359                configured: "file:///lop".into()
1360            })
1361        );
1362        let d = decide(&c, "name=other,url=file:///lop");
1363        assert_eq!(rejection(&d), Some(PromisorRejection::UnknownRemote));
1364    }
1365
1366    #[test]
1367    fn known_name_ignores_url() {
1368        let mut c = LOP.to_vec();
1369        c.push(("promisor.acceptFromServer", "knownName"));
1370        let d = decide(&c, "name=lop,url=file:///elsewhere");
1371        assert!(d.any_accepted());
1372        let d = decide(&c, "name=other,url=file:///lop");
1373        assert_eq!(rejection(&d), Some(PromisorRejection::UnknownRemote));
1374    }
1375
1376    #[test]
1377    fn all_accepts_unknown_remotes() {
1378        let d = decide(
1379            &[("promisor.acceptFromServer", "all")],
1380            "name=lop,url=file:///lop;name=x,url=file:///x",
1381        );
1382        assert_eq!(d.accepted_names(), ["lop", "x"]);
1383        assert_eq!(d.reply_value().as_deref(), Some("lop;x"));
1384    }
1385
1386    #[test]
1387    fn check_fields_conditions() {
1388        let mut c = LOP.to_vec();
1389        c.push(("promisor.acceptFromServer", "knownName"));
1390        c.push(("promisor.checkFields", "partialCloneFilter"));
1391        // Not set locally.
1392        let d = decide(&c, "name=lop,url=file:///lop,partialCloneFilter=blob:none");
1393        assert_eq!(
1394            rejection(&d),
1395            Some(PromisorRejection::FieldMissing(
1396                PromisorField::PartialCloneFilter
1397            ))
1398        );
1399        c.push(("remote.lop.partialCloneFilter", "blob:none"));
1400        // Not advertised.
1401        let d = decide(&c, "name=lop,url=file:///lop");
1402        assert_eq!(
1403            rejection(&d),
1404            Some(PromisorRejection::FieldMissing(
1405                PromisorField::PartialCloneFilter
1406            ))
1407        );
1408        // Different (case-sensitive).
1409        let d = decide(&c, "name=lop,url=file:///lop,partialCloneFilter=BLOB:none");
1410        assert_eq!(
1411            rejection(&d),
1412            Some(PromisorRejection::FieldMismatch(
1413                PromisorField::PartialCloneFilter
1414            ))
1415        );
1416        let d = decide(&c, "name=lop,url=file:///lop,partialCloneFilter=blob:none");
1417        assert!(d.any_accepted());
1418    }
1419
1420    #[test]
1421    fn check_fields_under_all_match_any_local_remote() {
1422        let c = [
1423            ("promisor.acceptFromServer", "all"),
1424            ("promisor.checkFields", "token"),
1425            ("remote.lop.promisor", "true"),
1426            ("remote.lop.url", "file:///lop"),
1427            ("remote.lop.token", "secret"),
1428        ];
1429        assert!(decide(&c, "name=new,url=file:///n,token=secret").any_accepted());
1430        assert_eq!(
1431            rejection(&decide(&c, "name=new,url=file:///n,token=other")),
1432            Some(PromisorRejection::FieldMismatch(PromisorField::Token))
1433        );
1434    }
1435
1436    #[test]
1437    fn reply_value_encodes_names() {
1438        let d = decide(
1439            &[("promisor.acceptFromServer", "all")],
1440            "name=a%3Bb,url=file:///x",
1441        );
1442        assert_eq!(d.reply_value().as_deref(), Some("a%3Bb"));
1443        assert_eq!(parse_accepted_reply("a%3Bb").unwrap(), ["a;b"]);
1444    }
1445
1446    #[test]
1447    fn decline_all_rejects_accepted_only() {
1448        let mut c = LOP.to_vec();
1449        c.push(("promisor.acceptFromServer", "knownName"));
1450        let mut d = decide(&c, "name=lop,url=file:///lop;name=x,url=file:///x");
1451        d.decline_all(PromisorRejection::NotPartialFetch);
1452        assert_eq!(
1453            d.candidates[0].rejection,
1454            Some(PromisorRejection::NotPartialFetch)
1455        );
1456        assert_eq!(
1457            d.candidates[1].rejection,
1458            Some(PromisorRejection::UnknownRemote)
1459        );
1460    }
1461
1462    #[test]
1463    fn combined_filter_rules() {
1464        let all = [("promisor.acceptFromServer", "all")];
1465        let d = decide(
1466            &all,
1467            "name=a,url=u,partialCloneFilter=blob:limit=5k;name=b,url=v,partialCloneFilter=blob:none",
1468        );
1469        assert_eq!(
1470            d.combined_filter(),
1471            (Some(ObjectFilterSpec::BlobNone), vec![])
1472        );
1473        let d = decide(
1474            &all,
1475            "name=a,url=u,partialCloneFilter=blob:limit=5k;name=b,url=v,partialCloneFilter=blob:limit=100",
1476        );
1477        assert_eq!(
1478            d.combined_filter(),
1479            (Some(ObjectFilterSpec::BlobLimit(100)), vec![])
1480        );
1481        let d = decide(&all, "name=a,url=u,partialCloneFilter=tree:0;name=b,url=v");
1482        assert_eq!(d.combined_filter(), (None, vec!["tree:0".to_owned()]));
1483    }
1484
1485    #[test]
1486    fn accepted_reply_parsing() {
1487        assert_eq!(parse_accepted_reply(""), Err(PromisorReplyError::Empty));
1488        assert_eq!(parse_accepted_reply(";"), Err(PromisorReplyError::Empty));
1489        assert_eq!(parse_accepted_reply("a;b;a").unwrap(), ["a", "b"]);
1490    }
1491
1492    #[test]
1493    fn lazy_fetch_order_rules() {
1494        let c = cfg(&[
1495            ("extensions.partialClone", "origin"),
1496            ("remote.origin.promisor", "true"),
1497            ("remote.origin.url", "file:///o"),
1498            ("remote.a.promisor", "true"),
1499            ("remote.a.url", "file:///a"),
1500            ("remote.b.partialCloneFilter", "blob:none"),
1501            ("remote.b.url", "file:///b"),
1502        ]);
1503        assert_eq!(lazy_fetch_order(&c, &[]), ["a", "b", "origin"]);
1504        assert_eq!(
1505            lazy_fetch_order(&c, &["b".into(), "gone".into(), "b".into()]),
1506            ["b", "a", "origin"]
1507        );
1508    }
1509
1510    #[test]
1511    fn debug_and_serialization_drop_url_credentials() {
1512        let p = PromisorInfo {
1513            name: "lop".into(),
1514            url: Some("https://bob:pw-secret@lop.example.com/r.git".into()),
1515            filter: None,
1516            token: Some("hunter2".into()),
1517        };
1518        let mut outputs = vec![
1519            format!("{p:?}"),
1520            format!(
1521                "{:?}",
1522                PromisorAdvertisement {
1523                    remotes: vec![p.clone()]
1524                }
1525            ),
1526        ];
1527        #[cfg(feature = "serde-models")]
1528        outputs.push(serde_json::to_string(&p).unwrap());
1529        for shown in outputs {
1530            assert!(!shown.contains("pw-secret"), "{shown}");
1531            assert!(!shown.contains("hunter2"), "{shown}");
1532            assert!(shown.contains("lop.example.com/r.git"), "{shown}");
1533        }
1534
1535        let d = decide(
1536            &[
1537                ("remote.lop.promisor", "true"),
1538                (
1539                    "remote.lop.url",
1540                    "https://bob:pw-secret@lop.example.com/r.git",
1541                ),
1542                ("promisor.acceptFromServer", "knownUrl"),
1543            ],
1544            "name=lop,url=https://lop.example.com/other.git",
1545        );
1546        let Some(PromisorRejection::UrlMismatch { configured }) = rejection(&d) else {
1547            panic!("expected a URL mismatch");
1548        };
1549        assert!(!configured.contains("pw-secret"), "{configured}");
1550        assert!(configured.contains("lop.example.com/r.git"), "{configured}");
1551        let shown = format!("{d:?}");
1552        assert!(!shown.contains("pw-secret"), "{shown}");
1553        #[cfg(feature = "serde-models")]
1554        {
1555            let json = serde_json::to_string(&d).unwrap();
1556            assert!(!json.contains("pw-secret"), "{json}");
1557        }
1558    }
1559
1560    #[test]
1561    fn debug_redacts_token() {
1562        let p = PromisorInfo {
1563            name: "lop".into(),
1564            url: Some("u".into()),
1565            filter: None,
1566            token: Some("hunter2".into()),
1567        };
1568        let shown = format!("{p:?}");
1569        assert!(!shown.contains("hunter2"), "{shown}");
1570        assert!(shown.contains("<redacted>"));
1571    }
1572
1573    #[test]
1574    fn display_url_drops_credentials() {
1575        let p = PromisorInfo {
1576            name: "lop".into(),
1577            url: Some("https://bob:pw@lop.example.com/r.git".into()),
1578            ..Default::default()
1579        };
1580        let shown = p.display_url();
1581        assert!(!shown.contains("pw"), "{shown}");
1582        assert!(shown.contains("lop.example.com/r.git"), "{shown}");
1583        assert_eq!(PromisorInfo::default().display_url(), "");
1584    }
1585
1586    #[cfg(feature = "serde-models")]
1587    #[test]
1588    fn serialized_info_skips_token() {
1589        let p = PromisorInfo {
1590            name: "lop".into(),
1591            url: Some("u".into()),
1592            filter: None,
1593            token: Some("hunter2".into()),
1594        };
1595        let json = serde_json::to_string(&p).unwrap();
1596        assert!(!json.contains("hunter2"), "{json}");
1597        assert!(json.contains("\"name\":\"lop\""), "{json}");
1598    }
1599
1600    #[test]
1601    fn legacy_reply_wrapper() {
1602        let server = cfg(&[
1603            ("promisor.advertise", "true"),
1604            ("promisor.sendFields", "partialCloneFilter"),
1605            ("remote.lop.promisor", "true"),
1606            ("remote.lop.url", "file:///lop"),
1607            ("remote.lop.partialCloneFilter", "blob:limit=5k"),
1608        ]);
1609        let info = promisor_remote_info(&server).unwrap();
1610        let mut client = LOP.to_vec();
1611        client.push(("promisor.acceptFromServer", "All"));
1612        let r = promisor_remote_reply(&cfg(&client), &info);
1613        assert_eq!(r.reply.as_deref(), Some("lop"));
1614        assert_eq!(r.accepted, ["lop"]);
1615        assert_eq!(
1616            r.accepted_filters,
1617            [("lop".to_owned(), "blob:limit=5k".to_owned())]
1618        );
1619    }
1620
1621    #[test]
1622    fn handshake_declines_unless_partial_and_resolves_auto_filter() {
1623        let mut client = LOP.to_vec();
1624        client.push(("promisor.acceptFromServer", "knownUrl"));
1625        let c = cfg(&client);
1626        let a = adv("name=lop,url=file:///lop,partialCloneFilter=blob:limit=5k");
1627
1628        let h = PromisorHandshake::evaluate(&c, &a, false, true);
1629        assert_eq!(
1630            rejection(&h.decision),
1631            Some(PromisorRejection::NotPartialFetch)
1632        );
1633        assert_eq!(h.resolved_filter, None);
1634
1635        let h = PromisorHandshake::evaluate(&c, &a, true, true);
1636        assert!(h.decision.any_accepted());
1637        assert_eq!(
1638            h.resolved_filter,
1639            Some(ObjectFilterSpec::BlobLimit(5 * 1024))
1640        );
1641
1642        let h = PromisorHandshake::evaluate(&c, &a, true, false);
1643        assert_eq!(h.resolved_filter, None);
1644
1645        let a = adv("name=lop,url=file:///lop,partialCloneFilter=tree:0");
1646        let h = PromisorHandshake::evaluate(&c, &a, true, true);
1647        assert_eq!(
1648            h.warnings,
1649            [PromisorConfigWarning::UnusableAutoFilter("tree:0".into())]
1650        );
1651    }
1652
1653    #[test]
1654    fn handshake_store_fields() {
1655        let dir = repo_with_config(
1656            "[remote \"lop\"]\n\turl = file:///lop\n\tpromisor = true\n\
1657             [promisor]\n\tacceptFromServer = knownUrl\n\tstoreFields = partialCloneFilter\n",
1658        );
1659        let c = load(dir.path());
1660        let a = adv("name=lop,url=file:///lop,partialCloneFilter=blob:none");
1661        let mut h = PromisorHandshake::evaluate(&c, &a, true, false);
1662        h.store_fields(dir.path(), &c).unwrap();
1663        assert_eq!(h.stored.len(), 1);
1664        assert_eq!(
1665            load(dir.path())
1666                .get("remote.lop.partialclonefilter")
1667                .as_deref(),
1668            Some("blob:none")
1669        );
1670    }
1671
1672    fn repo_with_config(body: &str) -> tempfile::TempDir {
1673        let dir = tempfile::tempdir().unwrap();
1674        std::fs::write(dir.path().join("config"), body).unwrap();
1675        dir
1676    }
1677
1678    fn load(dir: &Path) -> ConfigSet {
1679        ConfigSet::load_repo_local_only(dir).unwrap()
1680    }
1681
1682    #[test]
1683    fn store_fields_writes_known_remotes_only() {
1684        let dir = repo_with_config(
1685            "[remote \"lop\"]\n\turl = file:///lop\n\tpromisor = true\n\ttoken = old\n",
1686        );
1687        let c = load(dir.path());
1688        let policy = PromisorAcceptPolicy {
1689            accept: AcceptFromServer::All,
1690            check_fields: vec![],
1691            store_fields: vec![PromisorField::PartialCloneFilter, PromisorField::Token],
1692        };
1693        let d = PromisorDecision::decide(
1694            &policy,
1695            &configured_promisor_remotes(&c, &[]),
1696            &adv(
1697                "name=lop,url=file:///evil,partialCloneFilter=blob:limit=5k,token=new;\
1698                  name=unknown,url=file:///u,token=t",
1699            ),
1700        );
1701        let (stored, warnings) = store_advertised_fields(dir.path(), &policy, &c, &d).unwrap();
1702        assert!(warnings.is_empty());
1703        assert_eq!(
1704            stored,
1705            [
1706                StoredPromisorField {
1707                    remote: "lop".into(),
1708                    field: PromisorField::PartialCloneFilter,
1709                    replaced: false
1710                },
1711                StoredPromisorField {
1712                    remote: "lop".into(),
1713                    field: PromisorField::Token,
1714                    replaced: true
1715                }
1716            ]
1717        );
1718        let after = load(dir.path());
1719        assert_eq!(
1720            after.get("remote.lop.partialclonefilter").as_deref(),
1721            Some("blob:limit=5k")
1722        );
1723        assert_eq!(after.get("remote.lop.token").as_deref(), Some("new"));
1724        assert_eq!(after.get("remote.lop.url").as_deref(), Some("file:///lop"));
1725        assert_eq!(after.get("remote.unknown.url"), None);
1726
1727        // Unchanged values are a no-op.
1728        let (stored, _) = store_advertised_fields(dir.path(), &policy, &after, &d).unwrap();
1729        assert!(stored.is_empty());
1730    }
1731
1732    #[test]
1733    fn store_fields_rejects_invalid_values() {
1734        let dir = repo_with_config("[remote \"lop\"]\n\turl = file:///lop\n\tpromisor = true\n");
1735        let c = load(dir.path());
1736        let policy = PromisorAcceptPolicy {
1737            accept: AcceptFromServer::KnownName,
1738            check_fields: vec![],
1739            store_fields: vec![PromisorField::PartialCloneFilter, PromisorField::Token],
1740        };
1741        let d = PromisorDecision::decide(
1742            &policy,
1743            &configured_promisor_remotes(&c, &[]),
1744            &adv("name=lop,url=file:///lop,partialCloneFilter=bogus:filter,token=a%0Ab"),
1745        );
1746        let (stored, warnings) = store_advertised_fields(dir.path(), &policy, &c, &d).unwrap();
1747        assert!(stored.is_empty());
1748        assert_eq!(warnings.len(), 2);
1749        let after = load(dir.path());
1750        assert_eq!(after.get("remote.lop.token"), None);
1751        assert_eq!(after.get("remote.lop.partialclonefilter"), None);
1752    }
1753}