@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
55use super :: types:: { CrawledPackage , CrawlerOptions } ;
66use crate :: patch:: path_safety;
77use crate :: utils:: fs:: {
8- entry_is_dir, home_dir, is_dir, list_dir_entries, normalize_lexically, run_blocking,
8+ entry_is_dir, home_dir, is_dir, is_file , list_dir_entries, normalize_lexically, run_blocking,
99} ;
1010use crate :: utils:: process:: { CommandRunner , SystemCommandRunner } ;
1111
@@ -314,11 +314,18 @@ impl RubyCrawler {
314314 /// value resolves against the project root, matching
315315 /// `Bundler.bundle_path`; a leading `~` expands against home).
316316 /// Trusted as-is: it is the user's own environment.
317- /// 3. the `BUNDLE_PATH:` entry of the global config file
317+ /// 3. `<cwd>/bundle` when it holds `bundler/setup.rb` — the tree
318+ /// `bundle install --standalone` writes and the app loads through
319+ /// that script. Bundler 2 also recorded it as `BUNDLE_PATH:
320+ /// "bundle"` (root 1), but bundler 4 no longer remembers CLI flags
321+ /// and writes no config at all, so the marker is the only trace
322+ /// (#796). It counts as an explicit root, like the recorded path it
323+ /// replaces: the `gem env` fallback stays on for the default gems.
324+ /// 4. the `BUNDLE_PATH:` entry of the global config file
318325 /// ([`bundler_global_config_file`], what `bundle config set --global
319326 /// path <dir>` records) — resolved like the env var, and trusted
320327 /// like it: it is the user's own machine state, not project input.
321- /// 4 . `<cwd>/vendor/bundle` — the default deployment/`--path` location.
328+ /// 5 . `<cwd>/vendor/bundle` — the default deployment/`--path` location.
322329 ///
323330 /// The explicit roots can point anywhere (a machine-wide `BUNDLE_PATH`
324331 /// export must not pull another project's gem store into a non-Ruby
@@ -359,6 +366,10 @@ impl RubyCrawler {
359366 if let Some ( v) = bundle_path_env. filter ( |v| !v. is_empty ( ) ) {
360367 roots. push ( resolve_bundle_path ( cwd, Path :: new ( v) , home) ) ;
361368 }
369+ let standalone_root = cwd. join ( "bundle" ) ;
370+ if is_file ( & standalone_root. join ( "bundler" ) . join ( "setup.rb" ) ) . await {
371+ roots. push ( normalize_lexically ( & standalone_root) . unwrap_or ( standalone_root) ) ;
372+ }
362373 // The global config's path (`bundle config set --global path`)
363374 // is the user's own machine state, so it is trusted like the
364375 // env var: no containment guard. Bundler's `Settings#path`
@@ -851,7 +862,8 @@ fn verify_gem_at_path_sync(path: &Path) -> bool {
851862/// JSON envelope is the CLI's job, where `--silent`/`--json` gating lives.
852863pub struct BundleStoreDiscovery {
853864 /// The discovered installed-gem `gems/` stores, in root-precedence
854- /// order (local config > env > default `vendor/bundle`). Copies found
865+ /// order (local config > env > standalone `bundle/` > default
866+ /// `vendor/bundle`). Copies found
855867 /// under these are the PRIMARY class in apply's multi-copy fan-out;
856868 /// paths outside them are `gem env` fallback-home copies.
857869 pub stores : Vec < PathBuf > ,
@@ -2075,6 +2087,146 @@ mod tests {
20752087 ) ;
20762088 }
20772089
2090+ // ── `bundle install --standalone` root (#796) ─────
2091+
2092+ /// Lay down what `bundle install --standalone` leaves in `<cwd>/bundle`:
2093+ /// the scoped `ruby/<abi>/gems/<leaf>` store plus the
2094+ /// `bundler/setup.rb` load-path script the app requires. Returns the
2095+ /// `gems/` store dir.
2096+ async fn stage_standalone_bundle ( cwd : & Path , leaf : & str ) -> PathBuf {
2097+ let root = cwd. join ( "bundle" ) ;
2098+ let gems = root. join ( "ruby" ) . join ( "3.3.0" ) . join ( "gems" ) ;
2099+ tokio:: fs:: create_dir_all ( gems. join ( leaf) . join ( "lib" ) )
2100+ . await
2101+ . unwrap ( ) ;
2102+ tokio:: fs:: create_dir_all ( root. join ( "bundler" ) )
2103+ . await
2104+ . unwrap ( ) ;
2105+ tokio:: fs:: write (
2106+ root. join ( "bundler" ) . join ( "setup.rb" ) ,
2107+ "require 'rbconfig'\n $:.unshift File.expand_path(\" #{__dir__}/../#{RUBY_ENGINE}/#{Gem.ruby_api_version}/gems/rack-3.2.7/lib\" )\n " ,
2108+ )
2109+ . await
2110+ . unwrap ( ) ;
2111+ gems
2112+ }
2113+
2114+ /// Bundler 4 no longer remembers CLI flags, so `bundle install
2115+ /// --standalone` writes NO `.bundle/config` — the `./bundle` tree is
2116+ /// marked only by its `bundler/setup.rb`. Discovery must still find
2117+ /// it: it is the tree the app loads (#796).
2118+ #[ tokio:: test]
2119+ async fn standalone_bundle_root_discovered_without_config ( ) {
2120+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2121+ tokio:: fs:: write ( dir. path ( ) . join ( "Gemfile" ) , b"gem \" rack\" \n " )
2122+ . await
2123+ . unwrap ( ) ;
2124+ let gems = stage_standalone_bundle ( dir. path ( ) , "rack-3.2.7" ) . await ;
2125+
2126+ let discovery =
2127+ RubyCrawler :: discover_bundle_stores_with_env ( dir. path ( ) , None , None , None , None ) . await ;
2128+ assert_eq ! (
2129+ discovery. stores,
2130+ vec![ gems] ,
2131+ "standalone store must be discovered"
2132+ ) ;
2133+ // Same class as bundler 2's recorded `BUNDLE_PATH: "bundle"`: an
2134+ // explicit project root, which keeps the `gem env` fallback (the
2135+ // default gems live there), not the implicit vendor/bundle one.
2136+ assert ! ( !discovery. default_root_has_stores) ;
2137+ }
2138+
2139+ /// Without the `bundler/setup.rb` marker a `bundle/` dir is just a
2140+ /// project directory (a script folder, a frontend bundle output) and
2141+ /// must not be crawled — or patched — as a gem store.
2142+ #[ tokio:: test]
2143+ async fn bundle_dir_without_standalone_marker_is_not_a_store ( ) {
2144+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2145+ tokio:: fs:: write ( dir. path ( ) . join ( "Gemfile" ) , b"gem \" rack\" \n " )
2146+ . await
2147+ . unwrap ( ) ;
2148+ let gems = stage_standalone_bundle ( dir. path ( ) , "rack-3.2.7" ) . await ;
2149+ tokio:: fs:: remove_file ( dir. path ( ) . join ( "bundle" ) . join ( "bundler" ) . join ( "setup.rb" ) )
2150+ . await
2151+ . unwrap ( ) ;
2152+ assert ! ( gems. is_dir( ) ) ;
2153+
2154+ let paths = RubyCrawler :: get_vendor_bundle_paths_with_env ( dir. path ( ) , None , None ) . await ;
2155+ assert ! ( paths. is_empty( ) , "no marker, no store; got {paths:?}" ) ;
2156+ }
2157+
2158+ /// The standalone probe sits behind the same "looks like a Ruby
2159+ /// project" gate as the other explicit roots.
2160+ #[ tokio:: test]
2161+ async fn standalone_bundle_root_ignored_without_manifest ( ) {
2162+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2163+ stage_standalone_bundle ( dir. path ( ) , "rack-3.2.7" ) . await ;
2164+
2165+ let paths = RubyCrawler :: get_vendor_bundle_paths_with_env ( dir. path ( ) , None , None ) . await ;
2166+ assert ! (
2167+ paths. is_empty( ) ,
2168+ "no Bundler manifest, no store; got {paths:?}"
2169+ ) ;
2170+ }
2171+
2172+ /// Bundler 2 records `BUNDLE_PATH: "bundle"` for a standalone install,
2173+ /// so the config root and the standalone probe name the same tree: it
2174+ /// must be scanned (and patched) once.
2175+ #[ tokio:: test]
2176+ async fn standalone_bundle_root_dedups_with_bundler2_config_path ( ) {
2177+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2178+ tokio:: fs:: write ( dir. path ( ) . join ( "Gemfile" ) , b"gem \" rack\" \n " )
2179+ . await
2180+ . unwrap ( ) ;
2181+ let gems = stage_standalone_bundle ( dir. path ( ) , "rack-3.2.7" ) . await ;
2182+ tokio:: fs:: create_dir_all ( dir. path ( ) . join ( ".bundle" ) )
2183+ . await
2184+ . unwrap ( ) ;
2185+ tokio:: fs:: write (
2186+ dir. path ( ) . join ( ".bundle" ) . join ( "config" ) ,
2187+ "---\n BUNDLE_PATH: \" bundle\" \n " ,
2188+ )
2189+ . await
2190+ . unwrap ( ) ;
2191+
2192+ let paths = RubyCrawler :: get_vendor_bundle_paths_with_env ( dir. path ( ) , None , None ) . await ;
2193+ assert_eq ! ( paths, vec![ gems] ) ;
2194+ }
2195+
2196+ /// The standalone tree ranks after the explicit config/env roots and
2197+ /// before the implicit `vendor/bundle` default.
2198+ #[ tokio:: test]
2199+ async fn standalone_bundle_root_probes_between_env_and_default ( ) {
2200+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2201+ tokio:: fs:: write ( dir. path ( ) . join ( "Gemfile" ) , b"gem \" rack\" \n " )
2202+ . await
2203+ . unwrap ( ) ;
2204+ let standalone = stage_standalone_bundle ( dir. path ( ) , "rack-3.2.7" ) . await ;
2205+ let env_root = dir. path ( ) . join ( "envstore" ) ;
2206+ let env_gems = env_root. join ( "ruby" ) . join ( "3.3.0" ) . join ( "gems" ) ;
2207+ tokio:: fs:: create_dir_all ( env_gems. join ( "rack-3.2.7" ) . join ( "lib" ) )
2208+ . await
2209+ . unwrap ( ) ;
2210+ let default_gems = dir
2211+ . path ( )
2212+ . join ( "vendor" )
2213+ . join ( "bundle" )
2214+ . join ( "ruby" )
2215+ . join ( "3.3.0" )
2216+ . join ( "gems" ) ;
2217+ tokio:: fs:: create_dir_all ( default_gems. join ( "rack-3.2.7" ) . join ( "lib" ) )
2218+ . await
2219+ . unwrap ( ) ;
2220+
2221+ let paths = RubyCrawler :: get_vendor_bundle_paths_with_env (
2222+ dir. path ( ) ,
2223+ Some ( env_root. as_os_str ( ) ) ,
2224+ None ,
2225+ )
2226+ . await ;
2227+ assert_eq ! ( paths, vec![ env_gems, standalone, default_gems] ) ;
2228+ }
2229+
20782230 // ── config-sourced root containment (untrusted .bundle/config) ─
20792231
20802232 /// SECURITY: an ABSOLUTE `BUNDLE_PATH` in the (typically committed,
0 commit comments