From 17848a13d98568c3189a3c034f9ffd45be89df03 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Wed, 5 Aug 2026 12:57:18 +0000 Subject: [PATCH] feat: add a shared config layer read by all rattler-based tools Tools no longer read each other's config files (rattler-build reading `~/.pixi/config.toml` warned about every pixi-only key and leaked pixi settings into builds). Instead there is one shared layer that every rattler-based tool reads: - Shared files live in `/etc/rattler/config.toml` and `$XDG_CONFIG_HOME/rattler/config.toml`. `$RATTLER_HOME/config.toml` is honored when set, without a `~/.rattler` fallback. - Shared files accept only the `CommonConfig` keys. Tool-specific keys warn in every tool, so a shared file means the same thing everywhere. - `locations::config_search_paths` takes a single tool and returns `ConfigLocation`s interleaving both layers: system shared < system tool < user shared < user tool. A path appearing in both layers is parsed as a tool file. - `ConfigBase::load_from_default_locations` takes the tool name; `load_from_locations` merges an explicit list of `ConfigLocation`s. --- crates/rattler_config/src/config.rs | 111 ++- crates/rattler_config/src/lib.rs | 84 +- crates/rattler_config/src/locations.rs | 212 ++++- crates/rattler_config/tests/shared_layer.rs | 867 ++++++++++++++++++++ 4 files changed, 1202 insertions(+), 72 deletions(-) create mode 100644 crates/rattler_config/tests/shared_layer.rs diff --git a/crates/rattler_config/src/config.rs b/crates/rattler_config/src/config.rs index d514a3ae6a..df3720f4c5 100644 --- a/crates/rattler_config/src/config.rs +++ b/crates/rattler_config/src/config.rs @@ -25,6 +25,7 @@ use crate::config::{ build::BuildConfig, concurrency::ConcurrencyConfig, index::IndexConfig, proxy::ProxyConfig, repodata_config::RepodataConfig, run_post_link_scripts::RunPostLinkScripts, }; +use crate::locations::{ConfigLayer, ConfigLocation}; pub mod build; pub mod channel_config; @@ -493,10 +494,64 @@ where )) } + /// Parse a *shared* configuration file from a TOML string. + /// + /// Shared files (see [`crate::locations::ConfigLayer::Shared`]) may only + /// contain the keys shared by all rattler-based tools: the document is + /// deserialized into [`CommonConfig`] alone and the extension is left at + /// its default. Returns the parsed configuration together with the set + /// of keys [`CommonConfig`] did not recognize, including extension keys + /// the tool itself would understand, so that a shared file means the + /// same thing to every tool reading it. + pub fn from_toml_str_shared(input: &str) -> Result<(Self, BTreeSet), toml::de::Error> { + let mut unknown = BTreeSet::new(); + let common: CommonConfig = serde_ignored::deserialize( + toml::de::Deserializer::parse(input)?, + |path: serde_ignored::Path<'_>| { + unknown.insert(path.to_string()); + }, + )?; + + Ok(( + Self { + common, + extensions: T::default(), + loaded_from: Vec::new(), + }, + unknown, + )) + } + + /// Parse the file at `path` according to its `layer` and merge it into + /// `self`, warning about ignored keys. + fn merge_from_path(self, path: &Path, layer: ConfigLayer) -> Result { + let content = fs_err::read_to_string(path)?; + let (mut other, unused) = match layer { + ConfigLayer::Shared => Self::from_toml_str_shared(&content)?, + ConfigLayer::Tool => Self::from_toml_str(&content)?, + }; + for key in &unused { + match layer { + ConfigLayer::Shared => tracing::warn!( + "Ignoring configuration key `{key}` in {}: not a key shared by all rattler-based tools", + path.display() + ), + ConfigLayer::Tool => tracing::warn!( + "Ignoring unknown configuration key `{key}` in {}", + path.display() + ), + } + } + other.loaded_from.push(path.to_path_buf()); + self.merge_config(&other) + .map_err(|e| LoadError::MergeError(e, path.to_path_buf())) + } + /// Load the configuration by merging all the given files, in order: - /// later files take precedence over earlier ones. Unrecognized keys are - /// reported as `tracing` warnings; the merged configuration is validated - /// before it is returned. + /// later files take precedence over earlier ones. Every file is parsed + /// as a tool file (common keys plus extension keys); unrecognized keys + /// are reported as `tracing` warnings. The merged configuration is + /// validated before it is returned. /// /// Missing files result in an error; callers that search default /// locations should filter for existing files first (see @@ -505,38 +560,44 @@ where where I: IntoIterator, P: AsRef, + { + Self::load_from_locations(paths.into_iter().map(|path| ConfigLocation { + path: path.as_ref().to_path_buf(), + layer: ConfigLayer::Tool, + })) + } + + /// Load the configuration by merging all the given locations, in order: + /// later locations take precedence over earlier ones. Each file is + /// parsed according to its layer: shared files accept only the common + /// keys (see [`ConfigBase::from_toml_str_shared`]), tool files also + /// accept the extension keys. Unrecognized keys are reported as + /// `tracing` warnings; the merged configuration is validated before it + /// is returned. + pub fn load_from_locations(locations: I) -> Result + where + I: IntoIterator, { let mut config = Self::default(); - for path in paths { - let path = path.as_ref(); - let content = fs_err::read_to_string(path)?; - let (mut other, unused) = Self::from_toml_str(&content)?; - for key in &unused { - tracing::warn!( - "Ignoring unknown configuration key `{key}` in {}", - path.display() - ); - } - other.loaded_from.push(path.to_path_buf()); - config = config - .merge_config(&other) - .map_err(|e| LoadError::MergeError(e, path.to_path_buf()))?; + for location in locations { + config = config.merge_from_path(&location.path, location.layer)?; } config.validate()?; Ok(config) } - /// Load the configuration from the default locations of the given tools - /// (e.g. `&["pixi", "rattler-build"]`), skipping files that do not - /// exist. See [`crate::locations::config_search_paths`] for the exact - /// search order. - pub fn load_from_default_locations(tool_dirs: &[&str]) -> Result { - Self::load_from_files( - crate::locations::config_search_paths(tool_dirs) + /// Load the configuration from the default locations of the given tool + /// (e.g. `"rattler-build"`), skipping files that do not exist: the + /// shared `rattler` configuration layered with the tool's own files. + /// See [`crate::locations::config_search_paths`] for the exact search + /// order. + pub fn load_from_default_locations(tool: &str) -> Result { + Self::load_from_locations( + crate::locations::config_search_paths(tool) .into_iter() - .filter(|path| path.is_file()), + .filter(|location| location.path.is_file()), ) } } diff --git a/crates/rattler_config/src/lib.rs b/crates/rattler_config/src/lib.rs index a7feec4d4d..6c3f9de6d1 100644 --- a/crates/rattler_config/src/lib.rs +++ b/crates/rattler_config/src/lib.rs @@ -58,10 +58,11 @@ //! [`config::ConfigBase::load_from_files`] merges a list of files in order //! (later files win) and validates the result. //! [`config::ConfigBase::load_from_default_locations`] does the same for the -//! conventional locations described in [`locations`], which is how tools -//! share one configuration: e.g. rattler-build can load -//! `&["pixi", "rattler-build"]` to layer its own configuration on top of -//! pixi's. +//! conventional locations described in [`locations`]: the shared `rattler` +//! configuration files, which every rattler-based tool reads and which may +//! only contain the [`config::CommonConfig`] keys, layered with the tool's +//! own files. This is how tools share one configuration without reading +//! each other's files. //! //! # Editing //! @@ -76,6 +77,7 @@ pub mod edit; pub mod locations; pub use config::{CommonConfig, Config, ConfigBase, LoadError, MergeError, NoExtension}; +pub use locations::{ConfigLayer, ConfigLocation}; #[cfg(test)] mod tests { @@ -469,6 +471,80 @@ mod tests { assert!(unused.contains("custom_field")); } + #[test] + fn test_from_toml_str_shared_rejects_extension_keys() { + let toml = r#" + default-channels = ["conda-forge"] + tls-no-verify = true + custom_field = "an extension key" + definitely-a-typo = true + "#; + + let (config, unused) = TestConfig::from_toml_str_shared(toml).unwrap(); + + // Common keys are consumed as usual. + assert_eq!(config.default_channels.as_ref().map(Vec::len), Some(1)); + assert_eq!(config.tls_no_verify, Some(true)); + + // A shared file means the same thing to every tool: extension keys + // are reported as unused even though the extension knows them, and + // the extension stays at its default. + assert!(unused.contains("custom_field")); + assert!(unused.contains("definitely-a-typo")); + assert_eq!(config.extensions, TestExtension::default()); + } + + #[test] + fn test_load_from_locations_layers_shared_and_tool_files() { + use crate::locations::{ConfigLayer, ConfigLocation}; + + let temp_dir = TempDir::new().unwrap(); + let shared_path = temp_dir.path().join("shared.toml"); + let tool_path = temp_dir.path().join("tool.toml"); + std::fs::write( + &shared_path, + r#" + default-channels = ["conda-forge"] + tls-no-verify = true + "#, + ) + .unwrap(); + std::fs::write( + &tool_path, + r#" + default-channels = ["bioconda"] + custom_field = "tool files accept extension keys" + "#, + ) + .unwrap(); + + let config = TestConfig::load_from_locations([ + ConfigLocation { + path: shared_path.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: tool_path.clone(), + layer: ConfigLayer::Tool, + }, + ]) + .unwrap(); + + // The tool file wins where both set a key… + assert_eq!( + config.default_channels.as_ref().and_then(|c| c.first()), + Some(&"bioconda".parse().unwrap()) + ); + // …values only in the shared file are kept… + assert_eq!(config.tls_no_verify, Some(true)); + // …and extension keys from the tool file are consumed. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("tool files accept extension keys") + ); + assert_eq!(config.loaded_from, vec![shared_path, tool_path]); + } + #[test] fn test_validation_recurses_into_extension() { let toml = "numeric_field = 101"; diff --git a/crates/rattler_config/src/locations.rs b/crates/rattler_config/src/locations.rs index 046da6f4f5..21d83f3157 100644 --- a/crates/rattler_config/src/locations.rs +++ b/crates/rattler_config/src/locations.rs @@ -1,6 +1,22 @@ //! Standard configuration file locations shared by rattler-based tools. //! -//! Every tool has three conventional configuration locations, from lowest to +//! Configuration comes from two layers: +//! +//! - the **shared** layer: files every rattler-based tool reads. They may +//! only contain the keys shared by all tools ([`crate::config::CommonConfig`]); +//! tool-specific keys in these files are ignored with a warning. +//! - the **tool** layer: the tool's own files, which accept the shared keys +//! plus the tool-specific extension keys. +//! +//! The shared layer lives in the `rattler` directory: +//! `/etc/rattler/config.toml` (`C:\ProgramData\rattler\config.toml` on +//! Windows) and `$XDG_CONFIG_HOME/rattler/config.toml` (or the platform +//! equivalent reported by [`dirs::config_dir`]). `$RATTLER_HOME/config.toml` +//! is honored when the environment variable is set, but unlike the tool +//! layer there is no `~/.rattler` fallback: the shared layer is pure +//! configuration and does not warrant a home directory. +//! +//! Each tool has three conventional configuration locations, from lowest to //! highest precedence: //! //! 1. a system-wide file: `/etc//config.toml` (Linux/macOS) or @@ -12,17 +28,40 @@ //! environment variable is set (e.g. `PIXI_HOME`), otherwise //! `~/./config.toml`. //! -//! [`config_search_paths`] combines these for a *list* of tools so that a -//! tool can layer its own configuration on top of the configuration of the -//! tools it cooperates with — e.g. `rattler-build` passing -//! `&["pixi", "rattler-build"]` reads pixi's global configuration and -//! overrides it with its own. +//! [`config_search_paths`] combines both layers for a tool, from lowest to +//! highest precedence: system shared, system tool, user shared, user tool. +//! The user always overrides the system, and within each level the +//! tool-specific file overrides the shared one. use std::path::PathBuf; /// The conventional file name of a configuration file. pub const CONFIG_FILE_NAME: &str = "config.toml"; +/// The directory name of the shared configuration layer. +pub const SHARED_CONFIG_DIR: &str = "rattler"; + +/// The configuration layer a file belongs to, which determines the keys the +/// file may contain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigLayer { + /// A file shared by all rattler-based tools; only the common keys are + /// allowed. + Shared, + /// A tool's own file; common keys plus the tool's extension keys are + /// allowed. + Tool, +} + +/// A candidate configuration file together with the layer it belongs to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigLocation { + /// The path of the configuration file. + pub path: PathBuf, + /// The layer the file belongs to. + pub layer: ConfigLayer, +} + /// The name of the environment variable pointing at a tool's home directory, /// e.g. `PIXI_HOME` for `pixi` or `RATTLER_BUILD_HOME` for `rattler-build`. fn home_env_var(tool: &str) -> String { @@ -71,27 +110,86 @@ pub fn tool_home(tool: &str) -> Option { } } -/// All configuration file locations for the given tools, from lowest to -/// highest precedence: first the system-wide files of every tool, then the -/// per-user files of every tool. Within each group, later tools in the list -/// take precedence over earlier ones. +/// The system-wide shared configuration file: +/// `/etc/rattler/config.toml`, or `C:\ProgramData\rattler\config.toml` on +/// Windows. +pub fn shared_system_config_path() -> PathBuf { + system_config_path(SHARED_CONFIG_DIR) +} + +/// The per-user shared configuration files, from lowest to highest +/// precedence. Unlike [`user_config_paths`], `$RATTLER_HOME/config.toml` is +/// only included when the environment variable is set: the shared layer has +/// no `~/.rattler` fallback. +pub fn shared_user_config_paths() -> Vec { + [ + // On macOS, honor an explicitly set XDG_CONFIG_HOME even though it + // is not part of the platform convention used by `dirs`. + #[cfg(target_os = "macos")] + std::env::var("XDG_CONFIG_HOME").ok().map(|d| { + PathBuf::from(d) + .join(SHARED_CONFIG_DIR) + .join(CONFIG_FILE_NAME) + }), + dirs::config_dir().map(|d| d.join(SHARED_CONFIG_DIR).join(CONFIG_FILE_NAME)), + std::env::var_os(home_env_var(SHARED_CONFIG_DIR)) + .map(|home| PathBuf::from(home).join(CONFIG_FILE_NAME)), + ] + .into_iter() + .flatten() + .collect() +} + +/// All configuration file locations for a tool, from lowest to highest +/// precedence: the system-wide shared file, the system-wide tool file, the +/// per-user shared files, and the per-user tool files. The user always +/// overrides the system, and within each level the tool file overrides the +/// shared one. /// /// The returned paths are candidates; they are not checked for existence. -/// Duplicates (e.g. from overlapping tool homes) are removed, keeping the -/// occurrence with the highest precedence. -pub fn config_search_paths(tools: &[&str]) -> Vec { - let mut paths: Vec = tools +/// Duplicates are removed, keeping the occurrence with the highest +/// precedence; a path that appears in both layers (e.g. `RATTLER_HOME` +/// pointing into a tool's directory) is parsed as a tool file, since the +/// tool layer accepts a superset of the shared keys. +pub fn config_search_paths(tool: &str) -> Vec { + let mut locations: Vec = [(shared_system_config_path(), ConfigLayer::Shared)] + .into_iter() + .chain([(system_config_path(tool), ConfigLayer::Tool)]) + .chain( + shared_user_config_paths() + .into_iter() + .map(|path| (path, ConfigLayer::Shared)), + ) + .chain( + user_config_paths(tool) + .into_iter() + .map(|path| (path, ConfigLayer::Tool)), + ) + .map(|(path, layer)| ConfigLocation { path, layer }) + .collect(); + + let tool_paths: std::collections::HashSet = locations .iter() - .map(|tool| system_config_path(tool)) - .chain(tools.iter().flat_map(|tool| user_config_paths(tool))) + .filter(|location| location.layer == ConfigLayer::Tool) + .map(|location| location.path.clone()) .collect(); - // Deduplicate, keeping the *last* occurrence (highest precedence). + // Deduplicate by path, keeping the *last* occurrence (highest + // precedence). A path that also appears in the tool layer keeps the + // `Tool` parse mode regardless of which occurrence survives. let mut seen = std::collections::HashSet::new(); - let mut deduped: Vec = paths + let mut deduped: Vec = locations .drain(..) .rev() - .filter(|path| seen.insert(path.clone())) + .filter(|location| seen.insert(location.path.clone())) + .map(|location| { + let layer = if tool_paths.contains(&location.path) { + ConfigLayer::Tool + } else { + ConfigLayer::Shared + }; + ConfigLocation { layer, ..location } + }) .collect(); deduped.reverse(); deduped @@ -108,38 +206,66 @@ mod tests { } #[test] - fn search_paths_order_system_before_user() { - let paths = config_search_paths(&["pixi", "rattler-build"]); - let system_pixi = system_config_path("pixi"); - let user_pixi = user_config_paths("pixi"); + fn shared_user_paths_have_no_dotdir_fallback() { + // Without RATTLER_HOME set, the shared layer must not fall back to + // `~/.rattler` the way `user_config_paths` falls back to `~/.`. + if std::env::var_os("RATTLER_HOME").is_none() + && let Some(home) = dirs::home_dir() + { + let dotdir = home.join(".rattler").join(CONFIG_FILE_NAME); + assert!( + !shared_user_config_paths().contains(&dotdir), + "shared layer must not use a ~/.rattler dotdir" + ); + } + } - let system_pos = paths.iter().position(|p| p == &system_pixi); - let user_pos = user_pixi - .first() - .and_then(|first| paths.iter().position(|p| p == first)); + #[test] + fn search_paths_interleave_layers_by_level() { + let locations = config_search_paths("pixi"); + let position = |path: &std::path::Path| locations.iter().position(|l| l.path == path); + + let system_shared = position(&shared_system_config_path()); + let system_tool = position(&system_config_path("pixi")); + let user_shared = shared_user_config_paths().first().and_then(|p| position(p)); + let user_tool = user_config_paths("pixi").first().and_then(|p| position(p)); - if let (Some(system_pos), Some(user_pos)) = (system_pos, user_pos) { + if let (Some(system_shared), Some(system_tool)) = (system_shared, system_tool) { + assert!( + system_shared < system_tool, + "system tool config must override system shared config" + ); + } + if let (Some(system_tool), Some(user_shared)) = (system_tool, user_shared) { + assert!( + system_tool < user_shared, + "user shared config must override system tool config" + ); + } + if let (Some(user_shared), Some(user_tool)) = (user_shared, user_tool) { assert!( - system_pos < user_pos, - "system config must have lower precedence than user config" + user_shared < user_tool, + "user tool config must override user shared config" ); } } #[test] - fn search_paths_order_within_user_group_follows_tool_order() { - let paths = config_search_paths(&["pixi", "rattler-build"]); - let pixi_user = user_config_paths("pixi"); - let rb_user = user_config_paths("rattler-build"); - - if let (Some(pixi_first), Some(rb_last)) = (pixi_user.first(), rb_user.last()) { - let pixi_pos = paths.iter().position(|p| p == pixi_first); - let rb_pos = paths.iter().position(|p| p == rb_last); - if let (Some(pixi_pos), Some(rb_pos)) = (pixi_pos, rb_pos) { - assert!( - pixi_pos < rb_pos, - "later tools must take precedence over earlier ones" - ); + fn search_paths_mark_layers() { + let locations = config_search_paths("pixi"); + let tool_paths: Vec = [system_config_path("pixi")] + .into_iter() + .chain(user_config_paths("pixi")) + .collect(); + for location in &locations { + let is_shared_path = location.path == shared_system_config_path() + || shared_user_config_paths().contains(&location.path); + // A path in both layers is parsed as a tool file. + match location.layer { + ConfigLayer::Shared => { + assert!(is_shared_path && !tool_paths.contains(&location.path)); + } + ConfigLayer::Tool => assert!(tool_paths.contains(&location.path)), } } } diff --git a/crates/rattler_config/tests/shared_layer.rs b/crates/rattler_config/tests/shared_layer.rs new file mode 100644 index 0000000000..563cd2521e --- /dev/null +++ b/crates/rattler_config/tests/shared_layer.rs @@ -0,0 +1,867 @@ +//! End-to-end integration tests for the shared configuration layer: +//! `ConfigLayer`/`ConfigLocation`, `load_from_locations`, +//! `from_toml_str_shared`, the layered search paths and the tracing +//! warnings emitted for ignored keys. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use rattler_config::config::{Config, ConfigBase, MergeError}; +use rattler_config::locations::{ConfigLayer, ConfigLocation, config_search_paths}; +use serde::{Deserialize, Serialize}; +use tempfile::TempDir; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Metadata, Subscriber, span}; +use url::Url; + +/// A tool-specific extension, mirroring what pixi/rattler-build would do. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ToolExt { + #[serde(default)] + custom_field: Option, + #[serde(default)] + numeric_field: Option, +} + +impl Config for ToolExt { + fn merge_config(self, other: &Self) -> Result { + Ok(Self { + custom_field: other.custom_field.clone().or(self.custom_field), + numeric_field: other.numeric_field.or(self.numeric_field), + }) + } +} + +type ToolConfig = ConfigBase; + +// --------------------------------------------------------------------------- +// Warning capture: a minimal tracing subscriber recording WARN messages. +// --------------------------------------------------------------------------- + +struct RecordingSubscriber { + warnings: Arc>>, + next_id: AtomicU64, +} + +struct MessageVisitor(Option); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = Some(format!("{value:?}")); + } + } +} + +impl Subscriber for RecordingSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + fn new_span(&self, _span: &span::Attributes<'_>) -> span::Id { + span::Id::from_u64(self.next_id.fetch_add(1, Ordering::Relaxed) + 1) + } + fn record(&self, _span: &span::Id, _values: &span::Record<'_>) {} + fn record_follows_from(&self, _span: &span::Id, _follows: &span::Id) {} + fn event(&self, event: &Event<'_>) { + if *event.metadata().level() != Level::WARN { + return; + } + let mut visitor = MessageVisitor(None); + event.record(&mut visitor); + if let Some(message) = visitor.0 { + self.warnings.lock().unwrap().push(message); + } + } + fn enter(&self, _span: &span::Id) {} + fn exit(&self, _span: &span::Id) {} +} + +/// Run `f` with a thread-local recording subscriber and return the result +/// together with all WARN-level messages emitted during the call. +fn capture_warnings(f: impl FnOnce() -> R) -> (R, Vec) { + let warnings = Arc::new(Mutex::new(Vec::new())); + let subscriber = RecordingSubscriber { + warnings: Arc::clone(&warnings), + next_id: AtomicU64::new(0), + }; + let result = tracing::subscriber::with_default(subscriber, f); + let warnings = warnings.lock().unwrap().clone(); + (result, warnings) +} + +fn write_file(dir: &TempDir, name: &str, content: &str) -> PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, content).unwrap(); + path +} + +const SHARED_WARNING_MARKER: &str = "not a key shared by all rattler-based tools"; +const TOOL_WARNING_MARKER: &str = "Ignoring unknown configuration key"; + +// --------------------------------------------------------------------------- +// 1. Shared-layer file: only common keys honored, everything else ignored +// with the shared warning. +// --------------------------------------------------------------------------- + +#[test] +fn shared_layer_honors_common_keys_only_and_warns() { + let dir = TempDir::new().unwrap(); + let shared = write_file( + &dir, + "shared.toml", + r#" + default-channels = ["conda-forge"] + tls-no-verify = true + custom_field = "an extension key the tool itself understands" + definitely-a-typo = 1 + "#, + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: shared.clone(), + layer: ConfigLayer::Shared, + }]) + }); + let config = result.unwrap(); + + // Common keys are honored. + assert_eq!( + config.default_channels, + Some(vec!["conda-forge".parse().unwrap()]) + ); + assert_eq!(config.tls_no_verify, Some(true)); + + // The extension stays at its default even though the tool knows the key. + assert_eq!(config.extensions, ToolExt::default()); + + // Both ignored keys warn with the shared-layer message. + let shared_warnings: Vec<&String> = warnings + .iter() + .filter(|w| w.contains(SHARED_WARNING_MARKER)) + .collect(); + assert!( + shared_warnings.iter().any(|w| w.contains("`custom_field`")), + "expected a shared-layer warning for custom_field, got: {warnings:?}" + ); + assert!( + shared_warnings + .iter() + .any(|w| w.contains("`definitely-a-typo`")), + "expected a shared-layer warning for definitely-a-typo, got: {warnings:?}" + ); + // The warnings name the offending file. + assert!( + shared_warnings + .iter() + .all(|w| w.contains(shared.display().to_string().as_str())), + "shared warnings must name the file, got: {warnings:?}" + ); + // No tool-layer warning text for a shared file. + assert!( + warnings.iter().all(|w| !w.contains(TOOL_WARNING_MARKER)), + "shared file must not use the tool-layer warning, got: {warnings:?}" + ); + + assert_eq!(config.loaded_from, vec![shared]); +} + +// --------------------------------------------------------------------------- +// 2. Tool-layer file: common + extension keys accepted, unknown keys warn +// with the tool message. +// --------------------------------------------------------------------------- + +#[test] +fn tool_layer_accepts_extension_keys_and_warns_on_unknown() { + let dir = TempDir::new().unwrap(); + let tool = write_file( + &dir, + "tool.toml", + r#" + default-channels = ["bioconda"] + custom_field = "consumed" + numeric_field = 42 + definitely-a-typo = 1 + "#, + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: tool.clone(), + layer: ConfigLayer::Tool, + }]) + }); + let config = result.unwrap(); + + assert_eq!( + config.default_channels, + Some(vec!["bioconda".parse().unwrap()]) + ); + assert_eq!(config.extensions.custom_field.as_deref(), Some("consumed")); + assert_eq!(config.extensions.numeric_field, Some(42)); + + // Exactly the typo warns, with the tool-layer message. + assert!( + warnings + .iter() + .any(|w| w.contains(TOOL_WARNING_MARKER) && w.contains("`definitely-a-typo`")), + "expected a tool-layer warning for definitely-a-typo, got: {warnings:?}" + ); + // Extension keys must not be warned about in a tool file. + assert!( + warnings.iter().all(|w| !w.contains("custom_field")), + "tool file must not warn about its own extension keys, got: {warnings:?}" + ); + assert!( + warnings.iter().all(|w| !w.contains(SHARED_WARNING_MARKER)), + "tool file must not use the shared-layer warning, got: {warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 6. Same file content: shared parse warns about the extension key, tool +// parse does not. +// --------------------------------------------------------------------------- + +#[test] +fn same_content_warns_as_shared_but_not_as_tool() { + let dir = TempDir::new().unwrap(); + let content = r#" + default-channels = ["conda-forge"] + custom_field = "extension key" + "#; + let path = write_file(&dir, "config.toml", content); + + let (shared_result, shared_warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: path.clone(), + layer: ConfigLayer::Shared, + }]) + }); + shared_result.unwrap(); + assert!( + shared_warnings + .iter() + .any(|w| w.contains(SHARED_WARNING_MARKER) && w.contains("`custom_field`")), + "shared parse must warn about custom_field, got: {shared_warnings:?}" + ); + + let (tool_result, tool_warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ConfigLocation { + path: path.clone(), + layer: ConfigLayer::Tool, + }]) + }); + tool_result.unwrap(); + assert!( + tool_warnings.is_empty(), + "tool parse of the same content must not warn, got: {tool_warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 3. Realistic 4-file stack: system shared, system tool, user shared, user +// tool. Later files win per key, earlier-only keys survive, maps merge +// additively, loaded_from records all files in order, and extension keys +// in shared files never leak into the extension. +// --------------------------------------------------------------------------- + +#[test] +fn four_file_stack_merges_with_correct_precedence() { + let dir = TempDir::new().unwrap(); + let mirror_upstream_1 = "https://conda.anaconda.org/one/"; + let mirror_upstream_2 = "https://conda.anaconda.org/two/"; + let mirror_upstream_3 = "https://conda.anaconda.org/three/"; + + let system_shared = write_file( + &dir, + "system_shared.toml", + &format!( + r#" + default-channels = ["from-system-shared"] + tls-no-verify = true + custom_field = "from-system-shared" + + [mirrors] + "{mirror_upstream_1}" = ["https://mirror.example/one-old/"] + "# + ), + ); + let system_tool = write_file( + &dir, + "system_tool.toml", + &format!( + r#" + default-channels = ["from-system-tool"] + authentication-override-file = "/etc/auth.json" + custom_field = "from-system-tool" + + [mirrors] + "{mirror_upstream_2}" = ["https://mirror.example/two/"] + "# + ), + ); + let user_shared = write_file( + &dir, + "user_shared.toml", + &format!( + r#" + default-channels = ["from-user-shared"] + allow-hard-links = false + custom_field = "from-user-shared" + + [mirrors] + "{mirror_upstream_1}" = ["https://mirror.example/one-new/"] + "# + ), + ); + let user_tool = write_file( + &dir, + "user_tool.toml", + &format!( + r#" + default-channels = ["from-user-tool"] + numeric_field = 7 + + [mirrors] + "{mirror_upstream_3}" = ["https://mirror.example/three/"] + "# + ), + ); + + let (result, warnings) = capture_warnings(|| { + ToolConfig::load_from_locations([ + ConfigLocation { + path: system_shared.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: system_tool.clone(), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: user_shared.clone(), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: user_tool.clone(), + layer: ConfigLayer::Tool, + }, + ]) + }); + let config = result.unwrap(); + + // Later files win per key. + assert_eq!( + config.default_channels, + Some(vec!["from-user-tool".parse().unwrap()]) + ); + // Keys set only in earlier files survive. + assert_eq!(config.tls_no_verify, Some(true), "from system shared"); + assert_eq!( + config.authentication_override_file, + Some(PathBuf::from("/etc/auth.json")), + "from system tool" + ); + assert_eq!(config.allow_hard_links, Some(false), "from user shared"); + + // Mirrors merge additively across all four files; the later shared file + // overrides the earlier one per upstream URL. + let mirrors = &config.mirrors; + assert_eq!( + mirrors.len(), + 3, + "mirrors must merge additively: {mirrors:?}" + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_1).unwrap()], + vec![Url::parse("https://mirror.example/one-new/").unwrap()], + "later shared file must override the earlier one per key" + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_2).unwrap()], + vec![Url::parse("https://mirror.example/two/").unwrap()] + ); + assert_eq!( + mirrors[&Url::parse(mirror_upstream_3).unwrap()], + vec![Url::parse("https://mirror.example/three/").unwrap()] + ); + + // Extension keys come only from tool files. `custom_field` is set in + // both shared files (later than the system tool file!) but must keep + // the system tool value; `numeric_field` comes from the user tool file. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-system-tool"), + "extension keys in shared files must not leak into the extension" + ); + assert_eq!(config.extensions.numeric_field, Some(7)); + + // loaded_from records all files in load order. + assert_eq!( + config.loaded_from, + vec![system_shared, system_tool, user_shared, user_tool] + ); + + // Both shared files warned about their extension key; the tool files + // did not warn at all. + let shared_warning_count = warnings + .iter() + .filter(|w| w.contains(SHARED_WARNING_MARKER) && w.contains("`custom_field`")) + .count(); + assert_eq!( + shared_warning_count, 2, + "each shared file must warn about custom_field, got: {warnings:?}" + ); + assert!( + warnings.iter().all(|w| !w.contains(TOOL_WARNING_MARKER)), + "no tool-layer warnings expected, got: {warnings:?}" + ); +} + +// --------------------------------------------------------------------------- +// 4. `load_from_files` still parses everything as the tool layer. +// --------------------------------------------------------------------------- + +#[test] +fn load_from_files_parses_all_files_as_tool_layer() { + let dir = TempDir::new().unwrap(); + let first = write_file( + &dir, + "first.toml", + r#" + custom_field = "from-first" + numeric_field = 1 + "#, + ); + let second = write_file( + &dir, + "second.toml", + r#" + custom_field = "from-second" + "#, + ); + + let (result, warnings) = + capture_warnings(|| ToolConfig::load_from_files([first.clone(), second.clone()])); + let config = result.unwrap(); + + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-second") + ); + assert_eq!(config.extensions.numeric_field, Some(1)); + assert!( + warnings.is_empty(), + "extension keys must be consumed without warnings, got: {warnings:?}" + ); + assert_eq!(config.loaded_from, vec![first, second]); +} + +// --------------------------------------------------------------------------- +// Shared files with a malformed common key still fail to parse. +// --------------------------------------------------------------------------- + +#[test] +fn shared_layer_still_rejects_malformed_common_values() { + let dir = TempDir::new().unwrap(); + let shared = write_file(&dir, "bad.toml", "tls-no-verify = \"not-a-bool\"\n"); + + let result = ToolConfig::load_from_locations([ConfigLocation { + path: shared, + layer: ConfigLayer::Shared, + }]); + assert!( + result.is_err(), + "malformed common value in a shared file must be an error" + ); +} + +// --------------------------------------------------------------------------- +// 5. `config_search_paths` interleaving, layer tags and RATTLER_HOME +// behavior. Environment-mutating assertions run in a child process (this +// same test binary, filtered to one probe test) so parallel tests in +// this binary are never affected. +// --------------------------------------------------------------------------- + +const PROBE_ENV: &str = "SHARED_LAYER_ENV_PROBE"; + +fn run_probe(probe_name: &str, marker: &str, envs: &[(&str, Option<&OsStr>)]) { + let exe = std::env::current_exe().unwrap(); + let mut command = Command::new(exe); + command.args(["--exact", probe_name, "--nocapture"]); + // Start from a known state for every variable the probes look at. + for var in ["RATTLER_HOME", "XDG_CONFIG_HOME", "HOME", "SOME_TOOL_HOME"] { + command.env_remove(var); + } + for (key, value) in envs { + match value { + Some(value) => command.env(key, value), + None => command.env_remove(key), + }; + } + command.env(PROBE_ENV, marker); + let output = command.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "probe {probe_name} failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + // Guard against `--exact` silently matching zero tests (which exits 0). + assert!( + stdout.contains("PROBE-DONE"), + "probe {probe_name} did not run\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Probe body: with `RATTLER_HOME` set, `$RATTLER_HOME/config.toml` appears as +/// a Shared location and the full interleaving is +/// system-shared, system-tool, user-shared(s), user-tool(s). +#[test] +fn env_probe_search_paths_with_rattler_home() { + if std::env::var(PROBE_ENV).as_deref() != Ok("with-rattler-home") { + return; + } + let rattler_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + + let locations = config_search_paths("some-tool"); + + #[cfg(target_os = "linux")] + { + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let home = PathBuf::from(std::env::var("HOME").unwrap()); + let expected = vec![ + ConfigLocation { + path: PathBuf::from("/etc/rattler/config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: PathBuf::from("/etc/some-tool/config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: xdg.join("rattler").join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: rattler_home.join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: xdg.join("some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: home.join(".some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ]; + assert_eq!(locations, expected); + } + #[cfg(not(target_os = "linux"))] + { + let rattler_home_location = locations + .iter() + .find(|l| l.path == rattler_home.join("config.toml")) + .expect("RATTLER_HOME/config.toml must be a search path"); + assert_eq!(rattler_home_location.layer, ConfigLayer::Shared); + } + println!("PROBE-DONE"); +} + +/// Probe body: without `RATTLER_HOME` there is no `~/.rattler` fallback, and +/// the interleaving is system-shared, system-tool, user-shared, user-tool. +#[test] +fn env_probe_search_paths_without_rattler_home() { + if std::env::var(PROBE_ENV).as_deref() != Ok("without-rattler-home") { + return; + } + let home = PathBuf::from(std::env::var("HOME").unwrap()); + + let locations = config_search_paths("some-tool"); + + // No ~/.rattler path may appear anywhere. + let dot_rattler = home.join(".rattler").join("config.toml"); + assert!( + locations.iter().all(|l| l.path != dot_rattler), + "shared layer must have no ~/.rattler fallback, got: {locations:?}" + ); + assert!( + locations + .iter() + .all(|l| !l.path.to_string_lossy().contains(".rattler")), + "no .rattler dotdir expected, got: {locations:?}" + ); + + #[cfg(target_os = "linux")] + { + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let expected = vec![ + ConfigLocation { + path: PathBuf::from("/etc/rattler/config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: PathBuf::from("/etc/some-tool/config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: xdg.join("rattler").join("config.toml"), + layer: ConfigLayer::Shared, + }, + ConfigLocation { + path: xdg.join("some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ConfigLocation { + path: home.join(".some-tool").join("config.toml"), + layer: ConfigLayer::Tool, + }, + ]; + assert_eq!(locations, expected); + } + println!("PROBE-DONE"); +} + +#[test] +fn search_paths_respect_rattler_home() { + let rattler_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_with_rattler_home", + "with-rattler-home", + &[ + ("RATTLER_HOME", Some(rattler_home.path().as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +#[test] +fn search_paths_without_rattler_home_have_no_dotdir() { + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_without_rattler_home", + "without-rattler-home", + &[ + ("RATTLER_HOME", None), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +/// Probe body: when `RATTLER_HOME` and `SOME_TOOL_HOME` point at the same +/// directory, the colliding path is deduplicated to a single entry. A path +/// that appears in both layers is always parsed as a tool file, whichever +/// occurrence survives the dedup. +#[test] +fn env_probe_search_paths_dedup_on_layer_collision() { + if std::env::var(PROBE_ENV).as_deref() != Ok("layer-collision") { + return; + } + let shared_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + let colliding = shared_home.join("config.toml"); + + let locations = config_search_paths("some-tool"); + + let matches: Vec<&ConfigLocation> = locations.iter().filter(|l| l.path == colliding).collect(); + assert_eq!( + matches.len(), + 1, + "colliding path must be deduplicated to one entry, got: {locations:?}" + ); + assert_eq!( + matches[0].layer, + ConfigLayer::Tool, + "dedup must keep the highest-precedence occurrence (the tool layer)" + ); + println!("PROBE-DONE"); +} + +#[test] +fn search_paths_dedup_collision_between_layers() { + let shared_and_tool_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_dedup_on_layer_collision", + "layer-collision", + &[ + ( + "RATTLER_HOME", + Some(shared_and_tool_home.path().as_os_str()), + ), + ( + "SOME_TOOL_HOME", + Some(shared_and_tool_home.path().as_os_str()), + ), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +/// Probe body: the reverse collision direction. `RATTLER_HOME` points at +/// the tool's *system* directory, so the shared occurrence of the colliding +/// path comes *after* the tool occurrence and survives the dedup. The entry +/// must still be parsed as a tool file: the tool's own system config +/// legitimately contains extension keys, and parsing it as shared would +/// silently drop them. +#[cfg(not(target_os = "windows"))] +#[test] +fn env_probe_search_paths_dedup_reverse_layer_collision() { + if std::env::var(PROBE_ENV).as_deref() != Ok("reverse-layer-collision") { + return; + } + let colliding = PathBuf::from("/etc/some-tool/config.toml"); + + let locations = config_search_paths("some-tool"); + + let matches: Vec<&ConfigLocation> = locations.iter().filter(|l| l.path == colliding).collect(); + assert_eq!( + matches.len(), + 1, + "colliding path must be deduplicated to one entry, got: {locations:?}" + ); + assert_eq!( + matches[0].layer, + ConfigLayer::Tool, + "a path in both layers must be parsed as a tool file" + ); + println!("PROBE-DONE"); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn search_paths_dedup_reverse_collision_keeps_tool_layer() { + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_search_paths_dedup_reverse_layer_collision", + "reverse-layer-collision", + &[ + ("RATTLER_HOME", Some(OsStr::new("/etc/some-tool"))), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +} + +// --------------------------------------------------------------------------- +// End-to-end through the default locations: a real 4-file stack in +// RATTLER_HOME / tool home / XDG dirs, loaded via +// `load_from_default_locations` in a child process with a controlled +// environment. +// --------------------------------------------------------------------------- + +/// Probe body: build the user-level part of the stack on disk and load it +/// through `load_from_default_locations`. +/// +/// Unix only: on Windows `dirs::config_dir` uses the known-folder API and +/// ignores `XDG_CONFIG_HOME`, so the files written below would never be +/// found. +#[cfg(unix)] +#[test] +fn env_probe_load_from_default_locations() { + if std::env::var(PROBE_ENV).as_deref() != Ok("default-locations") { + return; + } + let xdg = PathBuf::from(std::env::var("XDG_CONFIG_HOME").unwrap()); + let rattler_home = PathBuf::from(std::env::var("RATTLER_HOME").unwrap()); + + // user shared (XDG): common key + extension key that must be ignored. + let xdg_shared_dir = xdg.join("rattler"); + std::fs::create_dir_all(&xdg_shared_dir).unwrap(); + std::fs::write( + xdg_shared_dir.join("config.toml"), + r#" + default-channels = ["from-xdg-shared"] + tls-no-verify = true + custom_field = "leaked-from-xdg-shared" + "#, + ) + .unwrap(); + + // user shared (RATTLER_HOME): higher precedence than the XDG shared file. + std::fs::write( + rattler_home.join("config.toml"), + r#" + default-channels = ["from-rattler-home"] + custom_field = "leaked-from-rattler-home" + "#, + ) + .unwrap(); + + // user tool (XDG): extension key must be consumed. + let xdg_tool_dir = xdg.join("some-tool"); + std::fs::create_dir_all(&xdg_tool_dir).unwrap(); + std::fs::write( + xdg_tool_dir.join("config.toml"), + r#" + default-channels = ["from-xdg-tool"] + custom_field = "from-xdg-tool" + "#, + ) + .unwrap(); + + let config = ToolConfig::load_from_default_locations("some-tool").unwrap(); + + // The tool file has the highest precedence among the files we created. + assert_eq!( + config.default_channels, + Some(vec!["from-xdg-tool".parse().unwrap()]), + "user tool file must win" + ); + // A common key set only in the lowest shared file survives. + assert_eq!(config.tls_no_verify, Some(true)); + // Extension keys in shared files never reach the extension. + assert_eq!( + config.extensions.custom_field.as_deref(), + Some("from-xdg-tool"), + "extension value must come from the tool file only" + ); + // All three files we created were recorded, in precedence order. The + // comparison ignores files outside the controlled environment (a real + // `/etc/rattler/config.toml` or `/etc/some-tool/config.toml` may exist + // on the machine running the tests and loads with lower precedence). + let ours: Vec = config + .loaded_from + .iter() + .filter(|path| !path.starts_with("/etc")) + .cloned() + .collect(); + assert_eq!( + ours, + vec![ + xdg_shared_dir.join("config.toml"), + rattler_home.join("config.toml"), + xdg_tool_dir.join("config.toml"), + ] + ); + println!("PROBE-DONE"); +} + +#[cfg(unix)] +#[test] +fn load_from_default_locations_layers_shared_and_tool() { + let rattler_home = TempDir::new().unwrap(); + let xdg = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + run_probe( + "env_probe_load_from_default_locations", + "default-locations", + &[ + ("RATTLER_HOME", Some(rattler_home.path().as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.path().as_os_str())), + ("HOME", Some(home.path().as_os_str())), + ], + ); +}