@@ -192,12 +192,14 @@ struct StaleInstallOutcome {
192192/// behind silently reinstates the stale bytes) — while a SHARED gem-env
193193/// home affects every project on the machine, so that flavor prefers moving
194194/// the project to a local bundle path and only conditionally names the
195- /// shared files.
195+ /// shared files. `project_local` is the caller's call: under the project
196+ /// root, or under the project's own `.bundle/config` path even when that
197+ /// path sits outside the tree (#709).
196198fn gem_stale_install_warning (
197199 purl : & str ,
198200 gem_dir : & Path ,
199201 leaf : & str ,
200- cwd : & Path ,
202+ project_local : bool ,
201203 project_cache_gem : Option < & Path > ,
202204) -> serde_json:: Value {
203205 let home = gem_dir
@@ -215,7 +217,7 @@ fn gem_stale_install_warning(
215217 paths. push ( extra. display ( ) . to_string ( ) ) ;
216218 }
217219 let list = paths. join ( ", " ) ;
218- let detail = if gem_dir . starts_with ( cwd ) {
220+ let detail = if project_local {
219221 format ! (
220222 "{purl} was switched to its hosted patch, but a stale \
221223 UNPATCHED install is already materialized at {} — `bundle install` \
@@ -293,7 +295,9 @@ async fn installed_stale_positive_evidence(
293295/// * Discovery is [`socket_patch_core::crawlers::RubyCrawler`] — the same
294296/// installed-gem APIs `apply` uses, honoring `--global`/`--global-prefix`
295297/// exactly like scan's own discovery; layouts the crawler grows into are
296- /// covered automatically.
298+ /// covered automatically. A `.bundle/config` path the containment guard
299+ /// refuses as a write root is still READ here
300+ /// (`verification_only_gem_paths`): bundler installs into it.
297301/// * Records are found BY UUID (the fetch key, stable across purl
298302/// spellings): this run's fetched records first, then the redirect
299303/// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed
@@ -364,7 +368,17 @@ async fn gem_stale_install_warnings(
364368 global,
365369 global_prefix,
366370 } ;
367- let gem_paths = crawler. get_gem_paths ( & options) . await . unwrap_or_default ( ) ;
371+ let mut gem_paths = crawler. get_gem_paths ( & options) . await . unwrap_or_default ( ) ;
372+ // A `.bundle/config` path outside the project is refused as a write
373+ // root, yet bundler installs into and loads from it: read it too, or a
374+ // stale materialization there never warns (#709). It is this project's
375+ // own bundle path, so it takes the project-local remedy.
376+ let config_stores = crawler. verification_only_gem_paths ( & options) . await ;
377+ for gems_dir in & config_stores {
378+ if !gem_paths. contains ( gems_dir) {
379+ gem_paths. push ( gems_dir. clone ( ) ) ;
380+ }
381+ }
368382 // Every candidate's installed dir in every gem home, one blocking pass
369383 // (and at most one listing) per home — the per-candidate lookups the
370384 // loop below consumes, in the same (candidate, home) order.
@@ -419,8 +433,10 @@ async fn gem_stale_install_warnings(
419433 if j. patched || !j. positive {
420434 continue ;
421435 }
436+ let project_local =
437+ dir. starts_with ( cwd) || config_stores. iter ( ) . any ( |store| dir. starts_with ( store) ) ;
422438 let mut folded_cache: Option < std:: path:: PathBuf > = None ;
423- if dir . starts_with ( cwd ) {
439+ if project_local {
424440 let project_cache = app_cache. join ( format ! ( "{}.gem" , j. leaf) ) ;
425441 if project_cache. is_file ( ) {
426442 let proven_patched = match (
@@ -446,7 +462,7 @@ async fn gem_stale_install_warnings(
446462 & j. purl ,
447463 dir,
448464 & j. leaf ,
449- cwd ,
465+ project_local ,
450466 folded_cache. as_deref ( ) ,
451467 ) ) ;
452468 out. stale_purls . insert ( j. purl . clone ( ) ) ;
@@ -3291,7 +3307,13 @@ mod tests {
32913307 let cwd = PathBuf :: from ( "proj" ) ;
32923308 let home = gem_home ( & cwd) ;
32933309 let gem_dir = home. join ( "gems" ) . join ( GEM_LEAF ) ;
3294- let w = gem_stale_install_warning ( GEM_PURL , & gem_dir, GEM_LEAF , & cwd, None ) ;
3310+ let w = gem_stale_install_warning (
3311+ GEM_PURL ,
3312+ & gem_dir,
3313+ GEM_LEAF ,
3314+ gem_dir. starts_with ( & cwd) ,
3315+ None ,
3316+ ) ;
32953317 let detail = detail_of ( & w) ;
32963318 assert ! ( detail. contains( GEM_PURL ) , "{detail}" ) ;
32973319 assert ! ( detail. contains( & gem_dir. display( ) . to_string( ) ) , "{detail}" ) ;
@@ -3326,7 +3348,13 @@ mod tests {
33263348 let cwd = PathBuf :: from ( "proj" ) ;
33273349 let home = PathBuf :: from ( "shared-gem-home" ) . join ( "ruby" ) . join ( "3.3.0" ) ;
33283350 let gem_dir = home. join ( "gems" ) . join ( GEM_LEAF ) ;
3329- let w = gem_stale_install_warning ( GEM_PURL , & gem_dir, GEM_LEAF , & cwd, None ) ;
3351+ let w = gem_stale_install_warning (
3352+ GEM_PURL ,
3353+ & gem_dir,
3354+ GEM_LEAF ,
3355+ gem_dir. starts_with ( & cwd) ,
3356+ None ,
3357+ ) ;
33303358 let detail = detail_of ( & w) ;
33313359 assert ! ( detail. contains( "shared gem home" ) , "{detail}" ) ;
33323360 assert ! (
@@ -3356,7 +3384,13 @@ mod tests {
33563384 . join ( "vendor" )
33573385 . join ( "cache" )
33583386 . join ( format ! ( "{GEM_LEAF}.gem" ) ) ;
3359- let w = gem_stale_install_warning ( GEM_PURL , & gem_dir, GEM_LEAF , & cwd, Some ( & committed) ) ;
3387+ let w = gem_stale_install_warning (
3388+ GEM_PURL ,
3389+ & gem_dir,
3390+ GEM_LEAF ,
3391+ gem_dir. starts_with ( & cwd) ,
3392+ Some ( & committed) ,
3393+ ) ;
33603394 let detail = detail_of ( & w) ;
33613395 assert ! (
33623396 detail. contains( & committed. display( ) . to_string( ) ) ,
@@ -3484,6 +3518,57 @@ mod tests {
34843518 assert ! ( out. warnings. is_empty( ) ) ;
34853519 }
34863520
3521+ /// #709: a `.bundle/config` `path` outside the project is refused as an
3522+ /// install (write) root, but bundler still installs into and loads
3523+ /// from it — so the probe must read it, or a stale materialization
3524+ /// there never warns and the purl stays in the same-run `--vex`
3525+ /// `assume_applied` set.
3526+ #[ tokio:: test]
3527+ async fn gem_stale_probe_reads_refused_out_of_tree_config_path ( ) {
3528+ let proj = tempfile:: tempdir ( ) . unwrap ( ) ;
3529+ let outside = tempfile:: tempdir ( ) . unwrap ( ) ;
3530+ std:: fs:: write ( proj. path ( ) . join ( "Gemfile" ) , b"gem \" stale-unit\" \n " ) . unwrap ( ) ;
3531+ std:: fs:: create_dir_all ( proj. path ( ) . join ( ".bundle" ) ) . unwrap ( ) ;
3532+ std:: fs:: write (
3533+ proj. path ( ) . join ( ".bundle" ) . join ( "config" ) ,
3534+ format ! ( "---\n BUNDLE_PATH: \" {}\" \n " , outside. path( ) . display( ) ) ,
3535+ )
3536+ . unwrap ( ) ;
3537+ // Bundler's scoped layout under the configured root.
3538+ let gem_dir = outside
3539+ . path ( )
3540+ . join ( "ruby" )
3541+ . join ( "3.3.0" )
3542+ . join ( "gems" )
3543+ . join ( GEM_LEAF ) ;
3544+ std:: fs:: create_dir_all ( gem_dir. join ( "lib" ) ) . unwrap ( ) ;
3545+ std:: fs:: write ( gem_dir. join ( "lib" ) . join ( "stale_unit.rb" ) , GEM_UPSTREAM ) . unwrap ( ) ;
3546+
3547+ let out = probe ( proj. path ( ) , & one_confirmed ( ) , & one_record ( ) ) . await ;
3548+ assert_eq ! ( out. warnings. len( ) , 1 , "{:?}" , out. warnings) ;
3549+ let detail = detail_of ( & out. warnings [ 0 ] ) ;
3550+ assert ! ( detail. contains( & gem_dir. display( ) . to_string( ) ) , "{detail}" ) ;
3551+ // The project's own bundle path, not a machine-wide gem home: the
3552+ // verified delete-list remedy, not the shared-home caveat.
3553+ assert ! ( detail. contains( "Remove the stale" ) , "{detail}" ) ;
3554+ assert ! ( !detail. contains( "shared gem home" ) , "{detail}" ) ;
3555+ assert_eq ! (
3556+ out. stale_purls,
3557+ std:: collections:: BTreeSet :: from( [ GEM_PURL . to_string( ) ] )
3558+ ) ;
3559+ // Read-only: the refused root is never written.
3560+ assert_eq ! (
3561+ std:: fs:: read( gem_dir. join( "lib" ) . join( "stale_unit.rb" ) ) . unwrap( ) ,
3562+ GEM_UPSTREAM
3563+ ) ;
3564+
3565+ // A patched materialization there stays quiet.
3566+ std:: fs:: write ( gem_dir. join ( "lib" ) . join ( "stale_unit.rb" ) , GEM_PATCHED ) . unwrap ( ) ;
3567+ let out = probe ( proj. path ( ) , & one_confirmed ( ) , & one_record ( ) ) . await ;
3568+ assert ! ( out. warnings. is_empty( ) , "{:?}" , out. warnings) ;
3569+ assert ! ( out. stale_purls. is_empty( ) ) ;
3570+ }
3571+
34873572 /// FALSE-POSITIVE hardening: an install whose record file is MISSING
34883573 /// (or unreadable — same NotFound class) is not positive evidence, so
34893574 /// the probe stays quiet instead of prescribing deletion on a tree it
0 commit comments