Skip to main content

sley_remote/
promisor.rs

1//! Config policy for protocol-v2 promisor-remote advertisement and acceptance.
2
3use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use sley_config::GitConfig;
9use sley_config::raw_edit::{ConfigFileWriteOptions, RawConfigEditor, write_config_file_locked};
10use sley_config::remotes::remote_names;
11use sley_core::{Capability, GitError, Result};
12use sley_protocol::{
13    PromisorRemoteAdvertisement, encode_promisor_remote_advertisement,
14    encode_promisor_remote_reply, parse_promisor_remote_advertisement,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum PromisorAcceptPolicy {
19    #[default]
20    None,
21    KnownUrl,
22    KnownName,
23    All,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct PromisorRemoteDecision {
28    /// Accepted remotes in server-advertised order.
29    pub accepted: Vec<PromisorRemoteAdvertisement>,
30    /// Encoded client capability reply, absent when none were accepted.
31    pub reply: Option<String>,
32    /// Accepted advertised fields that should be persisted in existing client
33    /// remote configuration.
34    pub stored_fields: Vec<PromisorRemoteFieldUpdate>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PromisorRemoteField {
39    PartialCloneFilter,
40    Token,
41}
42
43impl PromisorRemoteField {
44    pub const fn config_key(self) -> &'static str {
45        match self {
46            Self::PartialCloneFilter => "partialCloneFilter",
47            Self::Token => "token",
48        }
49    }
50
51    pub const fn display_name(self) -> &'static str {
52        match self {
53            Self::PartialCloneFilter => "filter",
54            Self::Token => "token",
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PromisorRemoteFieldUpdate {
61    pub remote_name: String,
62    pub field: PromisorRemoteField,
63    pub previous: Option<String>,
64    pub value: String,
65}
66
67/// Build the server's capability from configured promisor remotes.
68pub fn promisor_remote_server_capability(config: &GitConfig) -> Result<Option<Capability>> {
69    if !config
70        .get_bool("promisor", None, "advertise")
71        .unwrap_or(false)
72    {
73        return Ok(None);
74    }
75    let send_fields = configured_fields(config, "sendFields");
76    let remotes = configured_promisor_remotes(config, &send_fields);
77    if remotes.is_empty() {
78        return Ok(None);
79    }
80    Ok(Some(Capability {
81        name: "promisor-remote".into(),
82        value: Some(encode_promisor_remote_advertisement(&remotes)?),
83    }))
84}
85
86/// Apply `promisor.acceptFromServer` to an advertised capability.
87pub fn decide_promisor_remote_reply(
88    config: &GitConfig,
89    capability: &Capability,
90) -> Result<PromisorRemoteDecision> {
91    if capability.name != "promisor-remote" {
92        return Ok(PromisorRemoteDecision::default());
93    }
94    let Some(value) = capability.value.as_deref() else {
95        return Ok(PromisorRemoteDecision::default());
96    };
97    let policy = promisor_accept_policy(config);
98    if policy == PromisorAcceptPolicy::None {
99        return Ok(PromisorRemoteDecision::default());
100    }
101    let checked_fields = configured_fields(config, "checkFields");
102    let configured = configured_promisor_remotes(config, &checked_fields);
103    let advertised = parse_promisor_remote_advertisement(value)?;
104    let accepted = advertised
105        .into_iter()
106        .filter(|remote| should_accept(policy, remote, &configured, &checked_fields))
107        .collect::<Vec<_>>();
108    let names = accepted
109        .iter()
110        .map(|remote| remote.name.clone())
111        .collect::<Vec<_>>();
112    let stored_fields = planned_stored_fields(config, &accepted);
113    Ok(PromisorRemoteDecision {
114        accepted,
115        reply: encode_promisor_remote_reply(&names)?,
116        stored_fields,
117    })
118}
119
120/// Persist planned accepted-field updates with git's lockfile config writer.
121pub fn apply_promisor_remote_field_updates(
122    git_dir: &Path,
123    updates: &[PromisorRemoteFieldUpdate],
124) -> Result<()> {
125    if updates.is_empty() {
126        return Ok(());
127    }
128    let path = git_dir.join("config");
129    let mut contents = match fs::read(&path) {
130        Ok(contents) => contents,
131        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
132        Err(err) => return Err(GitError::Io(err.to_string())),
133    };
134    for update in updates {
135        let mut editor = RawConfigEditor::new(
136            contents,
137            "remote",
138            Some(&update.remote_name),
139            update.field.config_key(),
140        );
141        editor.set_multivar(Some(&update.value), None, None, true);
142        contents = editor.into_bytes();
143    }
144    write_config_file_locked(&path, &contents, ConfigFileWriteOptions::default())
145        .map_err(|err| GitError::Io(err.to_string()))
146}
147
148/// Construct the concrete partial-clone filter for `--filter=auto` from the
149/// filters carried by accepted promisor advertisements.
150pub fn promisor_remote_auto_filter(
151    decision: &PromisorRemoteDecision,
152) -> Option<sley_odb::PackObjectFilter> {
153    decision
154        .accepted
155        .iter()
156        .filter_map(|remote| remote.partial_clone_filter.as_deref())
157        .filter_map(crate::pack_filter_from_spec)
158        .reduce(crate::filter::combine_pack_filters)
159}
160
161pub fn promisor_accept_policy(config: &GitConfig) -> PromisorAcceptPolicy {
162    match config.get("promisor", None, "acceptFromServer") {
163        Some(value) if value.eq_ignore_ascii_case("All") => PromisorAcceptPolicy::All,
164        Some(value) if value.eq_ignore_ascii_case("KnownName") => PromisorAcceptPolicy::KnownName,
165        Some(value) if value.eq_ignore_ascii_case("KnownUrl") => PromisorAcceptPolicy::KnownUrl,
166        _ => PromisorAcceptPolicy::None,
167    }
168}
169
170/// Whether repository config declares at least one promisor object source.
171pub fn config_has_promisor_remote(config: &GitConfig) -> bool {
172    config
173        .get("extensions", None, "partialclone")
174        .is_some_and(|value| !value.is_empty())
175        || remote_names(config).into_iter().any(|name| {
176            config
177                .get_bool("remote", Some(&name), "promisor")
178                .unwrap_or(false)
179                || config
180                    .get("remote", Some(&name), "partialCloneFilter")
181                    .is_some_and(|value| !value.is_empty())
182        })
183}
184
185/// Configured promisor remotes in Git's lazy-fetch order.
186///
187/// Remotes retain config order, except the legacy/default remote named by
188/// `extensions.partialClone`, which Git moves to the tail so more specialized
189/// promisors are tried first. If that default is not otherwise configured as a
190/// promisor, it is still appended as the final fallback.
191pub fn configured_promisor_remote_names(config: &GitConfig) -> Vec<String> {
192    let mut config_order = Vec::new();
193    for section in &config.sections {
194        if section.name.eq_ignore_ascii_case("remote")
195            && let Some(name) = section.subsection.as_deref()
196            && !config_order.iter().any(|existing| existing == name)
197        {
198            config_order.push(name.to_string());
199        }
200    }
201    let mut names = config_order
202        .into_iter()
203        .filter(|name| {
204            config
205                .get_bool("remote", Some(name), "promisor")
206                .unwrap_or(false)
207                || config
208                    .get("remote", Some(name), "partialCloneFilter")
209                    .is_some_and(|value| !value.is_empty())
210        })
211        .collect::<Vec<_>>();
212    if let Some(default) = config
213        .get("extensions", None, "partialClone")
214        .filter(|value| !value.is_empty())
215    {
216        if let Some(position) = names.iter().position(|name| name == default) {
217            let default = names.remove(position);
218            names.push(default);
219        } else {
220            names.push(default.to_string());
221        }
222    }
223    names
224}
225
226/// Emit the native equivalent of Git's lazy-promisor `run_command` trace.
227///
228/// Sley performs this contact in-process; the trace records the equivalent Git
229/// operation for `GIT_TRACE` consumers without spawning Git or a helper.
230pub(crate) fn trace_promisor_remote_contact(remote_name: &str) {
231    let Some(target) = env::var_os("GIT_TRACE") else {
232        return;
233    };
234    let value = target.to_string_lossy();
235    if matches!(value.to_ascii_lowercase().as_str(), "" | "0" | "false") {
236        return;
237    }
238    let line = promisor_remote_contact_trace_line(remote_name);
239    if matches!(value.to_ascii_lowercase().as_str(), "1" | "2" | "true") {
240        eprintln!("{line}");
241    } else if Path::new(target.as_os_str()).is_absolute()
242        && let Ok(mut file) = fs::OpenOptions::new()
243            .create(true)
244            .append(true)
245            .open(target)
246    {
247        let _ = writeln!(file, "{line}");
248    }
249}
250
251fn promisor_remote_contact_trace_line(remote_name: &str) -> String {
252    format!(
253        "trace: run_command: git -c fetch.negotiationAlgorithm=noop fetch {} --no-tags --no-write-fetch-head --recurse-submodules=no --filter=blob:none --stdin",
254        trace_quote_argument(remote_name)
255    )
256}
257
258fn trace_quote_argument(value: &str) -> String {
259    const SAFE: &[u8] = b"+,-./:=@_^";
260    if !value.is_empty()
261        && value
262            .bytes()
263            .all(|byte| byte.is_ascii_alphanumeric() || SAFE.contains(&byte))
264    {
265        return value.to_string();
266    }
267    format!("'{}'", value.replace('\'', "'\\''"))
268}
269
270fn configured_fields(config: &GitConfig, key: &str) -> Vec<String> {
271    config
272        .get("promisor", None, key)
273        .unwrap_or("")
274        .split(',')
275        .map(str::trim)
276        .filter(|field| {
277            field.eq_ignore_ascii_case("partialCloneFilter") || field.eq_ignore_ascii_case("token")
278        })
279        .map(str::to_string)
280        .collect()
281}
282
283fn configured_promisor_remotes(
284    config: &GitConfig,
285    fields: &[String],
286) -> Vec<PromisorRemoteAdvertisement> {
287    remote_names(config)
288        .into_iter()
289        .filter(|name| {
290            config
291                .get_bool("remote", Some(name), "promisor")
292                .unwrap_or(false)
293                || config
294                    .get("remote", Some(name), "partialCloneFilter")
295                    .is_some_and(|value| !value.is_empty())
296        })
297        .filter_map(|name| {
298            let url = config.get("remote", Some(&name), "url")?.to_string();
299            if url.is_empty() {
300                return None;
301            }
302            let includes = |field: &str| fields.iter().any(|item| item.eq_ignore_ascii_case(field));
303            Some(PromisorRemoteAdvertisement {
304                partial_clone_filter: includes("partialCloneFilter")
305                    .then(|| {
306                        config
307                            .get("remote", Some(&name), "partialCloneFilter")
308                            .map(str::to_string)
309                    })
310                    .flatten(),
311                token: includes("token")
312                    .then(|| {
313                        config
314                            .get("remote", Some(&name), "token")
315                            .map(str::to_string)
316                    })
317                    .flatten(),
318                name,
319                url,
320            })
321        })
322        .collect()
323}
324
325fn planned_stored_fields(
326    config: &GitConfig,
327    accepted: &[PromisorRemoteAdvertisement],
328) -> Vec<PromisorRemoteFieldUpdate> {
329    let fields = configured_fields(config, "storeFields");
330    if fields.is_empty() {
331        return Vec::new();
332    }
333    let configured = configured_promisor_remotes(config, &fields);
334    let mut updates = Vec::new();
335    for advertised in accepted {
336        let Some(current) = configured
337            .iter()
338            .find(|remote| remote.name == advertised.name)
339        else {
340            continue;
341        };
342        for field_name in &fields {
343            let (field, advertised_value, previous) =
344                if field_name.eq_ignore_ascii_case("partialCloneFilter") {
345                    (
346                        PromisorRemoteField::PartialCloneFilter,
347                        advertised.partial_clone_filter.as_deref(),
348                        current.partial_clone_filter.as_deref(),
349                    )
350                } else {
351                    (
352                        PromisorRemoteField::Token,
353                        advertised.token.as_deref(),
354                        current.token.as_deref(),
355                    )
356                };
357            let Some(value) = advertised_value else {
358                continue;
359            };
360            let valid = match field {
361                PromisorRemoteField::PartialCloneFilter => {
362                    crate::pack_filter_from_spec(value).is_some()
363                }
364                PromisorRemoteField::Token => !value.chars().any(char::is_control),
365            };
366            if valid && previous != Some(value) {
367                updates.push(PromisorRemoteFieldUpdate {
368                    remote_name: advertised.name.clone(),
369                    field,
370                    previous: previous.map(str::to_string),
371                    value: value.to_string(),
372                });
373            }
374        }
375    }
376    updates
377}
378
379fn should_accept(
380    policy: PromisorAcceptPolicy,
381    advertised: &PromisorRemoteAdvertisement,
382    configured: &[PromisorRemoteAdvertisement],
383    checked_fields: &[String],
384) -> bool {
385    let named = configured
386        .iter()
387        .find(|remote| remote.name == advertised.name);
388    let candidate = match policy {
389        PromisorAcceptPolicy::None => return false,
390        PromisorAcceptPolicy::All => None,
391        PromisorAcceptPolicy::KnownName => match named {
392            Some(remote) => Some(remote),
393            None => return false,
394        },
395        PromisorAcceptPolicy::KnownUrl => match named {
396            Some(remote) if remote.url == advertised.url => Some(remote),
397            _ => return false,
398        },
399    };
400    checked_fields.iter().all(|field| {
401        let advertised_value = field_value(advertised, field);
402        match candidate {
403            Some(configured) => advertised_value == field_value(configured, field),
404            None => advertised_value.is_some_and(|value| {
405                configured
406                    .iter()
407                    .any(|remote| field_value(remote, field) == Some(value))
408            }),
409        }
410    })
411}
412
413fn field_value<'a>(remote: &'a PromisorRemoteAdvertisement, field: &str) -> Option<&'a str> {
414    if field.eq_ignore_ascii_case("partialCloneFilter") {
415        remote.partial_clone_filter.as_deref()
416    } else if field.eq_ignore_ascii_case("token") {
417        remote.token.as_deref()
418    } else {
419        None
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn advertises_promisors_in_config_order_with_selected_fields() {
429        let config = GitConfig::parse(
430            b"[promisor]\n\tadvertise = true\n\tsendFields = partialCloneFilter, token\n\
431              [remote \"first\"]\n\tpromisor = true\n\turl = file:///one\n\tpartialCloneFilter = blob:none\n\
432              [remote \"ignored\"]\n\turl = file:///ignored\n\
433              [remote \"second\"]\n\tpromisor = true\n\turl = file:///two\n\ttoken = secret\n",
434        )
435        .expect("config");
436        let capability = promisor_remote_server_capability(&config)
437            .expect("capability")
438            .expect("advertised");
439        assert_eq!(
440            capability.value.as_deref(),
441            Some(
442                "name=first,url=file:///one,partialCloneFilter=blob:none;name=second,url=file:///two,token=secret"
443            )
444        );
445    }
446
447    #[test]
448    fn acceptance_preserves_server_order_and_honors_known_name() {
449        let config = GitConfig::parse(
450            b"[promisor]\n\tacceptFromServer = KnownName\n\
451              [remote \"second\"]\n\tpromisor = true\n\turl = file:///local-two\n\
452              [remote \"first\"]\n\tpromisor = true\n\turl = file:///local-one\n",
453        )
454        .expect("config");
455        let capability = Capability {
456            name: "promisor-remote".into(),
457            value: Some(
458                "name=first,url=file:///server-one;name=second,url=file:///server-two".into(),
459            ),
460        };
461        let decision = decide_promisor_remote_reply(&config, &capability).expect("decision");
462        assert_eq!(decision.reply.as_deref(), Some("first;second"));
463    }
464
465    #[test]
466    fn accepted_fields_plan_storage_and_resolve_auto_filter() {
467        let config = GitConfig::parse(
468            b"[promisor]\n\tacceptFromServer = All\n\tstoreFields = partialCloneFilter\n\
469              [remote \"lop\"]\n\tpromisor = true\n\turl = file:///lop\n\tpartialCloneFilter = blob:none\n",
470        )
471        .expect("config");
472        let capability = Capability {
473            name: "promisor-remote".into(),
474            value: Some(
475                "name=lop,url=file:///lop,partialCloneFilter=blob:limit=9500,token=secret".into(),
476            ),
477        };
478        let decision = decide_promisor_remote_reply(&config, &capability).expect("decision");
479        assert_eq!(
480            decision.stored_fields,
481            vec![PromisorRemoteFieldUpdate {
482                remote_name: "lop".into(),
483                field: PromisorRemoteField::PartialCloneFilter,
484                previous: Some("blob:none".into()),
485                value: "blob:limit=9500".into(),
486            }]
487        );
488        assert_eq!(
489            promisor_remote_auto_filter(&decision),
490            Some(sley_odb::PackObjectFilter::BlobLimit(9500))
491        );
492    }
493
494    #[test]
495    fn lazy_fetch_order_moves_default_partial_clone_remote_to_tail() {
496        let config = GitConfig::parse(
497            b"[extensions]\n\tpartialClone = origin\n\
498              [remote \"origin\"]\n\tpromisor = true\n\turl = file:///origin\n\
499              [remote \"lop\"]\n\tpromisor = true\n\turl = file:///lop\n\
500              [remote \"archive\"]\n\tpartialCloneFilter = blob:none\n\turl = file:///archive\n",
501        )
502        .expect("config");
503        assert_eq!(
504            configured_promisor_remote_names(&config),
505            vec!["lop", "archive", "origin"]
506        );
507    }
508
509    #[test]
510    fn native_promisor_contact_trace_matches_git_run_command_shape() {
511        assert_eq!(
512            promisor_remote_contact_trace_line("lop"),
513            "trace: run_command: git -c fetch.negotiationAlgorithm=noop fetch lop --no-tags --no-write-fetch-head --recurse-submodules=no --filter=blob:none --stdin"
514        );
515        assert!(
516            !promisor_remote_contact_trace_line("lop").contains("unused_lop"),
517            "the trace names only the remote actually selected"
518        );
519    }
520}