diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index f2643b0b5..37eb33793 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -47,7 +47,7 @@ impl RubyCrawler { options, std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), - ambient_path_system_env().as_deref(), + &BundlerEnvPathFlags::ambient(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(&options.cwd).as_deref(), @@ -74,7 +74,7 @@ impl RubyCrawler { options, None, bundle_path_env, - None, + &BundlerEnvPathFlags::default(), app_config_env, home_env, global_config, @@ -93,7 +93,7 @@ impl RubyCrawler { options: &CrawlerOptions, gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, - path_system_env: Option<&OsStr>, + env_flags: &BundlerEnvPathFlags, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, global_config: Option<&Path>, @@ -111,7 +111,7 @@ impl RubyCrawler { &options.cwd, gemfile_env, bundle_path_env, - path_system_env, + env_flags, app_config_env, home_env, global_config, @@ -169,7 +169,7 @@ impl RubyCrawler { options, std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), - ambient_path_system_env().as_deref(), + &BundlerEnvPathFlags::ambient(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(&options.cwd).as_deref(), @@ -397,7 +397,7 @@ impl RubyCrawler { cwd: &Path, gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, - path_system_env: Option<&OsStr>, + env_flags: &BundlerEnvPathFlags, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, global_config: Option<&Path>, @@ -447,8 +447,10 @@ impl RubyCrawler { let default_root = cwd.join("vendor").join("bundle"); let default_root = normalize_lexically(&default_root).unwrap_or(default_root); - // A truthy `path.system` in the tier Bundler reads sends it to the - // system gem home, which ignores every bundle path: the env + // A truthy `path.system` or a falsy `disable_shared_gems` (#1931) + // in the tier Bundler reads sends it to the system gem home + // ([`BundlerPathSettings::uses_system_gems`]), which ignores every + // bundle path: the env // `BUNDLE_PATH` it shadows (or that sits in the same tier) and the // default `vendor/bundle`. A leftover store there must not be // crawled, nor switch off the `gem env` homes where the loaded @@ -456,19 +458,18 @@ impl RubyCrawler { // counts for a Ruby project. (A recorded local path never coexists // with it: [`parse_bundle_config_path`] already drops it.) let path_tier = if has_manifest { - Self::bundler_path_tier( - cwd, - bundle_path_env, - path_system_env, - app_config_env, - global_config, - ignore_config, - ) - .await + BundlerPathTiers { + local: read_app_config(cwd, app_config_env, ignore_config).await, + env: env_flags.tier(bundle_path_env), + global: read_global_config(global_config, ignore_config).await, + } + .deciding() } else { None }; - let uses_system_gems = path_tier.is_some_and(|tier| tier.system); + let uses_system_gems = path_tier + .as_ref() + .is_some_and(|(_, tier)| tier.uses_system_gems()); let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; @@ -509,16 +510,15 @@ impl RubyCrawler { // is the user's own machine state, so it is trusted like the // env var: no containment guard. Bundler's `Settings#path` // takes the FIRST tier that sets `path`, `path.system`, or - // `disable_shared_gems`, so a local or env setting shadows it - // entirely — even an empty env - // `BUNDLE_PATH`, which adds no root above but still stops - // Bundler (`explicit_path` is `""`). An env - // `BUNDLE_PATH__SYSTEM` or `BUNDLE_DISABLE_SHARED_GEMS` already - // dropped `global_config`, see - // [`global_path_config_unless_env_path_settings`]. - let shadowed = bundle_path_env.is_some() - || (!ignore_config && Self::app_config_sets_path(cwd, app_config_env).await); - if !shadowed { + // `disable_shared_gems`, so it applies only when the global + // tier is the deciding one: any local or env setting shadows + // it entirely — even an empty env `BUNDLE_PATH`, which adds no + // root above but still stops Bundler (`explicit_path` is `""`), + // or an env flag set to `"false"`. + if path_tier + .as_ref() + .is_some_and(|(level, _)| *level == BundlerPathLevel::Global) + { let text = read_global_config(global_config, false).await; let value = match text { Some(text) => { @@ -535,8 +535,7 @@ impl RubyCrawler { roots.push(resolve_bundle_path(cwd, Path::new(&value), home)); } } - // With no explicit path and no truthy `path.system` in the - // deciding tier, Bundler's `Path#base_path` is `/.bundle` + // With no explicit path and no system gems in the deciding tier, Bundler's `Path#base_path` is `/.bundle` // whenever `use_system_gems?` is false: `default_install_uses_path` // on 2.x, `bundler_5_mode?` (`simulate_version 5`) on 4.x, and // the default from Bundler 5 on (#967). Probed without reading @@ -544,7 +543,10 @@ impl RubyCrawler { // once Bundler installed there. Like the explicit roots it keeps // the `gem env` fallback on, since default gems stay in the // system homes. - if !path_tier.is_some_and(|tier| tier.system || tier.explicit_path) { + if !path_tier + .as_ref() + .is_some_and(|(_, tier)| tier.uses_system_gems() || tier.path.is_some()) + { let dot_bundle = cwd.join(".bundle"); roots.push(normalize_lexically(&dot_bundle).unwrap_or(dot_bundle)); } @@ -591,7 +593,7 @@ impl RubyCrawler { cwd, std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), - ambient_path_system_env().as_deref(), + &BundlerEnvPathFlags::ambient(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(cwd).as_deref(), @@ -614,7 +616,7 @@ impl RubyCrawler { cwd, None, bundle_path_env, - None, + &BundlerEnvPathFlags::default(), app_config_env, home_env, global_config, @@ -624,8 +626,7 @@ impl RubyCrawler { } /// [`Self::discover_bundle_stores_with_env`] with an env - /// `BUNDLE_PATH__SYSTEM` value, which also drops the global config - /// the way [`ambient_global_config_unless_ignored`] does. + /// `BUNDLE_PATH__SYSTEM` value. #[cfg(test)] async fn discover_bundle_stores_with_path_system_env( cwd: &Path, @@ -633,19 +634,35 @@ impl RubyCrawler { path_system_env: Option<&OsStr>, global_config: Option<&Path>, ) -> BundleStoreDiscovery { - let global_config = global_path_config_unless_env_path_settings( - global_config.map(Path::to_path_buf), - path_system_env, - None, - ); + Self::discover_bundle_stores_with_env_flags( + cwd, + bundle_path_env, + &BundlerEnvPathFlags { + path_system: path_system_env.map(OsStr::to_os_string), + disable_shared_gems: None, + }, + global_config, + ) + .await + } + + /// [`Self::discover_bundle_stores_with_env`] with the env tier's + /// `path.system` and `disable_shared_gems` flags. + #[cfg(test)] + async fn discover_bundle_stores_with_env_flags( + cwd: &Path, + bundle_path_env: Option<&OsStr>, + env_flags: &BundlerEnvPathFlags, + global_config: Option<&Path>, + ) -> BundleStoreDiscovery { Self::discover_bundle_stores_impl( cwd, None, bundle_path_env, - path_system_env, + env_flags, None, None, - global_config.as_deref(), + global_config, false, ) .await @@ -665,10 +682,11 @@ impl RubyCrawler { /// and rollback write through) — only of verification. Empty in global / /// `--global-prefix` mode and whenever no config root was refused. pub async fn verification_only_gem_paths(&self, options: &CrawlerOptions) -> Vec { - self.verification_only_gem_paths_with_env( + Self::verification_only_gem_paths_impl( options, std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), + &BundlerEnvPathFlags::ambient(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(&options.cwd).as_deref(), @@ -690,19 +708,42 @@ impl RubyCrawler { home_env: Option<&OsStr>, global_config: Option<&Path>, ignore_config: bool, + ) -> Vec { + Self::verification_only_gem_paths_impl( + options, + gemfile_env, + bundle_path_env, + &BundlerEnvPathFlags::default(), + app_config_env, + home_env, + global_config, + ignore_config, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn verification_only_gem_paths_impl( + options: &CrawlerOptions, + gemfile_env: Option<&OsStr>, + bundle_path_env: Option<&OsStr>, + env_flags: &BundlerEnvPathFlags, + app_config_env: Option<&OsStr>, + home_env: Option<&OsStr>, + global_config: Option<&Path>, + ignore_config: bool, ) -> Vec { if options.global || options.global_prefix.is_some() { return Vec::new(); } // Under `BUNDLE_IGNORE_CONFIG` bundler never reads the config path, - // so nothing is refused and there is nothing extra to verify. - // `path.system` only drops the default root, never the refused - // config root this reads, so the env flag is not needed here. + // so nothing is refused and there is nothing extra to verify. The + // env flags only decide which other stores are already crawled. let discovery = Self::discover_bundle_stores_impl( &options.cwd, gemfile_env, bundle_path_env, - None, + env_flags, app_config_env, home_env, global_config, @@ -859,53 +900,6 @@ impl RubyCrawler { paths } - /// Whether the app config file sets `path`, `path.system`, or - /// `disable_shared_gems` at all, including an empty string — any one - /// makes bundler stop at that tier (`Settings#path`), so the global - /// config's path never applies. - async fn app_config_sets_path(cwd: &Path, app_config_env: Option<&OsStr>) -> bool { - let config = bundler_app_config_dir(cwd, app_config_env).join("config"); - crate::utils::fs::read_regular_to_string(&config) - .await - .is_ok_and(|text| config_path_tier(&text).is_some()) - } - - /// The Bundler tier that decides the install path. `Settings#path` - /// takes the FIRST of the local app config, the environment and the - /// global config that sets `path`, `path.system` or - /// `disable_shared_gems` (even to an empty or false value); `None` when - /// no tier does. Callers already pass no `global_config` when the env - /// tier sets `path.system` or `disable_shared_gems` (see - /// [`global_path_config_unless_env_path_settings`]). - async fn bundler_path_tier( - cwd: &Path, - bundle_path_env: Option<&OsStr>, - path_system_env: Option<&OsStr>, - app_config_env: Option<&OsStr>, - global_config: Option<&Path>, - ignore_config: bool, - ) -> Option { - if !ignore_config { - let config = bundler_app_config_dir(cwd, app_config_env).join("config"); - if let Ok(text) = crate::utils::fs::read_regular_to_string(&config).await { - if let Some(tier) = config_path_tier(&text) { - return Some(tier); - } - } - } - if bundle_path_env.is_some() || path_system_env.is_some() { - return Some(BundlerPathTier { - explicit_path: bundle_path_env.is_some(), - system: path_system_env.is_some_and(|v| bundler_truthy(&v.to_string_lossy())), - }); - } - let global = global_config?; - let text = crate::utils::fs::read_regular_to_string(global) - .await - .ok()?; - config_path_tier(&text) - } - /// The `BUNDLE_PATH` recorded in bundler's app config file — the value /// `bundle config set --local path ` writes. The file lives at /// `$BUNDLE_APP_CONFIG/config`, else `/.bundle/config`, resolved by @@ -1316,6 +1310,63 @@ impl BundlerPathSettings { std::env::var_os("BUNDLE_DEPLOYMENT").as_deref(), ) } + + /// Whether this tier decides the install path: `Settings#path` stops at + /// the first tier that sets `path`, `path.system` or + /// `disable_shared_gems`, even to an empty or false value. + fn decides_path(&self) -> bool { + self.path.is_some() || self.path_system.is_some() || self.disable_shared_gems.is_some() + } + + /// Whether this tier, when it decides, sends Bundler to the system gem + /// home: `Path.new(path, system_path || !disable_shared_gems)`, with + /// both flags through Bundler's `to_bool` ([`bundler_truthy`]). The + /// system home then ignores every bundle path, explicit or default. + fn uses_system_gems(&self) -> bool { + self.path_system.as_deref().is_some_and(bundler_truthy) + || self + .disable_shared_gems + .as_deref() + .is_some_and(|v| !bundler_truthy(v)) + } +} + +/// The env tier's `path.system` and `disable_shared_gems` flags +/// (`BUNDLE_PATH__SYSTEM`, `BUNDLE_DISABLE_SHARED_GEMS`), for the +/// install-root probe, which takes the env `BUNDLE_PATH` separately because +/// it resolves it as a path. +#[derive(Debug, Default, Clone)] +pub(crate) struct BundlerEnvPathFlags { + path_system: Option, + disable_shared_gems: Option, +} + +impl BundlerEnvPathFlags { + fn ambient() -> Self { + Self { + path_system: std::env::var_os("BUNDLE_PATH__SYSTEM"), + disable_shared_gems: std::env::var_os("BUNDLE_DISABLE_SHARED_GEMS"), + } + } + + /// The env tier these flags and `bundle_path_env` make up. Its + /// `deployment` is left unset: the probe only reads the deciding tier. + fn tier(&self, bundle_path_env: Option<&OsStr>) -> BundlerPathSettings { + BundlerPathSettings::from_env( + bundle_path_env, + self.path_system.as_deref(), + self.disable_shared_gems.as_deref(), + None, + ) + } +} + +/// Which of Bundler's settings tiers decided the install path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BundlerPathLevel { + Local, + Env, + Global, } /// The settings tiers Bundler's `Settings#path` reads, highest first: the @@ -1327,6 +1378,44 @@ pub(crate) struct BundlerPathTiers { pub(crate) global: Option, } +impl BundlerPathTiers { + /// Every present tier's settings, highest first. + fn settings(self) -> Vec<(BundlerPathLevel, BundlerPathSettings)> { + [ + self.local.as_deref().map(|text| { + ( + BundlerPathLevel::Local, + BundlerPathSettings::from_config_text(text), + ) + }), + Some((BundlerPathLevel::Env, self.env)), + self.global.as_deref().map(|text| { + ( + BundlerPathLevel::Global, + BundlerPathSettings::from_config_text(text), + ) + }), + ] + .into_iter() + .flatten() + .collect() + } + + /// The tier that decides the install path, the one rule every caller + /// reads it through: `Settings#path` takes the FIRST tier that + /// [`BundlerPathSettings::decides_path`] alone; `None` when no tier + /// does. + fn deciding(self) -> Option<(BundlerPathLevel, BundlerPathSettings)> { + Self::deciding_of(self.settings()) + } + + fn deciding_of( + settings: Vec<(BundlerPathLevel, BundlerPathSettings)>, + ) -> Option<(BundlerPathLevel, BundlerPathSettings)> { + settings.into_iter().find(|(_, tier)| tier.decides_path()) + } +} + /// Whether Bundler installs into an explicit `path` instead of the system /// gems, following `Bundler::Settings#path`: the first tier (local, env, /// global) that sets `path`, `path.system` or `disable_shared_gems` decides @@ -1345,37 +1434,18 @@ pub(crate) struct BundlerPathTiers { /// which one applies depends on the Bundler that runs, which a scan can't /// read. pub(crate) fn bundler_sets_explicit_path(tiers: BundlerPathTiers) -> bool { - let settings: Vec = [ - tiers - .local - .as_deref() - .map(BundlerPathSettings::from_config_text), - Some(tiers.env), - tiers - .global - .as_deref() - .map(BundlerPathSettings::from_config_text), - ] - .into_iter() - .flatten() - .collect(); - for tier in &settings { - if tier.path.is_none() && tier.path_system.is_none() && tier.disable_shared_gems.is_none() { - continue; - } - // Both flags go through Bundler's `to_bool` coercion. - let system = tier.path_system.as_deref().is_some_and(bundler_truthy) - || tier - .disable_shared_gems - .as_deref() - .is_some_and(|v| !bundler_truthy(v)); - return !system && tier.path.as_deref().is_some_and(|p| !p.is_empty()); - } + let settings = tiers.settings(); // `path = "vendor/bundle" if self[:deployment]`, after the tier loop. - settings + let deployment = settings .iter() - .find_map(|tier| tier.deployment.as_deref()) - .is_some_and(bundler_truthy) + .find_map(|(_, tier)| tier.deployment.as_deref()) + .is_some_and(bundler_truthy); + match BundlerPathTiers::deciding_of(settings) { + Some((_, tier)) => { + !tier.uses_system_gems() && tier.path.as_deref().is_some_and(|p| !p.is_empty()) + } + None => deployment, + } } /// Whether the installed gem dir `copy` (`/gems/-`) @@ -2140,44 +2210,17 @@ pub(crate) fn bundler_global_config_file( } /// [`ambient_bundler_global_config_file`], or `None` under -/// `BUNDLE_IGNORE_CONFIG` or when the environment sets `path.system` or -/// `disable_shared_gems` — for the install-root probe, which takes no -/// `ignore_config` flag of its own. +/// `BUNDLE_IGNORE_CONFIG` — for the install-root probe, which takes no +/// `ignore_config` flag of its own. Env flags that shadow it are applied +/// by the tier resolver ([`BundlerPathTiers::deciding`]). fn ambient_global_config_unless_ignored(root: &Path) -> Option { if bundler_ignores_config() { None } else { - global_path_config_unless_env_path_settings( - ambient_bundler_global_config_file(root), - std::env::var_os("BUNDLE_PATH__SYSTEM").as_deref(), - std::env::var_os("BUNDLE_DISABLE_SHARED_GEMS").as_deref(), - ) + ambient_bundler_global_config_file(root) } } -/// The global config file for the install-root probe, or `None` when the -/// env tier sets `path.system` or `disable_shared_gems`. Bundler's -/// `Settings#path` stops at the first tier that sets either flag or `path`, -/// and the env tier sits above the global one, so any flag value (even -/// `"false"` or empty) shadows a global `path` — the env `BUNDLE_PATH` part -/// of that rule is applied in [`RubyCrawler::discover_bundle_stores_impl`]. -fn global_path_config_unless_env_path_settings( - global_config: Option, - path_system_env: Option<&OsStr>, - disable_shared_gems_env: Option<&OsStr>, -) -> Option { - if path_system_env.is_some() || disable_shared_gems_env.is_some() { - None - } else { - global_config - } -} - -/// The ambient `BUNDLE_PATH__SYSTEM` (Bundler's env-tier `path.system`). -fn ambient_path_system_env() -> Option { - std::env::var_os("BUNDLE_PATH__SYSTEM") -} - /// Bundler's boolean coercion for a setting value (`Settings#to_bool`): /// `false`, `f`, `no`, `n`, `0` (any case) and the empty string are /// false, everything else is true. @@ -2358,7 +2401,8 @@ fn resolve_config_bundle_path( /// `BUNDLE_PATH__SYSTEM:` must not match the path key — the prefix requires /// the colon immediately after `BUNDLE_PATH`. /// -/// `BUNDLE_PATH__SYSTEM: "true"` (bundler's `path.system` setting) makes +/// `BUNDLE_PATH__SYSTEM: "true"` (bundler's `path.system` setting), or a +/// falsy `BUNDLE_DISABLE_SHARED_GEMS` (#1931), makes /// bundler IGNORE any recorded path and use the system/default gem home, so /// the whole config entry parses as unset — the caller then falls through /// to the `gem env` homes, which is exactly where those gems live. The flag @@ -2377,6 +2421,7 @@ fn parse_legacy_bundle_config_path(contents: &str) -> Option { fn parse_bundle_config_path_with(contents: &str, unquote: fn(&str) -> &str) -> Option { let mut path: Option = None; let mut path_system = false; + let mut shared_gems = false; for line in contents.lines() { if let Some(rest) = line.strip_prefix("BUNDLE_PATH:") { let v = unquote(rest); @@ -2385,43 +2430,18 @@ fn parse_bundle_config_path_with(contents: &str, unquote: fn(&str) -> &str) -> O } } else if let Some(rest) = line.strip_prefix("BUNDLE_PATH__SYSTEM:") { path_system = bundler_truthy(unquote(rest)); + } else if let Some(rest) = line.strip_prefix("BUNDLE_DISABLE_SHARED_GEMS:") { + shared_gems = !bundler_truthy(unquote(rest)); } } - if path_system { + // The same system-home rule as [`BundlerPathSettings::uses_system_gems`]. + if path_system || shared_gems { None } else { path } } -/// The part of Bundler's `Settings::Path` the crawler needs from the tier -/// that decides the install path. -#[derive(Debug, Clone, Copy)] -struct BundlerPathTier { - /// The tier sets `path`, even to an empty string (`explicit_path`). - explicit_path: bool, - /// The tier's `path.system` is truthy (`system_path`). - system: bool, -} - -/// For one Bundler config file: `None` when it sets none of `path`, -/// `path.system` and `disable_shared_gems` (Bundler reads on to the next -/// tier), else what it says about the path. -fn config_path_tier(contents: &str) -> Option { - let sets_path = [ - "BUNDLE_PATH", - "BUNDLE_PATH__SYSTEM", - "BUNDLE_DISABLE_SHARED_GEMS", - ] - .iter() - .any(|key| bundle_config_setting_including_empty(contents, key).is_some()); - sets_path.then(|| BundlerPathTier { - explicit_path: bundle_config_setting_including_empty(contents, "BUNDLE_PATH").is_some(), - system: bundle_config_setting_including_empty(contents, "BUNDLE_PATH__SYSTEM") - .is_some_and(|v| bundler_truthy(&v)), - }) -} - /// The effective `:` value of a bundler app config file (flat YAML /// that bundler writes itself, `---\nBUNDLE_GEMFILE: "Gemfile.next"\n`): /// the last entry for the exact key wins, quotes are unwrapped, and an @@ -4445,34 +4465,84 @@ mod tests { /// value, even `"false"` or empty — shadows a global `path`: Bundler's /// `Settings#path` stops at the env tier (checked against Bundler /// 4.0.17: `explicit_path` is `nil` for each value). - #[test] - fn env_path_flags_shadow_the_global_config_path() { - let global = Some(PathBuf::from("/home/u/.bundle/config")); + #[tokio::test] + async fn env_path_flags_shadow_the_global_config_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + let store = root + .join("vendor") + .join("gems") + .join("ruby") + .join("3.3.0") + .join("gems"); + std::fs::create_dir_all(store.join("colorize-0.8.1")).unwrap(); + std::fs::write(root.join("Gemfile"), "gem \"colorize\"\n").unwrap(); + let global = dir.path().join("global-config"); + std::fs::write(&global, "---\nBUNDLE_PATH: \"vendor/gems\"\n").unwrap(); + let g = Some(global.as_path()); + let flags = + |path_system: Option<&str>, disable_shared_gems: Option<&str>| BundlerEnvPathFlags { + path_system: path_system.map(Into::into), + disable_shared_gems: disable_shared_gems.map(Into::into), + }; + for value in ["true", "false", ""] { - assert_eq!( - global_path_config_unless_env_path_settings( - global.clone(), - Some(OsStr::new(value)), - None - ), + for env in [flags(Some(value), None), flags(None, Some(value))] { + let stores = + RubyCrawler::discover_bundle_stores_with_env_flags(&root, None, &env, g) + .await + .stores; + assert!(stores.is_empty(), "{env:?}: {stores:?}"); + } + } + // Unset: the global file still applies. + let stores = RubyCrawler::discover_bundle_stores_with_env_flags( + &root, + None, + &BundlerEnvPathFlags::default(), + g, + ) + .await + .stores; + assert_eq!(stores, vec![store]); + } + + /// #1931, env variant: `BUNDLE_DISABLE_SHARED_GEMS=false` in the + /// environment sends Bundler to the system gems, so a leftover + /// `vendor/bundle` is dropped, as for an env `BUNDLE_PATH__SYSTEM=true`. + /// A truthy env flag keeps it. + #[tokio::test] + async fn env_falsy_disable_shared_gems_drops_leftover_vendor_bundle() { + let dir = tempfile::tempdir().unwrap(); + let store = stage_leftover_vendor_bundle(dir.path()).await; + let flags = |value: &str| BundlerEnvPathFlags { + path_system: None, + disable_shared_gems: Some(value.into()), + }; + for value in ["false", "no", "0", ""] { + let discovery = RubyCrawler::discover_bundle_stores_with_env_flags( + dir.path(), None, - "BUNDLE_PATH__SYSTEM={value}" - ); - assert_eq!( - global_path_config_unless_env_path_settings( - global.clone(), - None, - Some(OsStr::new(value)) - ), + &flags(value), None, - "BUNDLE_DISABLE_SHARED_GEMS={value}" + ) + .await; + assert!( + discovery.stores.is_empty(), + "{value}: {:?}", + discovery.stores ); + assert!(!discovery.default_root_has_stores, "{value}"); } - // Unset: the global file still applies. - assert_eq!( - global_path_config_unless_env_path_settings(global.clone(), None, None), - global - ); + let discovery = RubyCrawler::discover_bundle_stores_with_env_flags( + dir.path(), + None, + &flags("true"), + None, + ) + .await; + assert_eq!(discovery.stores, vec![store]); + assert!(discovery.default_root_has_stores); } #[tokio::test] @@ -5172,6 +5242,141 @@ mod tests { assert!(!discovery.default_root_has_stores); } + /// #1931: a falsy `disable_shared_gems` in the deciding tier sends + /// Bundler to the system gems exactly like a truthy `path.system` + /// (`system_scope = !disable_shared_gems`), so a leftover + /// `vendor/bundle` is unused: it must not be crawled, and must not + /// switch off the `gem env` homes. Local and global tiers. + #[tokio::test] + async fn falsy_disable_shared_gems_drops_leftover_vendor_bundle() { + for value in ["false", "no", "0", ""] { + let setting = format!("---\nBUNDLE_DISABLE_SHARED_GEMS: \"{value}\"\n"); + + let dir = tempfile::tempdir().unwrap(); + stage_leftover_vendor_bundle(dir.path()).await; + tokio::fs::create_dir_all(dir.path().join(".bundle")) + .await + .unwrap(); + tokio::fs::write(dir.path().join(".bundle").join("config"), &setting) + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None) + .await; + assert!( + discovery.stores.is_empty(), + "local {value}: {:?}", + discovery.stores + ); + assert!(!discovery.default_root_has_stores, "local {value}"); + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + stage_leftover_vendor_bundle(&root).await; + let global = dir.path().join("global-config"); + tokio::fs::write(&global, &setting).await.unwrap(); + let discovery = RubyCrawler::discover_bundle_stores_with_env( + &root, + None, + None, + None, + Some(&global), + ) + .await; + assert!( + discovery.stores.is_empty(), + "global {value}: {:?}", + discovery.stores + ); + assert!(!discovery.default_root_has_stores, "global {value}"); + } + } + + /// #1931: a path recorded in the same tier as a falsy + /// `disable_shared_gems` is ignored too (`Path#path` takes the system + /// home whenever `system_path` is set), local and global. + #[tokio::test] + async fn falsy_disable_shared_gems_drops_same_tier_path() { + let setting = "---\nBUNDLE_PATH: \"vendor/x\"\nBUNDLE_DISABLE_SHARED_GEMS: \"false\"\n"; + let stage = |root: PathBuf| async move { + let store = root.join("vendor/x/ruby/3.3.0/gems"); + tokio::fs::create_dir_all(store.join("colorize-0.8.1")) + .await + .unwrap(); + tokio::fs::write(root.join("Gemfile"), b"gem \"colorize\"\n") + .await + .unwrap(); + }; + + let dir = tempfile::tempdir().unwrap(); + stage(dir.path().to_path_buf()).await; + tokio::fs::create_dir_all(dir.path().join(".bundle")) + .await + .unwrap(); + tokio::fs::write(dir.path().join(".bundle/config"), setting) + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None).await; + assert!(discovery.stores.is_empty(), "local: {:?}", discovery.stores); + assert_eq!(discovery.skipped_config_path, None); + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + stage(root.clone()).await; + let global = dir.path().join("global-config"); + tokio::fs::write(&global, setting).await.unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(&root, None, None, None, Some(&global)) + .await; + assert!( + discovery.stores.is_empty(), + "global: {:?}", + discovery.stores + ); + } + + /// #1931 controls: a truthy `disable_shared_gems` keeps Bundler off the + /// system gems, so the leftover store stays; a falsy flag in a tier a + /// higher tier shadows does not count. + #[tokio::test] + async fn truthy_or_shadowed_disable_shared_gems_keeps_vendor_bundle() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); + let store = stage_leftover_vendor_bundle(&root).await; + tokio::fs::write( + root.join(".bundle").join("config"), + "---\nBUNDLE_DISABLE_SHARED_GEMS: \"true\"\n", + ) + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(&root, None, None, None, None).await; + assert_eq!(discovery.stores, vec![store.clone()]); + assert!(discovery.default_root_has_stores); + + // A local `path.system: false` decides, so the global falsy flag + // is never read. + tokio::fs::write( + root.join(".bundle").join("config"), + "---\nBUNDLE_PATH__SYSTEM: \"false\"\n", + ) + .await + .unwrap(); + let global = dir.path().join("global-config"); + tokio::fs::write(&global, "---\nBUNDLE_DISABLE_SHARED_GEMS: \"false\"\n") + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(&root, None, None, None, Some(&global)) + .await; + assert_eq!(discovery.stores, vec![store]); + } + /// #915 controls: only the tier Bundler actually reads decides. A /// higher tier that sets a path (or a falsy flag) shadows a lower /// `path.system`, and a project with no config keeps the historic