From 9b3b69545cd1cfaadc050b0d75c528ac48f94eda Mon Sep 17 00:00:00 2001 From: Simone Primarosa Date: Mon, 9 Mar 2026 21:55:03 +0100 Subject: [PATCH 1/3] feat(conda): add filter_bins support to conda backend ## Problem Conda packages often pull in dependencies that install many binaries onto PATH. For example, `conda:postgresql` installs not only `psql` but also hundreds of dependency binaries like `clear`, `reset`, `tput`, `tabs`, and other ncurses utilities that shadow standard system commands. Running `clear` after installing `conda:postgresql` can break your terminal because it picks up the conda-provided ncurses `clear` instead of the system one. There is currently no way to control which binaries from a conda package are exposed on PATH. ## Solution Add `filter_bins` support to the conda backend, matching the existing implementation in the GitHub backend. When `filter_bins` is specified, only the listed binaries are symlinked into a `.mise-bins` directory, and `list_bin_paths` returns only that directory instead of the full `bin/` path. ```toml [tools] "conda:postgresql" = { version = "latest", filter_bins = "psql,pg_dump,pg_restore,createdb,dropdb" } ``` ### Changes - Added `get_filter_bins()` to parse the `filter_bins` option (supports both generic and platform-specific keys via `lookup_platform_key`) - Added `create_symlink_bin_dir()` to create a `.mise-bins` directory with symlinks only to the specified binaries - Hooked into both `install_fresh()` and `install_from_locked()` post-install steps - Updated `list_bin_paths()` to return only `.mise-bins` when `filter_bins` is set - Updated conda backend documentation with the new option and the `conda:postgresql` example --- docs/dev-tools/backends/conda.md | 20 ++++++++++ src/backend/conda.rs | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/docs/dev-tools/backends/conda.md b/docs/dev-tools/backends/conda.md index c7b2e7b57f..662b71bbd3 100644 --- a/docs/dev-tools/backends/conda.md +++ b/docs/dev-tools/backends/conda.md @@ -89,6 +89,26 @@ Override the conda channel for a specific package: "conda:bioconductor-deseq2" = { version = "latest", channel = "bioconda" } ``` +### `filter_bins` + +Comma-separated list of binaries to symlink into a filtered `.mise-bins` directory. +This is useful when a conda package pulls in dependencies that shadow system commands +you don't want overridden on PATH. + +For example, `conda:postgresql` installs not only `psql` but also hundreds of dependency +binaries like `clear`, `reset`, `tput`, `tabs`, and other ncurses utilities that shadow +standard system commands. With `filter_bins`, you can expose only the binaries you need: + +```toml +[tools] +"conda:postgresql" = { version = "latest", filter_bins = "psql,pg_dump,pg_restore,createdb,dropdb" } +``` + +When enabled: + +- A `.mise-bins` subdirectory is created inside the install path with symlinks only to the specified binaries +- Other binaries from the package and its dependencies are not exposed on PATH + ## Common Channels - `conda-forge` - Community-maintained packages (default) diff --git a/src/backend/conda.rs b/src/backend/conda.rs index b96e41542c..1548fb3fcd 100644 --- a/src/backend/conda.rs +++ b/src/backend/conda.rs @@ -1,6 +1,7 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; use crate::backend::platform_target::PlatformTarget; +use crate::backend::static_helpers::lookup_with_fallback; use crate::cli::args::BackendArg; use crate::config::Config; use crate::config::Settings; @@ -373,6 +374,10 @@ impl CondaBackend { Self::make_bins_executable(&install_path)?; + if let Some(bins) = self.get_filter_bins(tv) { + self.create_symlink_bin_dir(tv, bins)?; + } + // Store lockfile info let n_deps = all_records.len() - 1; // all except main let dep_basenames: Vec = all_records[..n_deps] @@ -465,6 +470,10 @@ impl CondaBackend { Self::make_bins_executable(&install_path)?; + if let Some(bins) = self.get_filter_bins(tv) { + self.create_symlink_bin_dir(tv, bins)?; + } + // Repopulate tv.conda_packages from lockfile so downstream lockfile update preserves entries for basename in &dep_basenames { if let Some(pkg_info) = lockfile.get_conda_package(platform_key, basename) { @@ -496,6 +505,58 @@ impl CondaBackend { Ok(()) } + fn get_filter_bins(&self, tv: &ToolVersion) -> Option> { + let opts = tv.request.options(); + let filter_bins = lookup_with_fallback(&opts, "filter_bins")?; + + Some( + filter_bins + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ) + } + + /// Creates a `.mise-bins` directory with symlinks only to the binaries specified in filter_bins. + fn create_symlink_bin_dir(&self, tv: &ToolVersion, bins: Vec) -> Result<()> { + let symlink_dir = tv.install_path().join(".mise-bins"); + file::create_dir_all(&symlink_dir)?; + + let install_path = tv.install_path(); + let src_dirs: Vec = if cfg!(windows) { + vec![ + install_path.join("Library").join("bin"), + install_path.join("bin"), + ] + } else { + vec![install_path.join("bin")] + }; + + for bin_name in bins { + let mut found = false; + for dir in &src_dirs { + let src = dir.join(&bin_name); + if src.exists() { + let dst = symlink_dir.join(&bin_name); + if !dst.exists() { + file::make_symlink_or_copy(&src, &dst)?; + } + found = true; + break; + } + } + + if !found { + warn!( + "Could not find binary '{}' in install directories. Available paths: {:?}", + bin_name, src_dirs + ); + } + } + Ok(()) + } + /// Resolve conda packages for lockfile's shared conda-packages section. /// Returns a map of basename -> CondaPackageInfo for deps of this tool on the given platform. pub async fn resolve_conda_packages( @@ -672,6 +733,12 @@ impl Backend for CondaBackend { _config: &Arc, tv: &ToolVersion, ) -> Result> { + if let Some(bins) = self.get_filter_bins(tv) { + if !bins.is_empty() { + return Ok(vec![tv.install_path().join(".mise-bins")]); + } + } + let install_path = tv.install_path(); if cfg!(windows) { // Conda packages on Windows can put binaries in either location From 037362031968d21a1e97da3baf061f451491b603 Mon Sep 17 00:00:00 2001 From: Simone Primarosa Date: Mon, 9 Mar 2026 23:22:33 +0100 Subject: [PATCH 2/3] feat(conda): exclude transitive dependency binaries from PATH automatically Instead of requiring users to manually specify filter_bins, the conda backend now automatically exposes only binaries from the main package. Uses rattler link_package return value (Vec) to identify which files belong to the main package vs transitive dependencies, then symlinks only the main packages binaries into .mise-bins/. This prevents conda dependencies (e.g. ncurses clear/reset/tput when installing postgresql) from shadowing system commands. --- docs/dev-tools/backends/conda.md | 20 ------- src/backend/conda.rs | 95 +++++++++++++------------------- 2 files changed, 38 insertions(+), 77 deletions(-) diff --git a/docs/dev-tools/backends/conda.md b/docs/dev-tools/backends/conda.md index 662b71bbd3..c7b2e7b57f 100644 --- a/docs/dev-tools/backends/conda.md +++ b/docs/dev-tools/backends/conda.md @@ -89,26 +89,6 @@ Override the conda channel for a specific package: "conda:bioconductor-deseq2" = { version = "latest", channel = "bioconda" } ``` -### `filter_bins` - -Comma-separated list of binaries to symlink into a filtered `.mise-bins` directory. -This is useful when a conda package pulls in dependencies that shadow system commands -you don't want overridden on PATH. - -For example, `conda:postgresql` installs not only `psql` but also hundreds of dependency -binaries like `clear`, `reset`, `tput`, `tabs`, and other ncurses utilities that shadow -standard system commands. With `filter_bins`, you can expose only the binaries you need: - -```toml -[tools] -"conda:postgresql" = { version = "latest", filter_bins = "psql,pg_dump,pg_restore,createdb,dropdb" } -``` - -When enabled: - -- A `.mise-bins` subdirectory is created inside the install path with symlinks only to the specified binaries -- Other binaries from the package and its dependencies are not exposed on PATH - ## Common Channels - `conda-forge` - Community-maintained packages (default) diff --git a/src/backend/conda.rs b/src/backend/conda.rs index 1548fb3fcd..f8b0927d26 100644 --- a/src/backend/conda.rs +++ b/src/backend/conda.rs @@ -1,7 +1,6 @@ use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; use crate::backend::platform_target::PlatformTarget; -use crate::backend::static_helpers::lookup_with_fallback; use crate::cli::args::BackendArg; use crate::config::Config; use crate::config::Settings; @@ -19,6 +18,7 @@ use rattler::install::{InstallDriver, InstallOptions, PythonInfo, link_package}; use rattler_conda_types::{ Channel, ChannelConfig, GenericVirtualPackage, MatchSpec, ParseStrictness, Platform as CondaPlatform, RepoDataRecord, prefix::Prefix, + prefix_record::PathsEntry, }; use rattler_repodata_gateway::{Gateway, RepoData}; use rattler_solve::{ @@ -257,17 +257,17 @@ impl CondaBackend { prefix: &Prefix, driver: &InstallDriver, python_info: Option, - ) -> Result<()> { + ) -> Result> { let temp_dir = tempfile::tempdir()?; Self::extract_package(archive, temp_dir.path()).await?; let install_options = InstallOptions { python_info, ..InstallOptions::default() }; - link_package(temp_dir.path(), prefix, driver, install_options) + let paths = link_package(temp_dir.path(), prefix, driver, install_options) .await .map_err(|e| eyre::eyre!("failed to link {}: {}", archive.display(), e))?; - Ok(()) + Ok(paths) } /// Extract PythonInfo from the solved records if a python package is present. @@ -366,17 +366,20 @@ impl CondaBackend { .map_err(|e| eyre::eyre!("failed to create conda prefix: {}", e))?; let driver = InstallDriver::default(); + let mut main_paths = Vec::new(); for (record, archive) in all_records.iter().zip(downloaded.iter()) { let name = record.package_record.name.as_normalized(); + let is_main = name == tool_name_norm; ctx.pr.set_message(format!("installing {name}")); - Self::install_package(archive, &prefix, &driver, python_info.clone()).await?; + let paths = + Self::install_package(archive, &prefix, &driver, python_info.clone()).await?; + if is_main { + main_paths = paths; + } } Self::make_bins_executable(&install_path)?; - - if let Some(bins) = self.get_filter_bins(tv) { - self.create_symlink_bin_dir(tv, bins)?; - } + self.create_symlink_bin_dir(tv, &main_paths)?; // Store lockfile info let n_deps = all_records.len() - 1; // all except main @@ -462,17 +465,17 @@ impl CondaBackend { .map_err(|e| eyre::eyre!("failed to create conda prefix: {}", e))?; let driver = InstallDriver::default(); + let mut main_paths = Vec::new(); for archive in &downloaded { let filename = archive.file_name().and_then(|n| n.to_str()).unwrap_or("?"); ctx.pr.set_message(format!("installing {filename}")); - Self::install_package(archive, &prefix, &driver, python_info.clone()).await?; + // main package is always last, so main_paths ends up with its entries + main_paths = + Self::install_package(archive, &prefix, &driver, python_info.clone()).await?; } Self::make_bins_executable(&install_path)?; - - if let Some(bins) = self.get_filter_bins(tv) { - self.create_symlink_bin_dir(tv, bins)?; - } + self.create_symlink_bin_dir(tv, &main_paths)?; // Repopulate tv.conda_packages from lockfile so downstream lockfile update preserves entries for basename in &dep_basenames { @@ -505,53 +508,31 @@ impl CondaBackend { Ok(()) } - fn get_filter_bins(&self, tv: &ToolVersion) -> Option> { - let opts = tv.request.options(); - let filter_bins = lookup_with_fallback(&opts, "filter_bins")?; - - Some( - filter_bins - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), - ) - } - - /// Creates a `.mise-bins` directory with symlinks only to the binaries specified in filter_bins. - fn create_symlink_bin_dir(&self, tv: &ToolVersion, bins: Vec) -> Result<()> { + /// Creates a `.mise-bins` directory with symlinks only to binaries from the main package. + /// Uses the PathsEntry list returned by rattler's link_package to identify which files + /// belong to the main package (excluding transitive dependency binaries). + fn create_symlink_bin_dir(&self, tv: &ToolVersion, main_paths: &[PathsEntry]) -> Result<()> { let symlink_dir = tv.install_path().join(".mise-bins"); file::create_dir_all(&symlink_dir)?; let install_path = tv.install_path(); - let src_dirs: Vec = if cfg!(windows) { - vec![ - install_path.join("Library").join("bin"), - install_path.join("bin"), - ] + let bin_dirs: &[&std::path::Path] = if cfg!(windows) { + &[std::path::Path::new("Library/bin"), std::path::Path::new("Scripts"), std::path::Path::new("bin")] } else { - vec![install_path.join("bin")] + &[std::path::Path::new("bin")] }; - for bin_name in bins { - let mut found = false; - for dir in &src_dirs { - let src = dir.join(&bin_name); - if src.exists() { - let dst = symlink_dir.join(&bin_name); - if !dst.exists() { - file::make_symlink_or_copy(&src, &dst)?; - } - found = true; - break; - } + for entry in main_paths { + if !bin_dirs.iter().any(|dir| entry.relative_path.starts_with(dir)) { + continue; } - - if !found { - warn!( - "Could not find binary '{}' in install directories. Available paths: {:?}", - bin_name, src_dirs - ); + let Some(bin_name) = entry.relative_path.file_name() else { + continue; + }; + let src = install_path.join(&entry.relative_path); + let dst = symlink_dir.join(bin_name); + if src.exists() && !dst.exists() { + file::make_symlink_or_copy(&src, &dst)?; } } Ok(()) @@ -733,12 +714,12 @@ impl Backend for CondaBackend { _config: &Arc, tv: &ToolVersion, ) -> Result> { - if let Some(bins) = self.get_filter_bins(tv) { - if !bins.is_empty() { - return Ok(vec![tv.install_path().join(".mise-bins")]); - } + let mise_bins = tv.install_path().join(".mise-bins"); + if mise_bins.exists() { + return Ok(vec![mise_bins]); } + // Fallback for tools installed before this change let install_path = tv.install_path(); if cfg!(windows) { // Conda packages on Windows can put binaries in either location From 70e71673628b256a91290652f3af35561f49eac4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:40:03 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- src/backend/conda.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/backend/conda.rs b/src/backend/conda.rs index f8b0927d26..24d744deda 100644 --- a/src/backend/conda.rs +++ b/src/backend/conda.rs @@ -17,8 +17,7 @@ use itertools::Itertools; use rattler::install::{InstallDriver, InstallOptions, PythonInfo, link_package}; use rattler_conda_types::{ Channel, ChannelConfig, GenericVirtualPackage, MatchSpec, ParseStrictness, - Platform as CondaPlatform, RepoDataRecord, prefix::Prefix, - prefix_record::PathsEntry, + Platform as CondaPlatform, RepoDataRecord, prefix::Prefix, prefix_record::PathsEntry, }; use rattler_repodata_gateway::{Gateway, RepoData}; use rattler_solve::{ @@ -517,13 +516,20 @@ impl CondaBackend { let install_path = tv.install_path(); let bin_dirs: &[&std::path::Path] = if cfg!(windows) { - &[std::path::Path::new("Library/bin"), std::path::Path::new("Scripts"), std::path::Path::new("bin")] + &[ + std::path::Path::new("Library/bin"), + std::path::Path::new("Scripts"), + std::path::Path::new("bin"), + ] } else { &[std::path::Path::new("bin")] }; for entry in main_paths { - if !bin_dirs.iter().any(|dir| entry.relative_path.starts_with(dir)) { + if !bin_dirs + .iter() + .any(|dir| entry.relative_path.starts_with(dir)) + { continue; } let Some(bin_name) = entry.relative_path.file_name() else {