diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d9895a2ce..ce55c27c6 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -487,6 +487,8 @@ the model is **not uniform** today: crawler probes the project's Bundler install roots in **bundler's own precedence order** — the app 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 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); @@ -496,7 +498,7 @@ the model is **not uniform** today: `gem_bundle_config_path_ignored` entry in the run-level `warnings[]` of `scan`/`apply` `--json` envelopes (detail names the config value and the env-`BUNDLE_PATH` remedy), and one stderr `Warning: …` line on the human path, gated on `!--silent` - (`--silent` = errors only). Explicit env/config roots only count when `--cwd` holds a Bundler + (`--silent` = errors only). 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 **coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] 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 826f79633..e67cee674 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 @@ -327,7 +327,8 @@ async fn gem_hosted_redirect_over_stale_install_warns_loudly() { ); assert_eq!(code, 0, "human re-scan must succeed:\n{stderr}"); assert!( - stderr.contains("Warning: ") && stderr.contains("was switched to its hosted patch, but a stale"), + stderr.contains("Warning: ") + && stderr.contains("was switched to its hosted patch, but a stale"), "human mode must print the stale-install warning on stderr:\n{stderr}" ); assert!( @@ -540,6 +541,82 @@ async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() { assert_eq!(env["status"], "error", "envelope: {env}"); } +/// #796: Bundler 4's `bundle install --standalone` installs into +/// `/bundle/ruby//` and writes `bundle/bundler/setup.rb`, but no +/// `.bundle/config` (bundler 4 no longer remembers CLI flags). That tree is +/// what the app loads, so a stale UNPATCHED copy there must trip the guard +/// with the project-local remedy, and the same run's `--vex` must not +/// attest the purl. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_stale_bundler4_standalone_install_warns_and_is_not_attested() { + 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::create_dir_all(proj.join("bundle").join("bundler")).unwrap(); + std::fs::write( + proj.join("bundle").join("bundler").join("setup.rb"), + "require 'rbconfig'\n", + ) + .unwrap(); + assert!(!proj.join(".bundle").exists(), "bundler 4 writes no config"); + + 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, "envelope: {env}"); + let details = stale_warnings(&env); + assert_eq!( + details.len(), + 1, + "the stale standalone copy must trip the guard: {env}\nstderr:\n{stderr}" + ); + assert!( + details[0].contains(&gem_dir.display().to_string()) + && !details[0].contains("shared gem home"), + "the warning must name the standalone copy as project-local: {}", + details[0] + ); + if let Ok(doc) = std::fs::read_to_string(&vex_path) { + assert!( + !doc.contains(PURL), + "a stale standalone copy must never be attested:\n{doc}" + ); + } + assert_ne!( + code, 0, + "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 4f28086d9..cde93294d 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; use crate::utils::fs::{ - entry_is_dir, home_dir, is_dir, list_dir_entries, normalize_lexically, run_blocking, + entry_is_dir, home_dir, is_dir, is_file, list_dir_entries, normalize_lexically, run_blocking, }; use crate::utils::process::{CommandRunner, SystemCommandRunner}; @@ -314,11 +314,18 @@ impl RubyCrawler { /// value resolves against the project root, matching /// `Bundler.bundle_path`; a leading `~` expands against home). /// Trusted as-is: it is the user's own environment. - /// 3. the `BUNDLE_PATH:` entry of the global config file + /// 3. `/bundle` when it holds `bundler/setup.rb` — the tree + /// `bundle install --standalone` writes and the app loads through + /// that script. Bundler 2 also recorded it as `BUNDLE_PATH: + /// "bundle"` (root 1), but bundler 4 no longer remembers CLI flags + /// and writes no config at all, so the marker is the only trace + /// (#796). It counts as an explicit root, like the recorded path it + /// replaces: the `gem env` fallback stays on for the default gems. + /// 4. the `BUNDLE_PATH:` entry of the global config file /// ([`bundler_global_config_file`], what `bundle config set --global /// path ` records) — resolved like the env var, and trusted /// like it: it is the user's own machine state, not project input. - /// 4. `/vendor/bundle` — the default deployment/`--path` location. + /// 5. `/vendor/bundle` — the default deployment/`--path` location. /// /// The explicit roots can point anywhere (a machine-wide `BUNDLE_PATH` /// export must not pull another project's gem store into a non-Ruby @@ -359,6 +366,10 @@ impl RubyCrawler { if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) { roots.push(resolve_bundle_path(cwd, Path::new(v), home)); } + let standalone_root = cwd.join("bundle"); + if is_file(&standalone_root.join("bundler").join("setup.rb")).await { + roots.push(normalize_lexically(&standalone_root).unwrap_or(standalone_root)); + } // The global config's path (`bundle config set --global path`) // is the user's own machine state, so it is trusted like the // env var: no containment guard. Bundler's `Settings#path` @@ -851,7 +862,8 @@ fn verify_gem_at_path_sync(path: &Path) -> bool { /// JSON envelope is the CLI's job, where `--silent`/`--json` gating lives. pub struct BundleStoreDiscovery { /// The discovered installed-gem `gems/` stores, in root-precedence - /// order (local config > env > default `vendor/bundle`). Copies found + /// order (local config > env > standalone `bundle/` > default + /// `vendor/bundle`). Copies found /// under these are the PRIMARY class in apply's multi-copy fan-out; /// paths outside them are `gem env` fallback-home copies. pub stores: Vec, @@ -2075,6 +2087,146 @@ mod tests { ); } + // ── `bundle install --standalone` root (#796) ───── + + /// Lay down what `bundle install --standalone` leaves in `/bundle`: + /// the scoped `ruby//gems/` store plus the + /// `bundler/setup.rb` load-path script the app requires. Returns the + /// `gems/` store dir. + async fn stage_standalone_bundle(cwd: &Path, leaf: &str) -> PathBuf { + let root = cwd.join("bundle"); + let gems = root.join("ruby").join("3.3.0").join("gems"); + tokio::fs::create_dir_all(gems.join(leaf).join("lib")) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join("bundler")) + .await + .unwrap(); + tokio::fs::write( + root.join("bundler").join("setup.rb"), + "require 'rbconfig'\n$:.unshift File.expand_path(\"#{__dir__}/../#{RUBY_ENGINE}/#{Gem.ruby_api_version}/gems/rack-3.2.7/lib\")\n", + ) + .await + .unwrap(); + gems + } + + /// Bundler 4 no longer remembers CLI flags, so `bundle install + /// --standalone` writes NO `.bundle/config` — the `./bundle` tree is + /// marked only by its `bundler/setup.rb`. Discovery must still find + /// it: it is the tree the app loads (#796). + #[tokio::test] + async fn standalone_bundle_root_discovered_without_config() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n") + .await + .unwrap(); + let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await; + + let discovery = + RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None).await; + assert_eq!( + discovery.stores, + vec![gems], + "standalone store must be discovered" + ); + // Same class as bundler 2's recorded `BUNDLE_PATH: "bundle"`: an + // explicit project root, which keeps the `gem env` fallback (the + // default gems live there), not the implicit vendor/bundle one. + assert!(!discovery.default_root_has_stores); + } + + /// Without the `bundler/setup.rb` marker a `bundle/` dir is just a + /// project directory (a script folder, a frontend bundle output) and + /// must not be crawled — or patched — as a gem store. + #[tokio::test] + async fn bundle_dir_without_standalone_marker_is_not_a_store() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n") + .await + .unwrap(); + let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await; + tokio::fs::remove_file(dir.path().join("bundle").join("bundler").join("setup.rb")) + .await + .unwrap(); + assert!(gems.is_dir()); + + let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await; + assert!(paths.is_empty(), "no marker, no store; got {paths:?}"); + } + + /// The standalone probe sits behind the same "looks like a Ruby + /// project" gate as the other explicit roots. + #[tokio::test] + async fn standalone_bundle_root_ignored_without_manifest() { + let dir = tempfile::tempdir().unwrap(); + stage_standalone_bundle(dir.path(), "rack-3.2.7").await; + + let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await; + assert!( + paths.is_empty(), + "no Bundler manifest, no store; got {paths:?}" + ); + } + + /// Bundler 2 records `BUNDLE_PATH: "bundle"` for a standalone install, + /// so the config root and the standalone probe name the same tree: it + /// must be scanned (and patched) once. + #[tokio::test] + async fn standalone_bundle_root_dedups_with_bundler2_config_path() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n") + .await + .unwrap(); + let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await; + tokio::fs::create_dir_all(dir.path().join(".bundle")) + .await + .unwrap(); + tokio::fs::write( + dir.path().join(".bundle").join("config"), + "---\nBUNDLE_PATH: \"bundle\"\n", + ) + .await + .unwrap(); + + let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await; + assert_eq!(paths, vec![gems]); + } + + /// The standalone tree ranks after the explicit config/env roots and + /// before the implicit `vendor/bundle` default. + #[tokio::test] + async fn standalone_bundle_root_probes_between_env_and_default() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n") + .await + .unwrap(); + let standalone = stage_standalone_bundle(dir.path(), "rack-3.2.7").await; + let env_root = dir.path().join("envstore"); + let env_gems = env_root.join("ruby").join("3.3.0").join("gems"); + tokio::fs::create_dir_all(env_gems.join("rack-3.2.7").join("lib")) + .await + .unwrap(); + let default_gems = dir + .path() + .join("vendor") + .join("bundle") + .join("ruby") + .join("3.3.0") + .join("gems"); + tokio::fs::create_dir_all(default_gems.join("rack-3.2.7").join("lib")) + .await + .unwrap(); + + let paths = RubyCrawler::get_vendor_bundle_paths_with_env( + dir.path(), + Some(env_root.as_os_str()), + None, + ) + .await; + assert_eq!(paths, vec![env_gems, standalone, default_gems]); + } + // ── config-sourced root containment (untrusted .bundle/config) ─ /// SECURITY: an ABSOLUTE `BUNDLE_PATH` in the (typically committed, diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 62a597332..687c2192c 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -660,6 +660,70 @@ async fn get_gem_paths_env_root_still_includes_gempath_homes() { ); } +/// #796: a Bundler 4 `bundle install --standalone` project has NO +/// `.bundle/config` — only `bundle/bundler/setup.rb` plus the +/// `bundle/ruby//gems/` store the app loads. With the same +/// `name-version` also installed in an ambient gem home, the standalone +/// copy must be discovered, and ranked ahead of the `gem env` homes, so +/// apply patches the copy the app actually loads (and VEX judges it) +/// instead of patching only the ambient copy. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn get_gem_paths_finds_bundler4_standalone_tree_before_gem_homes() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("Gemfile"), + b"source \"https://rubygems.org\"\ngem \"rack\", \"~> 3.1\"\n", + ) + .await + .unwrap(); + let standalone = tmp.path().join("bundle"); + let standalone_gems = standalone.join("ruby").join("3.3.0").join("gems"); + let standalone_copy = stage_gem(&standalone_gems, "rack", "3.2.7").await; + tokio::fs::create_dir_all(standalone.join("bundler")) + .await + .unwrap(); + tokio::fs::write( + standalone.join("bundler").join("setup.rb"), + "require 'rbconfig'\n", + ) + .await + .unwrap(); + + // The ambient copy of the SAME version in the gem home `gem env` reports. + let home = tempfile::tempdir().unwrap(); + let home_gems = home.path().join("gems"); + let ambient_copy = stage_gem(&home_gems, "rack", "3.2.7").await; + let bin = tempfile::tempdir().unwrap(); + install_fake_gem(bin.path(), home.path()); + + let crawler = RubyCrawler; + let options = options_at(tmp.path()); + let paths = with_path(bin.path(), || async { + crawler + .get_gem_paths_with_env(&options, None, None, None, None) + .await + }) + .await + .unwrap(); + assert_eq!( + paths, + vec![standalone_gems.clone(), home_gems.clone()], + "standalone store first, then the gem-env home; got {paths:?}" + ); + + // Every physical copy the apply fan-out iterates includes the + // standalone one, found under the PRIMARY store. + let purls = ["pkg:gem/rack@3.2.7".to_string()]; + let mut found = Vec::new(); + for gems_dir in &paths { + let hits = crawler.find_each_by_purl(gems_dir, &purls).await; + found.extend(hits.into_iter().flatten().map(|p| p.path)); + } + assert_eq!(found, vec![standalone_copy, ambient_copy]); +} + /// Bundler stops before the global path when the environment supplies /// either path flag, even when the flag is false or an empty string. #[tokio::test]