diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..208675691 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -505,7 +505,12 @@ the model is **not uniform** today: "nothing installed" absence the hosted lockfile basis excuses), while `apply`/`rollback` never touch it (#709). Explicit env/config/standalone roots only count when `--cwd` holds a Bundler manifest/lockfile. When the default `vendor/bundle` root holds no store, the gem homes `gem env` - reports are appended (default gems like rexml/json only ever live there). When several roots hold + reports are appended (default gems like rexml/json only ever live there). When the Bundler tier that + decides the install path (the first of the app config, the environment and the global config that + sets `path`, `path.system` or `disable_shared_gems`) sets a truthy `path.system` (Bundler's own + coercion: anything but `false`/`f`/`no`/`n`/`0`/empty), bundler installs into and loads from the + system gem home, so the default `vendor/bundle` root is not probed at all: a leftover store there + is neither crawled nor allowed to hide the `gem env` homes (#915). When several roots hold **coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy, mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`, diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 6a6f99c93..a5163d341 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -47,6 +47,7 @@ impl RubyCrawler { Ok(Self::gem_paths_and_discovery( options, std::env::var_os("BUNDLE_PATH").as_deref(), + ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(&options.cwd).as_deref(), @@ -72,6 +73,7 @@ impl RubyCrawler { Ok(Self::gem_paths_and_discovery( options, bundle_path_env, + None, app_config_env, home_env, global_config, @@ -88,6 +90,7 @@ impl RubyCrawler { async fn gem_paths_and_discovery( options: &CrawlerOptions, bundle_path_env: Option<&OsStr>, + path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, global_config: Option<&Path>, @@ -104,6 +107,7 @@ impl RubyCrawler { let discovery = Self::discover_bundle_stores_impl( &options.cwd, bundle_path_env, + path_system_env, app_config_env, home_env, global_config, @@ -160,6 +164,7 @@ impl RubyCrawler { let (gem_paths, discovery) = Self::gem_paths_and_discovery( options, std::env::var_os("BUNDLE_PATH").as_deref(), + ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(&options.cwd).as_deref(), @@ -354,6 +359,7 @@ impl RubyCrawler { async fn discover_bundle_stores_impl( cwd: &Path, bundle_path_env: Option<&OsStr>, + path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, global_config: Option<&Path>, @@ -363,6 +369,25 @@ 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 + // `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 + // copy lives (#915). Gated like the explicit roots: config only + // counts for a Ruby project. (A recorded local path never coexists + // with it: [`parse_bundle_config_path`] already drops it.) + let uses_system_gems = Self::has_bundler_manifest(cwd).await + && Self::bundler_path_system( + cwd, + bundle_path_env, + path_system_env, + app_config_env, + global_config, + ignore_config, + ) + .await; + let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; let mut skipped_config_root = None; @@ -385,7 +410,7 @@ impl RubyCrawler { } } } - if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) { + if let Some(v) = bundle_path_env.filter(|v| !v.is_empty() && !uses_system_gems) { roots.push(resolve_bundle_path(cwd, Path::new(v), home)); } let standalone_root = cwd.join("bundle"); @@ -414,7 +439,9 @@ impl RubyCrawler { } } } - roots.push(default_root.clone()); + if !uses_system_gems { + roots.push(default_root.clone()); + } let mut stores = Vec::new(); let mut default_root_has_stores = false; @@ -452,6 +479,7 @@ impl RubyCrawler { Self::discover_bundle_stores_impl( cwd, std::env::var_os("BUNDLE_PATH").as_deref(), + ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), ambient_global_config_unless_ignored(cwd).as_deref(), @@ -473,6 +501,7 @@ impl RubyCrawler { Self::discover_bundle_stores_impl( cwd, bundle_path_env, + None, app_config_env, home_env, global_config, @@ -481,6 +510,33 @@ impl RubyCrawler { .await } + /// [`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. + #[cfg(test)] + async fn discover_bundle_stores_with_path_system_env( + cwd: &Path, + bundle_path_env: Option<&OsStr>, + 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_impl( + cwd, + bundle_path_env, + path_system_env, + None, + None, + global_config.as_deref(), + false, + ) + .await + } + /// Installed-gem stores under a config-sourced `BUNDLE_PATH` the /// containment guard refused (it resolves outside the project), for /// READ-ONLY consumers: the hosted stale-install probe and `vex`'s @@ -523,9 +579,12 @@ impl RubyCrawler { } // 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. let discovery = Self::discover_bundle_stores_impl( &options.cwd, bundle_path_env, + None, app_config_env, home_env, global_config, @@ -604,15 +663,42 @@ impl RubyCrawler { 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| { - [ - "BUNDLE_PATH", - "BUNDLE_PATH__SYSTEM", - "BUNDLE_DISABLE_SHARED_GEMS", - ] - .iter() - .any(|key| bundle_config_setting_including_empty(&text, key).is_some()) - }) + .is_ok_and(|text| config_path_system(&text).is_some()) + } + + /// Whether the Bundler tier that decides the install path sets a + /// truthy `path.system`. `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); with no such tier there is no `path.system` at all. + /// 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_system( + cwd: &Path, + bundle_path_env: Option<&OsStr>, + path_system_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, + global_config: Option<&Path>, + ignore_config: bool, + ) -> bool { + 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(system) = config_path_system(&text) { + return system; + } + } + } + if bundle_path_env.is_some() || path_system_env.is_some() { + return path_system_env.is_some_and(|v| bundler_truthy(&v.to_string_lossy())); + } + if let Some(global) = global_config { + if let Ok(text) = crate::utils::fs::read_regular_to_string(global).await { + return config_path_system(&text).unwrap_or(false); + } + } + false } /// The `BUNDLE_PATH` recorded in bundler's app config file — the value @@ -1302,6 +1388,21 @@ fn global_path_config_unless_env_path_settings( } } +/// 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. +pub(crate) fn bundler_truthy(value: &str) -> bool { + !matches!( + value.to_ascii_lowercase().as_str(), + "false" | "f" | "no" | "n" | "0" | "" + ) +} + /// [`bundler_global_config_file`] for the ambient environment. pub(crate) fn ambient_bundler_global_config_file(root: &Path) -> Option { bundler_global_config_file( @@ -1468,9 +1569,9 @@ fn resolve_config_bundle_path( /// `BUNDLE_PATH__SYSTEM: "true"` (bundler's `path.system` setting) 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. Bundler -/// converts only the exact string `true` to a truthy setting; anything else -/// leaves the recorded path in effect. +/// to the `gem env` homes, which is exactly where those gems live. The flag +/// goes through Bundler's own coercion ([`bundler_truthy`]), so `"1"` or +/// `"yes"` count too, while `"false"` leaves the recorded path in effect. fn parse_bundle_config_path(contents: &str) -> Option { let mut path: Option = None; let mut path_system = false; @@ -1481,7 +1582,7 @@ fn parse_bundle_config_path(contents: &str) -> Option { path = Some(v.to_string()); } } else if let Some(rest) = line.strip_prefix("BUNDLE_PATH__SYSTEM:") { - path_system = unquote_bundle_config_value(rest) == "true"; + path_system = bundler_truthy(unquote_bundle_config_value(rest)); } } if path_system { @@ -1491,6 +1592,23 @@ fn parse_bundle_config_path(contents: &str) -> Option { } } +/// 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 whether its `path.system` is truthy. +fn config_path_system(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(|| { + 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 @@ -3200,6 +3318,211 @@ mod tests { ); } + /// Stage a project that used to install into `vendor/bundle` (the + /// store is still on disk, gitignored) and returns that store. + async fn stage_leftover_vendor_bundle(root: &Path) -> PathBuf { + tokio::fs::write(root.join("Gemfile"), b"gem \"colorize\"\n") + .await + .unwrap(); + let store = root + .join("vendor") + .join("bundle") + .join("ruby") + .join("3.3.0") + .join("gems"); + tokio::fs::create_dir_all(store.join("colorize-0.8.1").join("lib")) + .await + .unwrap(); + store + } + + /// #915: `bundle config set --local path.system true` sends Bundler + /// back to the system gem home, so a leftover `vendor/bundle` is as + /// unused as a recorded path. It must not be crawled, and must not + /// switch off the `gem env` homes where the loaded copy lives. + /// Bundler's `to_bool` treats every value but `false`/`f`/`no`/`n`/ + /// `0`/empty as true (checked against Bundler 4.0.18). + #[tokio::test] + async fn local_path_system_drops_leftover_vendor_bundle() { + for value in ["true", "1", "yes"] { + 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"), + format!("---\nBUNDLE_PATH__SYSTEM: \"{value}\"\n"), + ) + .await + .unwrap(); + + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None) + .await; + assert!( + discovery.stores.is_empty(), + "{value}: {:?}", + discovery.stores + ); + assert!(!discovery.default_root_has_stores, "{value}"); + + // The local tier also shadows an env `BUNDLE_PATH`, even one + // naming the leftover `vendor/bundle` itself. + let vendor_bundle = dir.path().join("vendor").join("bundle"); + let discovery = RubyCrawler::discover_bundle_stores_with_env( + dir.path(), + Some(vendor_bundle.as_os_str()), + None, + None, + None, + ) + .await; + assert!( + discovery.stores.is_empty(), + "{value}: {:?}", + discovery.stores + ); + assert!(!discovery.default_root_has_stores, "{value}"); + } + } + + /// #915, env variant: `BUNDLE_PATH__SYSTEM=true` in the environment. + #[tokio::test] + async fn env_path_system_drops_leftover_vendor_bundle() { + let dir = tempfile::tempdir().unwrap(); + stage_leftover_vendor_bundle(dir.path()).await; + + let discovery = RubyCrawler::discover_bundle_stores_with_path_system_env( + dir.path(), + None, + Some(OsStr::new("true")), + None, + ) + .await; + assert!(discovery.stores.is_empty(), "{:?}", discovery.stores); + assert!(!discovery.default_root_has_stores); + + // Same tier as an env `BUNDLE_PATH`: Bundler goes to the system + // gem home (4.0.18 refuses the combination outright), so the env + // root must not bring the leftover store back. + let vendor_bundle = dir.path().join("vendor").join("bundle"); + let discovery = RubyCrawler::discover_bundle_stores_with_path_system_env( + dir.path(), + Some(vendor_bundle.as_os_str()), + Some(OsStr::new("true")), + None, + ) + .await; + assert!(discovery.stores.is_empty(), "{:?}", discovery.stores); + assert!(!discovery.default_root_has_stores); + } + + /// #915, global variant: `bundle config set --global path.system true`. + #[tokio::test] + async fn global_path_system_drops_leftover_vendor_bundle() { + 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, "---\nBUNDLE_PATH__SYSTEM: \"true\"\n") + .await + .unwrap(); + + let discovery = + RubyCrawler::discover_bundle_stores_with_env(&root, None, None, None, Some(&global)) + .await; + assert!(discovery.stores.is_empty(), "{:?}", discovery.stores); + assert!(!discovery.default_root_has_stores); + } + + /// #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 + /// leftover-`vendor/bundle` heuristic. + #[tokio::test] + async fn shadowed_path_system_keeps_vendor_bundle() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let store = stage_leftover_vendor_bundle(&root).await; + let global = dir.path().join("global-config"); + tokio::fs::write(&global, "---\nBUNDLE_PATH__SYSTEM: \"true\"\n") + .await + .unwrap(); + let vendor_bundle = root.join("vendor").join("bundle"); + + // No config at all: unchanged. + 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); + + // Env `BUNDLE_PATH` sits above the global tier. + let discovery = RubyCrawler::discover_bundle_stores_with_env( + &root, + Some(vendor_bundle.as_os_str()), + None, + None, + Some(&global), + ) + .await; + assert_eq!(discovery.stores, vec![store.clone()]); + + // A falsy env flag stops Bundler at the env tier too. + let discovery = RubyCrawler::discover_bundle_stores_with_path_system_env( + &root, + None, + Some(OsStr::new("false")), + None, + ) + .await; + assert_eq!(discovery.stores, vec![store.clone()]); + + // A local path sits above the env flag. + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); + tokio::fs::write( + root.join(".bundle").join("config"), + "---\nBUNDLE_PATH: \"vendor/bundle\"\n", + ) + .await + .unwrap(); + let discovery = RubyCrawler::discover_bundle_stores_with_path_system_env( + &root, + None, + Some(OsStr::new("true")), + None, + ) + .await; + assert_eq!(discovery.stores, vec![store.clone()]); + + // A falsy local flag sits above the global `path.system`. + tokio::fs::write( + root.join(".bundle").join("config"), + "---\nBUNDLE_PATH__SYSTEM: \"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]); + } + + /// Bundler's boolean coercion (`Settings#to_bool`). + #[test] + fn bundler_truthy_matches_bundler_to_bool() { + for value in ["true", "TRUE", "1", "yes", "y", "on", "anything"] { + assert!(bundler_truthy(value), "{value}"); + } + for value in ["false", "False", "f", "no", "N", "0", ""] { + assert!(!bundler_truthy(value), "{value}"); + } + } + /// Pure parser contract for the `.bundle/config` scrape: bundler's /// own quoted form, unquoted and single-quoted variants, CRLF, /// empty-value-as-unset, and no match on `BUNDLE_PATH__SYSTEM:` or @@ -3248,7 +3571,13 @@ mod tests { ), None ); - // Only the exact string "true" is truthy (bundler's own coercion). + // Bundler's own coercion: "1" is truthy too, "false" is not. + assert_eq!( + parse_bundle_config_path( + "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_PATH__SYSTEM: \"1\"\n" + ), + None + ); assert_eq!( parse_bundle_config_path( "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_PATH__SYSTEM: \"false\"\n" diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 687c2192c..fa8bc1196 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -777,6 +777,89 @@ async fn global_bundle_store_is_shadowed_by_environment_path_flags() { } } +/// #915: a project that moved from `vendor/bundle` back to system gems +/// (`bundle config set --local path.system true`, or the env +/// `BUNDLE_PATH__SYSTEM=true`) still has the gitignored `vendor/bundle`. +/// Bundler loads the system copy, so the ambient discovery must crawl the +/// `gem env` home and not the leftover store. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn path_system_crawls_gem_env_home_not_leftover_vendor_bundle() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + let leftover = root.join("vendor/bundle/ruby/3.3.0/gems"); + stage_gem(&leftover, "colorize", "0.8.1").await; + std::fs::write(root.join("Gemfile"), "gem 'colorize', '0.8.1'\n").unwrap(); + std::fs::create_dir_all(root.join(".bundle")).unwrap(); + + let gemdir = tmp.path().join("system-gem-home"); + let system_gems = gemdir.join("gems"); + let loaded_copy = stage_gem(&system_gems, "colorize", "0.8.1").await; + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + install_fake_gem(&bin, &gemdir); + + let keys = [ + "BUNDLE_CONFIG", + "BUNDLE_USER_CONFIG", + "BUNDLE_USER_HOME", + "BUNDLE_APP_CONFIG", + "BUNDLE_IGNORE_CONFIG", + "BUNDLE_PATH", + "BUNDLE_PATH__SYSTEM", + "BUNDLE_DISABLE_SHARED_GEMS", + "HOME", + ]; + let previous: Vec<_> = keys.iter().map(|key| std::env::var_os(key)).collect(); + for key in keys { + std::env::remove_var(key); + } + std::env::set_var("HOME", tmp.path().join("home")); + let crawler = RubyCrawler; + let options = options_at(&root); + let mut cases = Vec::new(); + for (label, config, env) in [ + ("control", "---\n", None), + ("local", "---\nBUNDLE_PATH__SYSTEM: \"true\"\n", None), + ("env", "---\n", Some("true")), + ] { + std::fs::write(root.join(".bundle/config"), config).unwrap(); + if let Some(value) = env { + std::env::set_var("BUNDLE_PATH__SYSTEM", value); + } + let paths = with_path(&bin, || async { crawler.get_gem_paths(&options).await }) + .await + .unwrap(); + std::env::remove_var("BUNDLE_PATH__SYSTEM"); + let mut found = Vec::new(); + for gems_dir in &paths { + let hits = crawler + .find_each_by_purl(gems_dir, &["pkg:gem/colorize@0.8.1".to_string()]) + .await; + found.extend(hits.into_iter().flatten().map(|p| p.path)); + } + cases.push((label, paths, found)); + } + // Restore the ambient state before any assertion can panic. + for (key, value) in keys.into_iter().zip(previous) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + + for (label, paths, found) in cases { + if label == "control" { + // No setting: the historic leftover heuristic is unchanged. + assert_eq!(paths, vec![leftover.clone()], "{label}"); + } else { + assert_eq!(paths, vec![system_gems.clone()], "{label}"); + assert_eq!(found, vec![loaded_copy.clone()], "{label}"); + } + } +} + // ── global gem discovery ─────────────────────────────────────── #[tokio::test]