diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7eddb127c..d01932a7a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -535,7 +535,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); 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 61db2695f..38de8fce0 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 @@ -1637,10 +1655,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", @@ -1648,9 +1676,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)), }) } @@ -3682,6 +3711,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..df43a6541 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(std::env::var_os).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]