Skip to main content

binstalk_manifests/
cargo_config.rs

1//! Cargo's `.cargo/config.toml`
2//!
3//! This manifest is used by Cargo to load configurations stored by users.
4//!
5//! Binstall reads from them to be compatible with `cargo-install`'s behavior.
6
7use std::{
8    collections::{btree_map::Entry as BTreeMapEntry, BTreeMap, HashSet, VecDeque},
9    fs::File,
10    io, mem,
11    path::{Path, PathBuf},
12};
13
14use compact_str::CompactString;
15use fs_lock::FileLock;
16use home::cargo_home;
17use merge::Merge;
18use miette::Diagnostic;
19use normalize_path::NormalizePath;
20use serde::Deserialize;
21use thiserror::Error;
22
23#[derive(Clone, Debug, Deserialize, Merge)]
24pub struct Install {
25    /// `cargo install` destination directory
26    #[merge(strategy = merge::option::overwrite_none)]
27    pub root: Option<PathBuf>,
28}
29
30#[derive(Clone, Debug, Deserialize, Merge)]
31pub struct Http {
32    /// HTTP proxy in libcurl format: "host:port"
33    ///
34    /// env: CARGO_HTTP_PROXY or HTTPS_PROXY or https_proxy or http_proxy
35    #[merge(strategy = merge::option::overwrite_none)]
36    pub proxy: Option<CompactString>,
37    /// timeout for each HTTP request, in seconds
38    ///
39    /// env: CARGO_HTTP_TIMEOUT or HTTP_TIMEOUT
40    #[merge(strategy = merge::option::overwrite_none)]
41    pub timeout: Option<u64>,
42    /// path to Certificate Authority (CA) bundle
43    #[merge(strategy = merge::option::overwrite_none)]
44    pub cainfo: Option<PathBuf>,
45}
46
47#[derive(Eq, PartialEq, Debug, Deserialize)]
48#[serde(untagged)]
49pub enum Env {
50    Value(CompactString),
51    WithOptions {
52        value: CompactString,
53        force: Option<bool>,
54        relative: Option<bool>,
55    },
56}
57
58#[derive(Debug, Deserialize, Merge)]
59pub struct Registry {
60    #[merge(strategy = merge::option::overwrite_none)]
61    pub index: Option<CompactString>,
62    #[serde(rename = "replace-with")]
63    #[merge(strategy = merge::option::overwrite_none)]
64    pub replace_with: Option<CompactString>,
65    #[serde(rename = "credential-provider")]
66    #[merge(strategy = merge::option::overwrite_none)]
67    pub credential_provider: Option<CredentialProvider>,
68}
69
70type GlobalCredentialProviders = Option<VecDeque<CompactString>>;
71fn merge_global_credential_providers(
72    left: &mut GlobalCredentialProviders,
73    right: GlobalCredentialProviders,
74) {
75    match (left.as_mut(), right) {
76        (None, right) => *left = right,
77        (Some(_), None) => (),
78        (Some(left), Some(right)) => {
79            left.reserve(right.len());
80            for provider in right.into_iter().rev() {
81                left.push_front(provider);
82            }
83        }
84    }
85}
86
87#[derive(Debug, Deserialize, Merge)]
88pub struct DefaultRegistry {
89    #[merge(strategy = merge::option::overwrite_none)]
90    pub default: Option<CompactString>,
91    #[serde(rename = "credential-provider")]
92    #[merge(strategy = merge::option::overwrite_none)]
93    pub credential_provider: Option<CredentialProvider>,
94    #[serde(rename = "global-credential-providers")]
95    #[merge(strategy = merge_global_credential_providers)]
96    pub global_credential_providers: GlobalCredentialProviders,
97}
98
99#[derive(Clone, Debug, Deserialize)]
100#[serde(untagged)]
101pub enum CredentialProvider {
102    String(CompactString),
103    Array(Vec<CompactString>),
104}
105
106#[derive(Deserialize, Debug)]
107#[serde(untagged)]
108pub enum IncludedConfig {
109    Path(PathBuf),
110    Extended {
111        path: PathBuf,
112        #[serde(default)]
113        optional: bool,
114    },
115}
116
117impl IncludedConfig {
118    pub fn path(&self) -> &Path {
119        match self {
120            Self::Path(path) => path,
121            Self::Extended { path, .. } => path,
122        }
123    }
124
125    pub fn path_mut(&mut self) -> &mut PathBuf {
126        match self {
127            Self::Path(path) => path,
128            Self::Extended { path, .. } => path,
129        }
130    }
131
132    pub fn optional(&self) -> bool {
133        match self {
134            Self::Path(..) => false,
135            Self::Extended { optional, .. } => *optional,
136        }
137    }
138
139    fn open(&self) -> io::Result<Option<FileLock>> {
140        let path = self.path().canonicalize()?;
141
142        match File::open(&path) {
143            Err(err) if err.kind() == io::ErrorKind::NotFound && self.optional() => Ok(None),
144            res => Ok(Some(FileLock::new_shared(res?)?.set_file_path(path))),
145        }
146    }
147}
148
149fn merge_btreemap<K: Ord, V>(left: &mut BTreeMap<K, V>, right: BTreeMap<K, V>) {
150    for (k, v) in right.into_iter() {
151        left.entry(k).or_insert(v);
152    }
153}
154
155fn merge_btreemap_recursive<K: Ord, V: Merge>(left: &mut BTreeMap<K, V>, right: BTreeMap<K, V>) {
156    for (k, v) in right.into_iter() {
157        match left.entry(k) {
158            BTreeMapEntry::Vacant(entry) => {
159                entry.insert(v);
160            }
161            BTreeMapEntry::Occupied(entry) => entry.into_mut().merge(v),
162        }
163    }
164}
165
166#[derive(Debug, Default, Deserialize, Merge)]
167#[non_exhaustive]
168pub struct Config {
169    #[merge(strategy = merge::option::recurse)]
170    pub install: Option<Install>,
171    #[merge(strategy = merge::option::recurse)]
172    pub http: Option<Http>,
173    #[serde(default)]
174    #[merge(strategy = merge_btreemap)]
175    pub env: BTreeMap<CompactString, Env>,
176    #[serde(default)]
177    #[merge(strategy = merge_btreemap_recursive)]
178    pub registries: BTreeMap<CompactString, Registry>,
179    #[merge(strategy = merge::option::recurse)]
180    pub registry: Option<DefaultRegistry>,
181    #[serde(default)]
182    #[merge(skip)]
183    pub include: Vec<IncludedConfig>,
184    #[serde(default, rename = "credential-alias")]
185    #[merge(strategy = merge_btreemap)]
186    pub credential_alias: BTreeMap<CompactString, CredentialProvider>,
187}
188
189fn join_if_relative(path: Option<&mut PathBuf>, dir: &Path) {
190    match path {
191        Some(path) if path.is_relative() => *path = dir.join(&*path),
192        _ => (),
193    }
194}
195
196fn iterate_reverse_preorder(
197    mut stack: Vec<IncludedConfig>,
198    mut load_config: impl FnMut(
199        &mut dyn io::Read,
200        &Path,
201    ) -> Result<Vec<IncludedConfig>, ConfigLoadError>,
202) -> Result<(), ConfigLoadError> {
203    // stack invariant: higher precedence config is the first out
204    let mut visited_path = HashSet::new();
205
206    while let Some(config_path) = stack.pop() {
207        let Some(file) = config_path.open()? else {
208            continue;
209        };
210
211        let path = file.get_file_path().unwrap(); // canonicalized path
212        let parent = config_path.path().parent().unwrap(); // original path
213
214        // Use the absolute, canonicalized path (expanded symlink) to track the
215        // content of the config being loaded, and use the normalized parent
216        // (not absolute or expanded symlink) to track the parent, as that
217        // would decide all the relative path in the config.
218        //
219        // Even if one config file has two symlinks, the content might be different
220        // if relative path is present.
221        if !visited_path.insert((path.to_owned(), parent.normalize())) {
222            return Err(ConfigLoadError::DeadLoopInLoading {
223                path: path.into(),
224                parent: parent.into(),
225            });
226        }
227
228        stack.extend(load_config(&mut (&file), parent)?);
229    }
230
231    Ok(())
232}
233
234impl Config {
235    pub fn default_path() -> Result<PathBuf, ConfigLoadError> {
236        Ok(cargo_home()?.join("config.toml"))
237    }
238
239    pub fn load() -> Result<Self, ConfigLoadError> {
240        Self::load_from_path(Self::default_path()?)
241    }
242
243    fn load_from_reader_inner(
244        reader: &mut dyn io::Read,
245        dir: &Path,
246    ) -> Result<Self, ConfigLoadError> {
247        let mut vec = Vec::new();
248        reader.read_to_end(&mut vec)?;
249
250        if vec.is_empty() {
251            Ok(Default::default())
252        } else {
253            let mut config: Config = toml_edit::de::from_slice(&vec)?;
254            join_if_relative(
255                config
256                    .install
257                    .as_mut()
258                    .and_then(|install| install.root.as_mut()),
259                dir,
260            );
261            join_if_relative(
262                config.http.as_mut().and_then(|http| http.cainfo.as_mut()),
263                dir,
264            );
265            for env in config.env.values_mut() {
266                let Env::WithOptions {
267                    value,
268                    relative: Some(true),
269                    ..
270                } = env
271                else {
272                    continue;
273                };
274                let path = Path::new(&value);
275                if path.is_relative() {
276                    *value = dir.join(path).to_string_lossy().into();
277                }
278            }
279
280            for included_config in &mut config.include {
281                join_if_relative(Some(included_config.path_mut()), dir);
282            }
283
284            Ok(config)
285        }
286    }
287
288    /// * `dir` - path to the dir where the config.toml is located.
289    ///   For relative path in the config, `Config::load_from_reader`
290    ///   will join the `dir` and the relative path to form the final
291    ///   path.
292    pub fn load_from_reader<R: io::Read>(
293        mut reader: R,
294        dir: &Path,
295    ) -> Result<Self, ConfigLoadError> {
296        fn inner(reader: &mut dyn io::Read, dir: &Path) -> Result<Config, ConfigLoadError> {
297            let mut root_config = Config::load_from_reader_inner(reader, dir)?;
298
299            iterate_reverse_preorder(mem::take(&mut root_config.include), |file, parent| {
300                let mut config = Config::load_from_reader_inner(file, parent)?;
301                let includes = mem::take(&mut config.include);
302                root_config.merge(config);
303                Ok(includes)
304            })?;
305
306            Ok(root_config)
307        }
308
309        inner(&mut reader, dir)
310    }
311
312    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigLoadError> {
313        fn inner(path: &Path) -> Result<Config, ConfigLoadError> {
314            match File::open(path) {
315                Ok(file) => {
316                    let file = FileLock::new_shared(file)?.set_file_path(path);
317                    // Any regular file must have a parent dir
318                    Config::load_from_reader(file, path.parent().unwrap())
319                }
320                Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Default::default()),
321                Err(err) => Err(err.into()),
322            }
323        }
324
325        inner(path.as_ref())
326    }
327
328    pub fn get_registry_index(&self, name: &str) -> Option<&str> {
329        let registry = self.registries.get(name)?;
330
331        if let Some(name) = registry.replace_with.as_deref() {
332            self.get_registry_index(name)
333        } else {
334            registry.index.as_deref()
335        }
336    }
337
338    pub fn get_registry(&self, name: &str) -> Option<&Registry> {
339        let registry = self.registries.get(name)?;
340
341        if let Some(name) = registry.replace_with.as_deref() {
342            self.get_registry(name)
343        } else {
344            Some(registry)
345        }
346    }
347}
348
349#[derive(Debug, Diagnostic, Error)]
350#[non_exhaustive]
351pub enum ConfigLoadError {
352    #[error("I/O Error: {0}")]
353    Io(#[from] io::Error),
354
355    #[error("Failed to deserialize toml: {0}")]
356    TomlParse(Box<toml_edit::de::Error>),
357
358    #[error("Detect deadloop in toml at `{path}` with parent `{parent}`")]
359    DeadLoopInLoading { path: Box<Path>, parent: Box<Path> },
360}
361
362impl From<toml_edit::de::Error> for ConfigLoadError {
363    fn from(e: toml_edit::de::Error) -> Self {
364        ConfigLoadError::TomlParse(Box::new(e))
365    }
366}
367
368impl From<toml_edit::TomlError> for ConfigLoadError {
369    fn from(e: toml_edit::TomlError) -> Self {
370        ConfigLoadError::TomlParse(Box::new(e.into()))
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    use std::{io::Cursor, path::MAIN_SEPARATOR};
379
380    use compact_str::{format_compact, ToCompactString};
381
382    const CONFIG: &str = r#"
383[env]
384# Set ENV_VAR_NAME=value for any process run by Cargo
385ENV_VAR_NAME = "value"
386# Set even if already present in environment
387ENV_VAR_NAME_2 = { value = "value", force = true }
388# Value is relative to .cargo directory containing `config.toml`, make absolute
389ENV_VAR_NAME_3 = { value = "relative-path", relative = true }
390
391[http]
392debug = false               # HTTP debugging
393proxy = "host:port"         # HTTP proxy in libcurl format
394timeout = 30                # timeout for each HTTP request, in seconds
395cainfo = "cert.pem"         # path to Certificate Authority (CA) bundle
396
397[install]
398root = "/some/path"         # `cargo install` destination directory
399
400[registries.private-registry]
401index = "sparse+https://registry.example.com/index/"
402credential-provider = "cargo:token"
403
404[registry]
405default = "private-registry"
406credential-provider = "cargo:token"
407global-credential-providers = ["cargo:token", "cargo:libsecret"]
408
409[credential-alias]
410custom = ["cargo-credential-example", "--account", "test"]
411    "#;
412
413    #[test]
414    fn test_loading() {
415        let config = Config::load_from_reader(Cursor::new(&CONFIG), Path::new("root")).unwrap();
416
417        assert_eq!(
418            config.install.unwrap().root.as_deref().unwrap(),
419            Path::new("/some/path")
420        );
421
422        let http = config.http.unwrap();
423        assert_eq!(http.proxy.unwrap(), CompactString::const_new("host:port"));
424        assert_eq!(http.timeout.unwrap(), 30);
425        assert_eq!(http.cainfo.unwrap(), Path::new("root").join("cert.pem"));
426
427        let env = config.env;
428        assert_eq!(env.len(), 3);
429        assert_eq!(
430            env.get("ENV_VAR_NAME").unwrap(),
431            &Env::Value(CompactString::const_new("value"))
432        );
433        assert_eq!(
434            env.get("ENV_VAR_NAME_2").unwrap(),
435            &Env::WithOptions {
436                value: CompactString::new("value"),
437                force: Some(true),
438                relative: None,
439            }
440        );
441        assert_eq!(
442            env.get("ENV_VAR_NAME_3").unwrap(),
443            &Env::WithOptions {
444                value: format_compact!("root{MAIN_SEPARATOR}relative-path"),
445                force: None,
446                relative: Some(true),
447            }
448        );
449
450        let registries = config.registries;
451        let private_registry = registries.get("private-registry").unwrap();
452        assert_eq!(
453            private_registry.index.as_deref(),
454            Some("sparse+https://registry.example.com/index/")
455        );
456        assert!(matches!(
457            private_registry.credential_provider.as_ref(),
458            Some(CredentialProvider::String(provider)) if provider == "cargo:token"
459        ));
460
461        let mut registry = config.registry.unwrap();
462        assert_eq!(registry.default.as_deref(), Some("private-registry"));
463        assert!(matches!(
464            registry.credential_provider.as_ref(),
465            Some(CredentialProvider::String(provider)) if provider == "cargo:token"
466        ));
467        assert_eq!(
468            registry
469                .global_credential_providers
470                .as_mut()
471                .map(VecDeque::make_contiguous)
472                .as_deref(),
473            Some(
474                &[
475                    CompactString::const_new("cargo:token"),
476                    CompactString::const_new("cargo:libsecret"),
477                ][..]
478            )
479        );
480
481        let aliases = config.credential_alias;
482        assert!(matches!(
483            aliases.get("custom"),
484            Some(CredentialProvider::Array(provider))
485                if provider
486                    == &[
487                        CompactString::const_new("cargo-credential-example"),
488                        CompactString::const_new("--account"),
489                        CompactString::const_new("test"),
490                    ]
491        ));
492    }
493
494    #[test]
495    fn test_merge_config() {
496        let mut config = Config {
497            // Omit include and http as they use prebuilt strategy
498            install: None,
499            http: None,
500            // Skipped during merge
501            include: Vec::new(),
502            // Same strategy as env
503            credential_alias: BTreeMap::new(),
504
505            env: BTreeMap::from([
506                (CompactString::new("1"), Env::Value(CompactString::new("1"))),
507                (CompactString::new("2"), Env::Value(CompactString::new("2"))),
508            ]),
509            registries: BTreeMap::from([
510                (
511                    CompactString::new("1"),
512                    Registry {
513                        index: None,
514                        replace_with: None,
515                        credential_provider: None,
516                    },
517                ),
518                (
519                    CompactString::new("2"),
520                    Registry {
521                        index: Some(CompactString::new("!")),
522                        replace_with: None,
523                        credential_provider: None,
524                    },
525                ),
526            ]),
527            registry: Some(DefaultRegistry {
528                default: Some(CompactString::new("1")),
529                credential_provider: None,
530                global_credential_providers: Some(VecDeque::from([CompactString::new("left")])),
531            }),
532        };
533        config.merge(Config {
534            install: None,
535            http: None,
536            include: Vec::new(),
537            credential_alias: BTreeMap::new(),
538
539            env: BTreeMap::from([
540                (
541                    CompactString::new("2"),
542                    Env::Value(CompactString::new("qwewrd")),
543                ),
544                (CompactString::new("3"), Env::Value(CompactString::new("3"))),
545            ]),
546            registries: BTreeMap::from([
547                (
548                    CompactString::new("2"),
549                    Registry {
550                        index: Some(CompactString::new("indexex")),
551                        replace_with: Some(CompactString::new("ere")),
552                        credential_provider: None,
553                    },
554                ),
555                (
556                    CompactString::new("3"),
557                    Registry {
558                        index: None,
559                        replace_with: Some(CompactString::new("re")),
560                        credential_provider: Some(CredentialProvider::String(CompactString::new(
561                            "213",
562                        ))),
563                    },
564                ),
565            ]),
566            registry: Some(DefaultRegistry {
567                default: Some(CompactString::new("www1")),
568                credential_provider: Some(CredentialProvider::String(CompactString::new("ww213"))),
569                global_credential_providers: Some(
570                    ["right", "2"].into_iter().map(Into::into).collect(),
571                ),
572            }),
573        });
574
575        assert_eq!(
576            config.env,
577            [1, 2, 3]
578                .iter()
579                .map(ToCompactString::to_compact_string)
580                .map(|s| (s.clone(), Env::Value(s)))
581                .collect::<BTreeMap<_, _>>(),
582        );
583
584        assert_eq!(
585            config.registries.keys().collect::<Vec<_>>(),
586            ["1", "2", "3"]
587        );
588
589        let registry_1 = config.registries.remove("1").unwrap();
590        assert_eq!(registry_1.index, None);
591        assert_eq!(registry_1.replace_with, None);
592        assert!(registry_1.credential_provider.is_none());
593
594        let registry_2 = config.registries.remove("2").unwrap();
595        assert_eq!(registry_2.index, Some(CompactString::new("!")));
596        assert_eq!(registry_2.replace_with, Some(CompactString::new("ere")));
597        assert!(registry_2.credential_provider.is_none());
598
599        let registry_3 = config.registries.remove("3").unwrap();
600        assert_eq!(registry_3.index, None);
601        assert_eq!(registry_3.replace_with, Some(CompactString::new("re")));
602        assert!(
603            matches!(registry_3.credential_provider.unwrap(), CredentialProvider::String(v) if v == "213")
604        );
605
606        let default_registry = config.registry.unwrap();
607        assert_eq!(default_registry.default, Some(CompactString::new("1")));
608        assert!(
609            matches!(default_registry.credential_provider.unwrap(), CredentialProvider::String(v) if v == "ww213")
610        );
611        assert_eq!(
612            default_registry.global_credential_providers.unwrap(),
613            ["right", "2", "left"]
614                .into_iter()
615                .map(CompactString::new)
616                .collect::<Vec<_>>(),
617        );
618    }
619}