From 4d700e8d482a84a9ee78cf95948717b7c648240f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:24:39 +0000 Subject: [PATCH 1/5] Start fix for #952 Assisted-by: Claude Code:claude-opus-5-5 From e086d49c3d492065eaa51d213251e6bde54c8849 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:35:22 +0000 Subject: [PATCH 2/5] Fix gem agent crawl under a moved BUNDLE_GEMFILE With BUNDLE_GEMFILE naming a gemfile in another directory (the Appraisal layout, gemfiles/rails_7.gemfile), Bundler's root moves to that directory: it reads gemfiles/.bundle/config and installs a relative bundle path under gemfiles/. Agent-mode discovery still probed the project root, found no store, and apply patched the system gem home while vex and apply --check attested a copy Bundler never loads. Discovery now resolves Bundler's root from the effective BUNDLE_GEMFILE (env, app config or global config) and probes the app config, BUNDLE_PATH, standalone and default stores under it. A value from the committed app config only moves the root inside the project, since the stores found there become apply write targets. Fixes #952 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 165 +++++++++++++++++- .../tests/crawler_ruby_e2e.rs | 141 +++++++++++++++ 2 files changed, 299 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index d0edbb072..5f932ceed 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -45,6 +45,7 @@ impl RubyCrawler { ) -> Result, std::io::Error> { Ok(Self::gem_paths_and_discovery( options, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), @@ -71,6 +72,7 @@ impl RubyCrawler { ) -> Result, std::io::Error> { Ok(Self::gem_paths_and_discovery( options, + None, bundle_path_env, None, app_config_env, @@ -88,6 +90,7 @@ impl RubyCrawler { /// (`skipped_config_path`) does not probe the roots a second time. async fn gem_paths_and_discovery( options: &CrawlerOptions, + gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, @@ -105,6 +108,7 @@ impl RubyCrawler { // Local mode: probe the Bundler install roots first. let discovery = Self::discover_bundle_stores_impl( &options.cwd, + gemfile_env, bundle_path_env, path_system_env, app_config_env, @@ -132,7 +136,9 @@ impl RubyCrawler { // `Gem.path`, and a gem the project loads routinely lives in a // non-`gemdir` home (rvm keeps shared gems in the `@global` gemset; // `--user-install` puts them under `~/.gem`/`$XDG_DATA_HOME`). - if !discovery.default_root_has_stores && Self::has_bundler_manifest(&options.cwd).await { + if !discovery.default_root_has_stores + && (discovery.bundler_root.is_some() || Self::has_bundler_manifest(&options.cwd).await) + { let mut seen: HashSet = paths.iter().cloned().collect(); for gems_dir in Self::gem_env_gems_dirs().await { if seen.insert(gems_dir.clone()) { @@ -162,6 +168,7 @@ impl RubyCrawler { let (gem_paths, discovery) = Self::gem_paths_and_discovery( options, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), @@ -388,6 +395,7 @@ impl RubyCrawler { /// shadows nothing. (Callers already drop `global_config` then.) async fn discover_bundle_stores_impl( cwd: &Path, + gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, @@ -396,6 +404,25 @@ impl RubyCrawler { ignore_config: bool, ) -> BundleStoreDiscovery { let home = home_env.map(Path::new); + // Every root below is relative to `Bundler.root`, which a + // `BUNDLE_GEMFILE` naming a gemfile in another directory moves + // there (#952): the app config, a relative `BUNDLE_PATH`, the + // standalone and default stores all follow it. A configured + // gemfile that exists also makes this a Ruby project for the + // gates below, whatever the project root itself holds. + let bundler_root = bundler_moved_root( + cwd, + BundlerEnv { + gemfile: gemfile_env, + lockfile: None, + app_config: app_config_env, + ignore_config, + global_config, + }, + ) + .await; + let has_manifest = bundler_root.is_some() || Self::has_bundler_manifest(cwd).await; + let cwd = bundler_root.as_deref().unwrap_or(cwd); let default_root = cwd.join("vendor").join("bundle"); let default_root = normalize_lexically(&default_root).unwrap_or(default_root); @@ -407,7 +434,7 @@ 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 path_tier = if Self::has_bundler_manifest(cwd).await { + let path_tier = if has_manifest { Self::bundler_path_tier( cwd, bundle_path_env, @@ -425,7 +452,7 @@ impl RubyCrawler { let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; let mut skipped_config_root = None; - if Self::has_bundler_manifest(cwd).await { + if has_manifest { if let Some(value) = Self::app_config_bundle_path(cwd, app_config_env, ignore_config, home).await { @@ -522,6 +549,7 @@ impl RubyCrawler { default_root_has_stores, skipped_config_path, skipped_config_root, + bundler_root, } } @@ -534,6 +562,7 @@ impl RubyCrawler { pub async fn discover_bundle_stores(cwd: &Path) -> BundleStoreDiscovery { Self::discover_bundle_stores_impl( cwd, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), ambient_path_system_env().as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), @@ -556,6 +585,7 @@ impl RubyCrawler { ) -> BundleStoreDiscovery { Self::discover_bundle_stores_impl( cwd, + None, bundle_path_env, None, app_config_env, @@ -583,6 +613,7 @@ impl RubyCrawler { ); Self::discover_bundle_stores_impl( cwd, + None, bundle_path_env, path_system_env, None, @@ -609,6 +640,7 @@ impl RubyCrawler { pub async fn verification_only_gem_paths(&self, options: &CrawlerOptions) -> Vec { self.verification_only_gem_paths_with_env( options, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_PATH").as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ambient_home().as_deref(), @@ -624,6 +656,7 @@ impl RubyCrawler { pub async fn verification_only_gem_paths_with_env( &self, options: &CrawlerOptions, + gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, @@ -639,6 +672,7 @@ impl RubyCrawler { // config root this reads, so the env flag is not needed here. let discovery = Self::discover_bundle_stores_impl( &options.cwd, + gemfile_env, bundle_path_env, None, app_config_env, @@ -725,13 +759,15 @@ impl RubyCrawler { /// ([`Self::bundler_install_homes`]) and `vex` /// ([`Self::bundler_unused_system_gem_homes`]) use. async fn bundler_uses_system_gems(cwd: &Path, discovery: &BundleStoreDiscovery) -> bool { - if discovery.default_root_has_stores || !Self::has_bundler_manifest(cwd).await { + if discovery.default_root_has_stores + || (discovery.bundler_root.is_none() && !Self::has_bundler_manifest(cwd).await) + { return false; } let ignore_config = bundler_ignores_config(); !bundler_sets_explicit_path(BundlerPathTiers { local: read_app_config( - cwd, + discovery.bundler_root.as_deref().unwrap_or(cwd), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), ignore_config, ) @@ -1416,6 +1452,10 @@ pub struct BundleStoreDiscovery { /// normalized). Never a write target: only /// [`RubyCrawler::verification_only_gem_paths`] probes it, read-only. pub skipped_config_root: Option, + /// `Bundler.root` when the configured `BUNDLE_GEMFILE` moves it out of + /// the project root ([`bundler_moved_root`]); every root above was + /// probed under it. `None` when Bundler's root is the project root. + pub bundler_root: Option, } /// The stable warning `(code, detail)` for a config-sourced `BUNDLE_PATH` @@ -1810,6 +1850,45 @@ pub async fn bundler_loaded_manifest_with_env( ) } +/// `Bundler.root` when the configured `BUNDLE_GEMFILE` moves it out of +/// `root`, else `None`. Bundler's root is the directory of the gemfile it +/// loads (`SharedHelpers#root`), so a gemfile in another directory (the +/// Appraisal layout, `BUNDLE_GEMFILE=gemfiles/rails_7.gemfile`) makes +/// Bundler read that directory's `.bundle/config` and resolve a relative +/// bundle path against it (#952). Only an existing file moves the root: +/// Bundler refuses a missing gemfile, so nothing installs from there. +/// +/// The env and global config values are the user's own machine state; a +/// value from the app config is project input, so it only moves the root +/// to a directory inside `root`: the moved root's stores become apply +/// write targets. +pub(crate) async fn bundler_moved_root(root: &Path, env: BundlerEnv<'_>) -> Option { + use crate::formats::gem::manifest::{GemfileSetting, LoadedManifest}; + let LoadedManifest::Unsupported { value, by } = + bundler_loaded_manifest_with_env(root, env).await + else { + return None; + }; + let absolute = |p: &Path| { + std::path::absolute(p) + .ok() + .and_then(|p| normalize_lexically(&p)) + }; + let gemfile = absolute(&root.join(&value))?; + let is_file = tokio::fs::metadata(&gemfile) + .await + .is_ok_and(|m| m.is_file()); + let dir = gemfile.parent().filter(|_| is_file)?; + let here = absolute(root)?; + if dir == here || same_file::is_same_file(dir, &here).unwrap_or(false) { + return None; + } + if by == GemfileSetting::AppConfig && !dir.starts_with(&here) { + return None; + } + Some(dir.to_path_buf()) +} + /// The Bundler mirror setting that captures one of the patch-registry /// `sources` (see [`crate::formats::gem::mirror`]), described for the /// refusal's detail. Reads the app config (honoring `BUNDLE_APP_CONFIG` @@ -2312,6 +2391,75 @@ async fn bundle_config_dir_reading( mod tests { use super::*; + /// #952: only an existing gemfile in another directory moves + /// `Bundler.root`; one in the project root, a missing file, or an app + /// config value (project input) naming a directory outside the + /// project leaves discovery at the project root. + #[tokio::test] + async fn moved_root_follows_an_existing_gemfile_in_another_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + let outside = tmp.path().join("elsewhere"); + for dir in [root.join("gemfiles"), outside.clone()] { + std::fs::create_dir_all(dir).unwrap(); + } + for file in [ + root.join("Gemfile"), + root.join("Gemfile.next"), + root.join("gemfiles/alt.gemfile"), + outside.join("Gemfile"), + ] { + std::fs::write(file, "gem \"foo\"\n").unwrap(); + } + let moved = |gemfile: Option<&'static str>| { + let root = root.clone(); + async move { + bundler_moved_root( + &root, + BundlerEnv { + gemfile: gemfile.map(OsStr::new), + ..BundlerEnv::default() + }, + ) + .await + } + }; + let gemfiles = std::path::absolute(root.join("gemfiles")).unwrap(); + assert_eq!( + moved(Some("gemfiles/alt.gemfile")).await, + Some(gemfiles.clone()) + ); + assert_eq!( + moved(Some("./gemfiles/../gemfiles/alt.gemfile")).await, + Some(gemfiles.clone()) + ); + assert_eq!(moved(None).await, None); + assert_eq!(moved(Some("Gemfile")).await, None); + assert_eq!(moved(Some("Gemfile.next")).await, None); + assert_eq!(moved(Some("gemfiles/missing.gemfile")).await, None); + assert_eq!(moved(Some("gemfiles")).await, None, "a directory"); + // The user's own environment may point anywhere. + assert_eq!( + moved(Some("../elsewhere/Gemfile")).await, + Some(std::path::absolute(&outside).unwrap()) + ); + + // A committed app config may move the root inside the project + // only: the moved root's stores become apply write targets. + std::fs::create_dir_all(root.join(".bundle")).unwrap(); + for (value, expected) in [ + ("gemfiles/alt.gemfile", Some(gemfiles.clone())), + ("../elsewhere/Gemfile", None), + ] { + std::fs::write( + root.join(".bundle/config"), + format!("---\nBUNDLE_GEMFILE: \"{value}\"\n"), + ) + .unwrap(); + assert_eq!(moved(None).await, expected, "{value}"); + } + } + #[tokio::test] async fn loaded_manifest_reads_the_app_config_file() { let dir = tempfile::tempdir().unwrap(); @@ -4168,7 +4316,7 @@ mod tests { discovery.stores ); let verify = crawler - .verification_only_gem_paths_with_env(&options, None, None, None, None, false) + .verification_only_gem_paths_with_env(&options, None, None, None, None, None, false) .await; let gems = normalize_lexically(&gems).unwrap(); assert_eq!(verify, vec![gems.clone()]); @@ -4190,6 +4338,7 @@ mod tests { &options, None, None, + None, Some(outside.path().as_os_str()), None, false, @@ -4204,6 +4353,7 @@ mod tests { &options, None, None, + None, Some(outside.path().as_os_str()), None, true, @@ -4216,6 +4366,7 @@ mod tests { let verify = crawler .verification_only_gem_paths_with_env( &options, + None, Some(outside.path().as_os_str()), None, Some(outside.path().as_os_str()), @@ -4231,7 +4382,7 @@ mod tests { ..options }; assert!(crawler - .verification_only_gem_paths_with_env(&global, None, None, None, None, false) + .verification_only_gem_paths_with_env(&global, None, None, None, None, None, false) .await .is_empty()); } diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index df43a6541..01be397df 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -1195,3 +1195,144 @@ async fn global_gem_discovery_via_rbenv_layout() { "~/.rbenv/versions/*/lib/ruby/gems/*/gems must be discovered; got {paths:?}" ); } + +/// #952: `BUNDLE_GEMFILE=gemfiles/alt.gemfile` (the Appraisal / CI-matrix +/// layout) moves `Bundler.root` to `gemfiles/`. Bundler then reads +/// `gemfiles/.bundle/config` and resolves a relative bundle path, from +/// that config or from the env `BUNDLE_PATH`, against `gemfiles/`, so +/// `bundle install` puts the loaded copy in `gemfiles/vendor/bundle`. +/// The same holds when the project's own `.bundle/config` names the +/// gemfile. Discovery must crawl that copy as the project's store (so +/// apply patches it), and `vex` must treat the `gem env` home as one +/// Bundler never loads, rather than attest its patched copy. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn moved_bundle_gemfile_root_crawls_the_loaded_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + std::fs::create_dir_all(root.join("gemfiles")).unwrap(); + let gemfile = "source \"https://rubygems.org\"\ngem \"colorize\", \"0.8.1\"\n"; + std::fs::write(root.join("Gemfile"), gemfile).unwrap(); + std::fs::write(root.join("gemfiles/alt.gemfile"), gemfile).unwrap(); + let loaded_store = root.join("gemfiles/vendor/bundle/ruby/3.3.0/gems"); + let loaded_copy = stage_gem(&loaded_store, "colorize", "0.8.1").await; + + let gemdir = tmp.path().join("system-gem-home"); + let system_gems = gemdir.join("gems"); + 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_GEMFILE", + "BUNDLE_LOCKFILE", + "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 alt = root.join("gemfiles/alt.gemfile"); + let mut cases = Vec::new(); + // (label, project .bundle/config, gemfiles/.bundle/config, + // env BUNDLE_GEMFILE, env BUNDLE_PATH) + let variants: [( + &str, + Option, + Option<&str>, + Option<&Path>, + Option<&str>, + ); 4] = [ + ( + "env gemfile, root config path", + None, + Some("---\nBUNDLE_PATH: \"vendor/bundle\"\n"), + Some(alt.as_path()), + None, + ), + ( + "env gemfile, env path", + None, + None, + Some(Path::new("gemfiles/alt.gemfile")), + Some("vendor/bundle"), + ), + ( + "app config gemfile, root config path", + Some("---\nBUNDLE_GEMFILE: \"gemfiles/alt.gemfile\"\n".to_string()), + Some("---\nBUNDLE_PATH: \"vendor/bundle\"\n"), + None, + None, + ), + ( + "app config gemfile, env path", + Some(format!("---\nBUNDLE_GEMFILE: \"{}\"\n", alt.display())), + None, + None, + Some("vendor/bundle"), + ), + ]; + for (label, app_config, root_config, gemfile_env, path_env) in variants { + for (dir, text) in [ + (root.join(".bundle"), app_config.as_deref()), + (root.join("gemfiles/.bundle"), root_config), + ] { + let _ = std::fs::remove_dir_all(&dir); + if let Some(text) = text { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("config"), text).unwrap(); + } + } + if let Some(value) = gemfile_env { + std::env::set_var("BUNDLE_GEMFILE", value); + } + if let Some(value) = path_env { + std::env::set_var("BUNDLE_PATH", value); + } + let stores = RubyCrawler::discover_bundle_stores(&root).await.stores; + let paths = with_path(&bin, || async { crawler.get_gem_paths(&options).await }) + .await + .unwrap(); + let unused = with_path(&bin, || async { + crawler.bundler_unused_system_gem_homes(&options).await + }) + .await; + std::env::remove_var("BUNDLE_GEMFILE"); + std::env::remove_var("BUNDLE_PATH"); + 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, stores, found, unused)); + } + // 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, stores, found, unused) in cases { + assert_eq!(stores, vec![loaded_store.clone()], "{label}"); + // The loaded copy is found first, as the primary store's copy. + assert_eq!(found.first(), Some(&loaded_copy), "{label}: {found:?}"); + assert_eq!(unused, vec![system_gems.clone()], "{label}"); + } +} From 5ae2b4203a9bc011f8f95dcd18b5453da1145ab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:44:58 +0000 Subject: [PATCH 3/5] Allow long discovery argument lists for clippy Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/ruby_crawler.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 5f932ceed..7d4a0b382 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -88,6 +88,7 @@ impl RubyCrawler { /// from (`None` in global / `--global-prefix` mode, which never probes /// the Bundler roots), so a caller that needs the discovery's advisories /// (`skipped_config_path`) does not probe the roots a second time. + #[allow(clippy::too_many_arguments)] async fn gem_paths_and_discovery( options: &CrawlerOptions, gemfile_env: Option<&OsStr>, @@ -393,6 +394,7 @@ impl RubyCrawler { /// [`bundler_ignores_config`]) bundler reads no config file, so the app /// config's `BUNDLE_PATH` is neither a root nor a refused root, and it /// shadows nothing. (Callers already drop `global_config` then.) + #[allow(clippy::too_many_arguments)] async fn discover_bundle_stores_impl( cwd: &Path, gemfile_env: Option<&OsStr>, @@ -653,6 +655,7 @@ impl RubyCrawler { /// [`Self::verification_only_gem_paths`] with the environment passed /// explicitly (the hermetic test seam, like /// [`Self::get_gem_paths_with_env`]). + #[allow(clippy::too_many_arguments)] pub async fn verification_only_gem_paths_with_env( &self, options: &CrawlerOptions, From 2195f7dcdf022a00c2e5693b6de15346eaa4916b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:07:46 +0000 Subject: [PATCH 4/5] Keep moved Bundler root inside safe bounds Review follow-ups for the BUNDLE_GEMFILE root fix: - a BUNDLE_GEMFILE export no longer makes a directory with no Gemfile a Ruby project, so a machine-wide export can't pull another project's gems into a non-Ruby scan; - a recorded path like ../vendor/bundle under gemfiles/ is checked against the project, not against gemfiles/, so it isn't refused; - a committed symlink can't carry the root outside the project; - a ~ in the gemfile value expands like it does in Bundler; - the hosted stale-install advice tags stores against the moved root. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 102 +++++++++++++----- .../tests/crawler_ruby_e2e.rs | 38 +++++++ 2 files changed, 111 insertions(+), 29 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 7d4a0b382..2508a2242 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -137,9 +137,7 @@ impl RubyCrawler { // `Gem.path`, and a gem the project loads routinely lives in a // non-`gemdir` home (rvm keeps shared gems in the `@global` gemset; // `--user-install` puts them under `~/.gem`/`$XDG_DATA_HOME`). - if !discovery.default_root_has_stores - && (discovery.bundler_root.is_some() || Self::has_bundler_manifest(&options.cwd).await) - { + if !discovery.default_root_has_stores && Self::has_bundler_manifest(&options.cwd).await { let mut seen: HashSet = paths.iter().cloned().collect(); for gems_dir in Self::gem_env_gems_dirs().await { if seen.insert(gems_dir.clone()) { @@ -409,21 +407,37 @@ impl RubyCrawler { // Every root below is relative to `Bundler.root`, which a // `BUNDLE_GEMFILE` naming a gemfile in another directory moves // there (#952): the app config, a relative `BUNDLE_PATH`, the - // standalone and default stores all follow it. A configured - // gemfile that exists also makes this a Ruby project for the - // gates below, whatever the project root itself holds. - let bundler_root = bundler_moved_root( - cwd, - BundlerEnv { - gemfile: gemfile_env, - lockfile: None, - app_config: app_config_env, - ignore_config, - global_config, - }, - ) - .await; - let has_manifest = bundler_root.is_some() || Self::has_bundler_manifest(cwd).await; + // standalone and default stores all follow it. Only for a Ruby + // project, like every explicit root: a machine-wide + // `BUNDLE_GEMFILE` export must not pull another project's stores + // into a non-Ruby scan. + let has_manifest = Self::has_bundler_manifest(cwd).await; + let bundler_root = if has_manifest { + bundler_moved_root( + cwd, + BundlerEnv { + gemfile: gemfile_env, + lockfile: None, + app_config: app_config_env, + ignore_config, + global_config, + }, + home, + ) + .await + } else { + None + }; + // The app config's `BUNDLE_PATH` stays contained in the project + // even when it resolves against a moved root inside it + // (`gemfiles/` recording `../vendor/bundle`). A root the user's + // own environment moved outside the project bounds it instead. + let containment = match &bundler_root { + Some(root) if !std::path::absolute(cwd).is_ok_and(|p| root.starts_with(p)) => { + root.clone() + } + _ => cwd.to_path_buf(), + }; let cwd = bundler_root.as_deref().unwrap_or(cwd); let default_root = cwd.join("vendor").join("bundle"); let default_root = normalize_lexically(&default_root).unwrap_or(default_root); @@ -458,7 +472,13 @@ impl RubyCrawler { if let Some(value) = Self::app_config_bundle_path(cwd, app_config_env, ignore_config, home).await { - match resolve_config_bundle_path(cwd, &value, home) { + let contained = match &bundler_root { + None => resolve_config_bundle_path(cwd, &value, home), + Some(_) => resolve_bundle_path(cwd, Path::new(&value), home) + .to_str() + .and_then(|moved| resolve_config_bundle_path(&containment, moved, home)), + }; + match contained { Some(root) => roots.push(root), // Refused by the containment guard. Recorded — not // printed: the crawler has no --silent/--json context, @@ -724,7 +744,7 @@ impl RubyCrawler { Vec::new() }; bundler_gem_homes_from( - &options.cwd, + discovery.bundler_root.as_deref().unwrap_or(&options.cwd), &discovery.stores, &verification, &system_homes, @@ -762,9 +782,7 @@ impl RubyCrawler { /// ([`Self::bundler_install_homes`]) and `vex` /// ([`Self::bundler_unused_system_gem_homes`]) use. async fn bundler_uses_system_gems(cwd: &Path, discovery: &BundleStoreDiscovery) -> bool { - if discovery.default_root_has_stores - || (discovery.bundler_root.is_none() && !Self::has_bundler_manifest(cwd).await) - { + if discovery.default_root_has_stores || !Self::has_bundler_manifest(cwd).await { return false; } let ignore_config = bundler_ignores_config(); @@ -1861,11 +1879,17 @@ pub async fn bundler_loaded_manifest_with_env( /// bundle path against it (#952). Only an existing file moves the root: /// Bundler refuses a missing gemfile, so nothing installs from there. /// +/// A leading `~` expands against `home`, as in bundler's `expand_path`. +/// /// The env and global config values are the user's own machine state; a /// value from the app config is project input, so it only moves the root -/// to a directory inside `root`: the moved root's stores become apply -/// write targets. -pub(crate) async fn bundler_moved_root(root: &Path, env: BundlerEnv<'_>) -> Option { +/// to a directory inside `root`, through symlinks too: the moved root's +/// stores become apply write targets. +pub(crate) async fn bundler_moved_root( + root: &Path, + env: BundlerEnv<'_>, + home: Option<&Path>, +) -> Option { use crate::formats::gem::manifest::{GemfileSetting, LoadedManifest}; let LoadedManifest::Unsupported { value, by } = bundler_loaded_manifest_with_env(root, env).await @@ -1877,7 +1901,7 @@ pub(crate) async fn bundler_moved_root(root: &Path, env: BundlerEnv<'_>) -> Opti .ok() .and_then(|p| normalize_lexically(&p)) }; - let gemfile = absolute(&root.join(&value))?; + let gemfile = absolute(&root.join(expand_tilde(Path::new(&value), home)))?; let is_file = tokio::fs::metadata(&gemfile) .await .is_ok_and(|m| m.is_file()); @@ -1886,8 +1910,15 @@ pub(crate) async fn bundler_moved_root(root: &Path, env: BundlerEnv<'_>) -> Opti if dir == here || same_file::is_same_file(dir, &here).unwrap_or(false) { return None; } - if by == GemfileSetting::AppConfig && !dir.starts_with(&here) { - return None; + if by == GemfileSetting::AppConfig { + let canonical = |p: &Path| std::fs::canonicalize(p).ok(); + let contained = match (canonical(dir), canonical(&here)) { + (Some(real_dir), Some(real_here)) => real_dir.starts_with(real_here), + _ => false, + }; + if !dir.starts_with(&here) || !contained { + return None; + } } Some(dir.to_path_buf()) } @@ -2414,8 +2445,11 @@ mod tests { ] { std::fs::write(file, "gem \"foo\"\n").unwrap(); } + // `~` is the temp dir, so `~/app/...` names the project's files. + let home = tmp.path().to_path_buf(); let moved = |gemfile: Option<&'static str>| { let root = root.clone(); + let home = home.clone(); async move { bundler_moved_root( &root, @@ -2423,6 +2457,7 @@ mod tests { gemfile: gemfile.map(OsStr::new), ..BundlerEnv::default() }, + Some(&home), ) .await } @@ -2441,6 +2476,10 @@ mod tests { assert_eq!(moved(Some("Gemfile.next")).await, None); assert_eq!(moved(Some("gemfiles/missing.gemfile")).await, None); assert_eq!(moved(Some("gemfiles")).await, None, "a directory"); + assert_eq!( + moved(Some("~/app/gemfiles/alt.gemfile")).await, + Some(gemfiles.clone()) + ); // The user's own environment may point anywhere. assert_eq!( moved(Some("../elsewhere/Gemfile")).await, @@ -2450,9 +2489,14 @@ mod tests { // A committed app config may move the root inside the project // only: the moved root's stores become apply write targets. std::fs::create_dir_all(root.join(".bundle")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("linked")).unwrap(); for (value, expected) in [ ("gemfiles/alt.gemfile", Some(gemfiles.clone())), ("../elsewhere/Gemfile", None), + // A committed symlink must not carry the root out either. + #[cfg(unix)] + ("linked/Gemfile", None), ] { std::fs::write( root.join(".bundle/config"), diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 01be397df..a4bdc5ccd 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -1329,6 +1329,44 @@ async fn moved_bundle_gemfile_root_crawls_the_loaded_copy() { } } + 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 non_ruby = tmp.path().join("frontend"); + std::fs::create_dir_all(&non_ruby).unwrap(); + // `gemfiles/.bundle/config` recording `../vendor/bundle` installs back + // in the project's own `vendor/bundle`: still inside the project, so + // not refused by the containment guard. + let climbing_store = root.join("vendor/bundle/ruby/3.3.0/gems"); + let climbing_copy = stage_gem(&climbing_store, "colorize", "0.8.1").await; + std::fs::remove_dir_all(root.join(".bundle")).ok(); + std::fs::remove_dir_all(root.join("gemfiles/vendor")).unwrap(); + std::fs::create_dir_all(root.join("gemfiles/.bundle")).unwrap(); + std::fs::write( + root.join("gemfiles/.bundle/config"), + "---\nBUNDLE_PATH: \"../vendor/bundle\"\n", + ) + .unwrap(); + std::env::set_var("BUNDLE_GEMFILE", &alt); + let climbing = RubyCrawler::discover_bundle_stores(&root).await; + // A machine-wide BUNDLE_GEMFILE export does not make a directory with + // no Gemfile a Ruby project. + let foreign = RubyCrawler::discover_bundle_stores(&non_ruby).await; + std::env::remove_var("BUNDLE_GEMFILE"); + 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), + } + } + assert_eq!(climbing.stores, vec![climbing_store.clone()]); + assert_eq!(climbing.skipped_config_path, None); + assert!(climbing_copy.is_dir()); + assert_eq!(foreign.bundler_root, None); + assert!(foreign.stores.is_empty(), "{:?}", foreign.stores); + for (label, stores, found, unused) in cases { assert_eq!(stores, vec![loaded_store.clone()], "{label}"); // The loaded copy is found first, as the primary store's copy. From 8466f94796a9f163ae5d3ee002ac9d97da4a153d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:25:06 +0000 Subject: [PATCH 5/5] Fold .. in the cwd before the containment check A --cwd spelled with a .. segment (app/gemfiles/..) kept the .. after std::path::absolute, so the moved Bundler root never looked like it was inside the project and a recorded ../vendor/bundle was refused. Also keep the stale-install project-local tag on --cwd: hosted mode refuses a moved BUNDLE_GEMFILE, so that probe never sees one. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/ruby_crawler.rs | 9 +++++++-- crates/socket-patch-core/tests/crawler_ruby_e2e.rs | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 2508a2242..18d30314e 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -433,7 +433,12 @@ impl RubyCrawler { // (`gemfiles/` recording `../vendor/bundle`). A root the user's // own environment moved outside the project bounds it instead. let containment = match &bundler_root { - Some(root) if !std::path::absolute(cwd).is_ok_and(|p| root.starts_with(p)) => { + Some(root) + if !std::path::absolute(cwd) + .ok() + .and_then(|p| normalize_lexically(&p)) + .is_some_and(|p| root.starts_with(p)) => + { root.clone() } _ => cwd.to_path_buf(), @@ -744,7 +749,7 @@ impl RubyCrawler { Vec::new() }; bundler_gem_homes_from( - discovery.bundler_root.as_deref().unwrap_or(&options.cwd), + &options.cwd, &discovery.stores, &verification, &system_homes, diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index a4bdc5ccd..bf1c45f16 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -1351,6 +1351,8 @@ async fn moved_bundle_gemfile_root_crawls_the_loaded_copy() { .unwrap(); std::env::set_var("BUNDLE_GEMFILE", &alt); let climbing = RubyCrawler::discover_bundle_stores(&root).await; + // The same project spelled with a `..` (`--cwd app/gemfiles/..`). + let climbing_dotdot = RubyCrawler::discover_bundle_stores(&root.join("gemfiles/..")).await; // A machine-wide BUNDLE_GEMFILE export does not make a directory with // no Gemfile a Ruby project. let foreign = RubyCrawler::discover_bundle_stores(&non_ruby).await; @@ -1363,6 +1365,8 @@ async fn moved_bundle_gemfile_root_crawls_the_loaded_copy() { } assert_eq!(climbing.stores, vec![climbing_store.clone()]); assert_eq!(climbing.skipped_config_path, None); + assert_eq!(climbing_dotdot.stores, vec![climbing_store.clone()]); + assert_eq!(climbing_dotdot.skipped_config_path, None); assert!(climbing_copy.is_dir()); assert_eq!(foreign.bundler_root, None); assert!(foreign.stores.is_empty(), "{:?}", foreign.stores);