From 604790abe04755255d85569e12060b7119416879 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:30:32 +0000 Subject: [PATCH 1/7] Start fix for #915 Assisted-by: Claude Code:claude-opus-5-5 From ff0362f933658554545d43e7c3db032d9ce19c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:50:30 +0000 Subject: [PATCH 2/7] Patch the gem Bundler loads under path.system A project that switched from vendor/bundle back to system gems (`bundle config set path.system true`, or BUNDLE_PATH__SYSTEM=true) usually still has the old gitignored vendor/bundle. The gem crawler always counted that leftover store and, because it held gems, stopped looking in the `gem env` homes. Agent apply then patched only the unused copy, and vex attested not_affected while Bundler kept loading the unpatched system gem. The crawler now works out which Bundler settings tier decides the install path (local config, then environment, then global config) and, when that tier sets a truthy path.system, skips the default vendor/bundle root so the system gem homes are crawled. path.system values now follow Bundler's own boolean coercion, so "1" or "yes" count as true too. Fixes #915 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 7 +- .../src/crawlers/ruby_crawler.rs | 324 +++++++++++++++++- .../tests/crawler_ruby_e2e.rs | 83 +++++ 3 files changed, 398 insertions(+), 16 deletions(-) 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/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 6a6f99c93..d42ff5b11 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>, @@ -414,7 +420,25 @@ impl RubyCrawler { } } } - roots.push(default_root.clone()); + // A truthy `path.system` in the tier Bundler reads sends it to the + // system gem home, which ignores `vendor/bundle` as much as any + // recorded path. 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. + 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; + if !uses_system_gems { + roots.push(default_root.clone()); + } let mut stores = Vec::new(); let mut default_root_has_stores = false; @@ -452,6 +476,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 +498,7 @@ impl RubyCrawler { Self::discover_bundle_stores_impl( cwd, bundle_path_env, + None, app_config_env, home_env, global_config, @@ -481,6 +507,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 +576,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 +660,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 +1385,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 +1566,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 +1579,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 +1589,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 +3315,179 @@ 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}"); + } + } + + /// #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); + } + + /// #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 +3536,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/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] From de26452ef3d86e2e44af6a1852a5b2730ed57eb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 3/7] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) 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/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)), } } From ccebcd007ddd686062e20410f55aa243bd1306c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:25:55 +0000 Subject: [PATCH 4/7] Skip env bundle path shadowed by path.system When the Bundler tier that wins sets path.system, Bundler also ignores an env BUNDLE_PATH below it or beside it. If that value named the leftover vendor/bundle, the crawler still probed it as the default root and hid the system gem homes again, so apply and vex kept targeting the unused copy. The env root is now skipped in that case too. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 69 ++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index d42ff5b11..a5163d341 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -369,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; @@ -391,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"); @@ -420,22 +439,6 @@ impl RubyCrawler { } } } - // A truthy `path.system` in the tier Bundler reads sends it to the - // system gem home, which ignores `vendor/bundle` as much as any - // recorded path. 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. - 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; if !uses_system_gems { roots.push(default_root.clone()); } @@ -3363,6 +3366,24 @@ mod tests { 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}"); } } @@ -3381,6 +3402,20 @@ mod tests { .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`. From 1861c151713612cddd188858caa9cc97b42fcbb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:26:01 +0000 Subject: [PATCH 5/7] Start fix for #967 Assisted-by: Claude Code:claude-opus-5-5 From d21cbd34acc012466479352cefc8db866ad74d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:40:31 +0000 Subject: [PATCH 6/7] Patch gems Bundler installs into .bundle With `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x), and no path in any settings tier, `bundle install` puts the project's gems in `/.bundle///gems` and `bundle exec` loads them from there. The gem crawler never probed that root, so agent `apply` patched only the system copy, `vex` attested not_affected over the unpatched loaded copy, and the hosted stale-install guard missed a stale `.bundle` materialization. Resolve the deciding Bundler path tier once (explicit path and `path.system`) and probe `/.bundle` whenever that tier names no path and no truthy `path.system`, mirroring `Path#base_path`. Like the other explicit roots it keeps the `gem env` fallback on for default gems. Fixes #967 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_stale_install.rs | 77 ++++++ .../src/crawlers/ruby_crawler.rs | 235 ++++++++++++++++-- .../tests/crawler_ruby_e2e.rs | 111 +++++++++ 3 files changed, 396 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index 24817325f..bf19519fa 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -617,6 +617,83 @@ async fn gem_hosted_stale_bundler4_standalone_install_warns_and_is_not_attested( ); } +/// #967: with `simulate_version 5` (Bundler 4.x) or +/// `default_install_uses_path` (2.x) and no path in any tier, Bundler +/// installs into `/.bundle///gems`. A stale copy there is +/// what `bundle exec` loads, so the guard must name it and the same run's +/// `--vex` must not attest the purl. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_stale_dot_bundle_install_warns_and_is_not_attested() { + for config in [ + "---\nBUNDLE_SIMULATE_VERSION: \"5\"\n", + "---\nBUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\"\n", + ] { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + let home = proj.join(".bundle").join("ruby").join("3.3.0"); + let gem_dir = home.join("gems").join(format!("{DEP}-{DEP_VERSION}")); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), UPSTREAM_LIB).unwrap(); + std::fs::write(proj.join(".bundle").join("config"), config).unwrap(); + + let vex_path = proj.join("out.vex.json"); + let (code, stdout, stderr) = common::run_with_env( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:gem/app@1.0.0", + ], + &[], + ); + let env = common::parse_json_envelope(&stdout); + assert_eq!( + env["redirect"]["redirected"], 1, + "{config}: envelope: {env}" + ); + let details = stale_warnings(&env); + assert_eq!( + details.len(), + 1, + "{config}: the stale .bundle copy must trip the guard: {env}\nstderr:\n{stderr}" + ); + assert!( + details[0].contains(&gem_dir.display().to_string()) + && !details[0].contains("shared gem home"), + "{config}: the warning must name the .bundle copy as project-local: {}", + details[0] + ); + if let Ok(doc) = std::fs::read_to_string(&vex_path) { + assert!( + !doc.contains(PURL), + "{config}: a stale .bundle copy must never be attested:\n{doc}" + ); + } + assert_ne!( + code, 0, + "{config}: an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } +} + /// #483: bundler's cache dir is the `cache_path` setting — `bundle config /// set --local cache_path vendor/gems` (a committed `.bundle/config`) or a /// `BUNDLE_CACHE_PATH` export. A fresh checkout whose committed archive at diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index a5163d341..f47b7707b 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -377,8 +377,8 @@ impl RubyCrawler { // 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( + let path_tier = if Self::has_bundler_manifest(cwd).await { + Self::bundler_path_tier( cwd, bundle_path_env, path_system_env, @@ -386,7 +386,11 @@ impl RubyCrawler { global_config, ignore_config, ) - .await; + .await + } else { + None + }; + let uses_system_gems = path_tier.is_some_and(|tier| tier.system); let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; @@ -438,6 +442,19 @@ 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` + // 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 + // those version-dependent flags: the scoped store only exists + // 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) { + let dot_bundle = cwd.join(".bundle"); + roots.push(normalize_lexically(&dot_bundle).unwrap_or(dot_bundle)); + } } if !uses_system_gems { roots.push(default_root.clone()); @@ -663,42 +680,43 @@ 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| config_path_system(&text).is_some()) + .is_ok_and(|text| config_path_tier(&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 + /// 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_system( + 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, - ) -> 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(system) = config_path_system(&text) { - return system; + if let Some(tier) = config_path_tier(&text) { + return Some(tier); } } } 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); - } + return Some(BundlerPathTier { + explicit_path: bundle_path_env.is_some(), + system: path_system_env.is_some_and(|v| bundler_truthy(&v.to_string_lossy())), + }); } - false + 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 @@ -1592,10 +1610,20 @@ fn parse_bundle_config_path(contents: &str) -> Option { } } +/// 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 whether its `path.system` is truthy. -fn config_path_system(contents: &str) -> Option { +/// tier), else what it says about the path. +fn config_path_tier(contents: &str) -> Option { let sets_path = [ "BUNDLE_PATH", "BUNDLE_PATH__SYSTEM", @@ -1603,9 +1631,10 @@ fn config_path_system(contents: &str) -> Option { ] .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)) + 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)), }) } @@ -3512,6 +3541,158 @@ mod tests { assert_eq!(discovery.stores, vec![store]); } + /// Stage a gem under `/.bundle/ruby/3.3.0/gems`, where Bundler + /// installs when it uses neither an explicit path nor the system gems + /// (`default_install_uses_path` on 2.x, `simulate_version 5` on 4.x). + async fn stage_dot_bundle_store(root: &Path) -> PathBuf { + tokio::fs::write(root.join("Gemfile"), b"gem \"colorize\"\n") + .await + .unwrap(); + let store = root.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 + } + + /// #967: with no tier setting `path`, `path.system` or + /// `disable_shared_gems`, Bundler's base path is `/.bundle` once + /// `default_install_uses_path` (2.x) or `simulate_version 5` (4.x) is + /// on. That store must be crawled. It is not the default root, so the + /// `gem env` fallback (default gems) stays on. + #[tokio::test] + async fn dot_bundle_base_path_is_crawled() { + for config in [ + None, + Some("---\nBUNDLE_SIMULATE_VERSION: \"5\"\n"), + Some("---\nBUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\"\n"), + // A falsy flag decides the tier without naming a path. + Some("---\nBUNDLE_PATH__SYSTEM: \"false\"\nBUNDLE_SIMULATE_VERSION: \"5\"\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let store = stage_dot_bundle_store(dir.path()).await; + if let Some(config) = config { + tokio::fs::write(dir.path().join(".bundle").join("config"), config) + .await + .unwrap(); + } + + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None) + .await; + assert_eq!(discovery.stores, vec![store], "{config:?}"); + assert!(!discovery.default_root_has_stores, "{config:?}"); + } + } + + /// #967, global variant: `bundle config set --global simulate_version + /// 5` with no path in any tier still installs into `/.bundle`. + #[tokio::test] + async fn dot_bundle_base_path_with_global_flag_is_crawled() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let store = stage_dot_bundle_store(&root).await; + let global = dir.path().join("global-config"); + tokio::fs::write(&global, "---\nBUNDLE_SIMULATE_VERSION: \"5\"\n") + .await + .unwrap(); + + let discovery = + RubyCrawler::discover_bundle_stores_with_env(&root, None, None, None, Some(&global)) + .await; + assert_eq!(discovery.stores, vec![store]); + assert!(!discovery.default_root_has_stores); + } + + /// #967 controls: Bundler never uses `/.bundle` when the tier + /// that decides the path names one (local, env or global) or sets a + /// truthy `path.system`, and the root only counts for a Ruby project. + #[tokio::test] + async fn dot_bundle_base_path_is_skipped_when_bundler_does_not_use_it() { + // A local path. + let dir = tempfile::tempdir().unwrap(); + stage_dot_bundle_store(dir.path()).await; + for config in [ + "---\nBUNDLE_PATH: \"vendor/bundle\"\n", + "---\nBUNDLE_PATH: \"\"\n", + "---\nBUNDLE_PATH__SYSTEM: \"true\"\n", + ] { + tokio::fs::write(dir.path().join(".bundle").join("config"), config) + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None) + .await; + assert!( + discovery.stores.is_empty(), + "{config}: {:?}", + discovery.stores + ); + } + tokio::fs::remove_file(dir.path().join(".bundle").join("config")) + .await + .unwrap(); + + // An env `BUNDLE_PATH`, even an empty one. + let elsewhere = dir.path().join("elsewhere"); + for env in [elsewhere.as_os_str(), OsStr::new("")] { + let discovery = RubyCrawler::discover_bundle_stores_with_env( + dir.path(), + Some(env), + None, + None, + None, + ) + .await; + assert!( + discovery.stores.is_empty(), + "{env:?}: {:?}", + discovery.stores + ); + } + + // An env `path.system`. + 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); + + // A global path or `path.system`. + let global = dir.path().join("global-config"); + for config in [ + "---\nBUNDLE_PATH: \"/opt/bundle\"\n", + "---\nBUNDLE_PATH__SYSTEM: \"true\"\n", + ] { + tokio::fs::write(&global, config).await.unwrap(); + let discovery = RubyCrawler::discover_bundle_stores_with_env( + dir.path(), + None, + None, + None, + Some(&global), + ) + .await; + assert!( + discovery.stores.is_empty(), + "{config}: {:?}", + discovery.stores + ); + } + + // Not a Ruby project. + tokio::fs::remove_file(dir.path().join("Gemfile")) + .await + .unwrap(); + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None).await; + assert!(discovery.stores.is_empty(), "{:?}", discovery.stores); + } + /// Bundler's boolean coercion (`Settings#to_bool`). #[test] fn bundler_truthy_matches_bundler_to_bool() { diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index fa8bc1196..8a30db1ca 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -860,6 +860,117 @@ async fn path_system_crawls_gem_env_home_not_leftover_vendor_bundle() { } } +/// #967: under `simulate_version 5` (Bundler 4.x) or +/// `default_install_uses_path` (2.x), with no path in any tier, `bundle +/// install` puts the project's gems in `/.bundle/ruby//gems` +/// and `bundle exec` loads them from there. The ambient discovery must +/// crawl that copy, and keep the `gem env` home (default gems) after it. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn dot_bundle_base_path_crawls_the_loaded_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + let dot_bundle = root.join(".bundle/ruby/3.3.0/gems"); + let loaded_copy = stage_gem(&dot_bundle, "colorize", "0.8.1").await; + std::fs::write(root.join("Gemfile"), "gem 'colorize', '0.8.1'\n").unwrap(); + + let gemdir = tmp.path().join("system-gem-home"); + let system_gems = gemdir.join("gems"); + let system_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", + "BUNDLE_SIMULATE_VERSION", + "BUNDLE_DEFAULT_INSTALL_USES_PATH", + "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 [ + ( + "local simulate_version", + Some("---\nBUNDLE_SIMULATE_VERSION: \"5\"\n"), + None, + ), + ( + "local default_install_uses_path", + Some("---\nBUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\"\n"), + None, + ), + ( + "env simulate_version", + None, + Some(("BUNDLE_SIMULATE_VERSION", "5")), + ), + ( + "env default_install_uses_path", + None, + Some(("BUNDLE_DEFAULT_INSTALL_USES_PATH", "true")), + ), + ] { + match config { + Some(config) => std::fs::write(root.join(".bundle/config"), config).unwrap(), + None => { + let _ = std::fs::remove_file(root.join(".bundle/config")); + } + } + if let Some((key, value)) = env { + std::env::set_var(key, value); + } + let paths = with_path(&bin, || async { crawler.get_gem_paths(&options).await }) + .await + .unwrap(); + if let Some((key, _)) = env { + std::env::remove_var(key); + } + 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 { + assert_eq!( + paths, + vec![dot_bundle.clone(), system_gems.clone()], + "{label}" + ); + assert_eq!( + found, + vec![loaded_copy.clone(), system_copy.clone()], + "{label}" + ); + } +} + // ── global gem discovery ─────────────────────────────────────── #[tokio::test] From 25e3ca642bd28560c6d34a0577670d8af07858a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:40:31 +0000 Subject: [PATCH 7/7] Document the .bundle gem install root Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 208675691..ddfbe553b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -489,7 +489,10 @@ the model is **not uniform** today: config file's `BUNDLE_PATH:` (`$BUNDLE_APP_CONFIG/config`, else `/.bundle/config` — what `bundle config set --local path` records), then the **`BUNDLE_PATH` environment variable**, then the **standalone `/bundle`** tree when it holds `bundle/bundler/setup.rb` (what `bundle install - --standalone` writes and the app loads; bundler 4 records no config for it), then the + --standalone` writes and the app loads; bundler 4 records no config for it), then **`/.bundle`** + when no Bundler tier sets a path or a truthy `path.system` (Bundler's base path under + `default_install_uses_path` on 2.x and `simulate_version 5` on 4.x, and the default from Bundler 5 + on; it keeps the `gem env` fallback on, like the explicit roots, #967), then the default `/vendor/bundle` — each in both store layouts bundler produces (scoped `///gems/` and flat `/gems/`). The env variable is the user's own machine state, so it is honored verbatim (it may point outside `--cwd`; a leading `~` expands against home);