diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index d0edbb072..18d30314e 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, @@ -86,8 +88,10 @@ 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>, bundle_path_env: Option<&OsStr>, path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, @@ -105,6 +109,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, @@ -162,6 +167,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(), @@ -386,8 +392,10 @@ 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>, bundle_path_env: Option<&OsStr>, path_system_env: Option<&OsStr>, app_config_env: Option<&OsStr>, @@ -396,6 +404,46 @@ 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. 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) + .ok() + .and_then(|p| normalize_lexically(&p)) + .is_some_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); @@ -407,7 +455,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,11 +473,17 @@ 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 { - 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, @@ -522,6 +576,7 @@ impl RubyCrawler { default_root_has_stores, skipped_config_path, skipped_config_root, + bundler_root, } } @@ -534,6 +589,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 +612,7 @@ impl RubyCrawler { ) -> BundleStoreDiscovery { Self::discover_bundle_stores_impl( cwd, + None, bundle_path_env, None, app_config_env, @@ -583,6 +640,7 @@ impl RubyCrawler { ); Self::discover_bundle_stores_impl( cwd, + None, bundle_path_env, path_system_env, None, @@ -609,6 +667,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(), @@ -621,9 +680,11 @@ 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, + gemfile_env: Option<&OsStr>, bundle_path_env: Option<&OsStr>, app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, @@ -639,6 +700,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, @@ -731,7 +793,7 @@ impl RubyCrawler { 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 +1478,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 +1876,58 @@ 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. +/// +/// 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`, 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 + else { + return None; + }; + let absolute = |p: &Path| { + std::path::absolute(p) + .ok() + .and_then(|p| normalize_lexically(&p)) + }; + 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()); + 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 { + 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()) +} + /// 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 +2430,88 @@ 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(); + } + // `~` 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, + BundlerEnv { + gemfile: gemfile.map(OsStr::new), + ..BundlerEnv::default() + }, + Some(&home), + ) + .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"); + 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, + 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(); + #[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"), + 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 +4368,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 +4390,7 @@ mod tests { &options, None, None, + None, Some(outside.path().as_os_str()), None, false, @@ -4204,6 +4405,7 @@ mod tests { &options, None, None, + None, Some(outside.path().as_os_str()), None, true, @@ -4216,6 +4418,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 +4434,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..bf1c45f16 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -1195,3 +1195,186 @@ 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), + } + } + + 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; + // 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; + 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_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); + + 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}"); + } +}